Skip to content

Commit 340a5ce

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 9c81498 commit 340a5ce

File tree

2 files changed

+101
-13
lines changed

2 files changed

+101
-13
lines changed

lib/widgets/compose_box.dart

+71-13
Original file line numberDiff line numberDiff line change
@@ -300,19 +300,77 @@ 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.withValues(alpha: 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.withValues(alpha: 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+
/// Overlay inset shadows on the child from all scrollable directions.
322+
class _ShadowBox extends StatefulWidget {
323+
const _ShadowBox({required this.color, required this.child});
324+
325+
final Color color;
326+
final Widget child;
327+
328+
@override
329+
State<_ShadowBox> createState() => _ShadowBoxState();
330+
}
331+
332+
class _ShadowBoxState extends State<_ShadowBox> {
333+
bool showTopShadow = false; bool showBottomShadow = false;
334+
bool showLeftShadow = false; bool showRightShadow = false;
335+
336+
bool handleScroll(ScrollNotification notification) {
337+
final metrics = notification.metrics;
338+
setState(() {
339+
switch (metrics.axisDirection) {
340+
case AxisDirection.up:
341+
case AxisDirection.down:
342+
showTopShadow = metrics.extentBefore != 0;
343+
showBottomShadow = metrics.extentAfter != 0;
344+
case AxisDirection.right:
345+
case AxisDirection.left:
346+
showLeftShadow = metrics.extentBefore != 0;
347+
showRightShadow = metrics.extentAfter != 0;
348+
}
349+
});
350+
return false;
351+
}
352+
353+
@override
354+
Widget build(BuildContext context) {
355+
BoxDecoration shadowFrom(AlignmentGeometry begin) =>
356+
BoxDecoration(gradient: LinearGradient(begin: begin, end: -begin,
357+
colors: [widget.color, widget.color.withValues(alpha: 0)]));
358+
359+
return NotificationListener<ScrollNotification>(
360+
onNotification: handleScroll,
361+
child: Stack(
362+
children: [
363+
widget.child,
364+
if (showTopShadow) Positioned(top: 0, left: 0, right: 0,
365+
child: Container(height: 8, decoration: shadowFrom(Alignment.topCenter))),
366+
if (showBottomShadow) Positioned(bottom: 0, left: 0, right: 0,
367+
child: Container(height: 8, decoration: shadowFrom(Alignment.bottomCenter))),
368+
if (showLeftShadow) Positioned(left: 0, top: 0, bottom: 0,
369+
child: Container(width: 8, decoration: shadowFrom(Alignment.centerLeft))),
370+
if (showRightShadow) Positioned(right: 0, top: 0, bottom: 0,
371+
child: Container(width: 8, decoration: shadowFrom(Alignment.centerRight))),
372+
],
373+
));
316374
}
317375
}
318376

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)