Skip to content

Commit c96e62c

Browse files
committed
compose_box: Cast inset shadow for scrollable contents.
This also supports horizontal scrolling, to later add shadow when we have more compose box icons. Fixex: zulip#915 Signed-off-by: Zixuan James Li <[email protected]>
1 parent 6ec4857 commit c96e62c

File tree

2 files changed

+103
-13
lines changed

2 files changed

+103
-13
lines changed

lib/widgets/compose_box.dart

+73-13
Original file line numberDiff line numberDiff line change
@@ -300,22 +300,82 @@ class _ContentInput extends StatelessWidget {
300300
narrow: narrow,
301301
controller: controller,
302302
focusNode: focusNode,
303-
fieldViewBuilder: (context) => TextField(
304-
controller: controller,
305-
focusNode: focusNode,
306-
decoration: InputDecoration.collapsed(
307-
hintText: hintText,
308-
hintStyle: TextStyle(color: designVariables.textInput.withOpacity(0.5))),
309-
minLines: 2,
310-
maxLines: null,
311-
textCapitalization: TextCapitalization.sentences,
312-
style: TextStyle(
313-
fontSize: 17,
314-
height: (contentLineHeight / 17),
315-
color: designVariables.textInput))));
303+
fieldViewBuilder: (context) => _ShadowBox(
304+
color: designVariables.bgComposeBox,
305+
child: TextField(
306+
controller: controller,
307+
focusNode: focusNode,
308+
decoration: InputDecoration.collapsed(
309+
hintText: hintText,
310+
hintStyle: TextStyle(color: designVariables.textInput.withOpacity(0.5))),
311+
minLines: 2,
312+
maxLines: null,
313+
textCapitalization: TextCapitalization.sentences,
314+
style: TextStyle(
315+
fontSize: 17,
316+
height: (contentLineHeight / 17),
317+
color: designVariables.textInput)),
318+
)));
319+
}
320+
}
321+
322+
/// Overlay inset shadows on the child from all scrollable directions.
323+
class _ShadowBox extends StatefulWidget {
324+
const _ShadowBox({required this.color, required this.child});
325+
326+
final Color color;
327+
final Widget child;
328+
329+
@override
330+
State<_ShadowBox> createState() => _ShadowBoxState();
331+
}
332+
333+
class _ShadowBoxState extends State<_ShadowBox> {
334+
bool showTopShadow = false; bool showBottomShadow = false;
335+
bool showLeftShadow = false; bool showRightShadow = false;
336+
337+
bool handleScroll(ScrollNotification notification) {
338+
final metrics = notification.metrics;
339+
setState(() {
340+
switch (metrics.axisDirection) {
341+
case AxisDirection.up:
342+
case AxisDirection.down:
343+
showTopShadow = metrics.extentBefore != 0;
344+
showBottomShadow = metrics.extentAfter != 0;
345+
case AxisDirection.right:
346+
case AxisDirection.left:
347+
showLeftShadow = metrics.extentBefore != 0;
348+
showRightShadow = metrics.extentAfter != 0;
349+
}
350+
});
351+
return false;
352+
}
353+
354+
@override
355+
Widget build(BuildContext context) {
356+
BoxDecoration shadowFrom(AlignmentGeometry begin) =>
357+
BoxDecoration(gradient: LinearGradient(begin: begin, end: -begin,
358+
colors: [widget.color, widget.color.withOpacity(0)]));
359+
360+
return NotificationListener<ScrollNotification>(
361+
onNotification: handleScroll,
362+
child: Stack(
363+
children: [
364+
widget.child,
365+
if (showTopShadow) Positioned(top: 0, left: 0, right: 0,
366+
child: Container(height: 8, decoration: shadowFrom(Alignment.topCenter))),
367+
if (showBottomShadow) Positioned(bottom: 0, left: 0, right: 0,
368+
child: Container(height: 8, decoration: shadowFrom(Alignment.bottomCenter))),
369+
if (showLeftShadow) Positioned(left: 0, top: 0, bottom: 0,
370+
child: Container(width: 8, decoration: shadowFrom(Alignment.centerLeft))),
371+
if (showRightShadow) Positioned(right: 0, top: 0, bottom: 0,
372+
child: Container(width: 8, decoration: shadowFrom(Alignment.centerRight))),
373+
],
374+
));
316375
}
317376
}
318377

378+
319379
/// The content input for _StreamComposeBox.
320380
class _StreamContentInput extends StatefulWidget {
321381
const _StreamContentInput({

test/widgets/compose_box_test.dart

+30
Original file line numberDiff line numberDiff line change
@@ -481,4 +481,34 @@ void main() {
481481
});
482482
});
483483
});
484+
485+
testWidgets('cast shadow when scrollable', (tester) async {
486+
Finder shadowFinderFrom(Alignment alignment) => find.byWidgetPredicate((widget) =>
487+
widget is Container && ((widget.decoration as BoxDecoration?)?.gradient as LinearGradient?)?.begin == alignment);
488+
489+
await prepareComposeBox(tester, narrow: const TopicNarrow(123, 'some topic'));
490+
check(shadowFinderFrom(Alignment.topCenter).evaluate()).isEmpty();
491+
check(shadowFinderFrom(Alignment.bottomCenter).evaluate()).isEmpty();
492+
493+
final contentInputFinder = find.byWidgetPredicate(
494+
(widget) => widget is TextField && widget.controller is ComposeContentController);
495+
496+
// Entering more than 7 lines to fully extend the compose box.
497+
await tester.enterText(contentInputFinder, 'newlines\n' * 8);
498+
await tester.pumpAndSettle();
499+
check(shadowFinderFrom(Alignment.topCenter).evaluate()).single;
500+
check(shadowFinderFrom(Alignment.bottomCenter).evaluate()).isEmpty();
501+
502+
// Scroll back up and the bottom shadow should be visible now.
503+
await tester.drag(contentInputFinder, const Offset(0, 22));
504+
await tester.pumpAndSettle();
505+
check(shadowFinderFrom(Alignment.topCenter).evaluate()).single;
506+
check(shadowFinderFrom(Alignment.bottomCenter).evaluate()).single;
507+
508+
// Scroll back to the top and the top shadow is no longer visible.
509+
await tester.drag(contentInputFinder, const Offset(0, 99));
510+
await tester.pumpAndSettle();
511+
check(shadowFinderFrom(Alignment.topCenter).evaluate()).isEmpty();
512+
check(shadowFinderFrom(Alignment.bottomCenter).evaluate()).single;
513+
});
484514
}

0 commit comments

Comments
 (0)