diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart index 18d125afd0..4d9c0aad6b 100644 --- a/lib/widgets/compose_box.dart +++ b/lib/widgets/compose_box.dart @@ -300,22 +300,82 @@ class _ContentInput extends StatelessWidget { narrow: narrow, controller: controller, focusNode: focusNode, - fieldViewBuilder: (context) => TextField( - controller: controller, - focusNode: focusNode, - decoration: InputDecoration.collapsed( - hintText: hintText, - hintStyle: TextStyle(color: designVariables.textInput.withOpacity(0.5))), - minLines: 2, - maxLines: null, - textCapitalization: TextCapitalization.sentences, - style: TextStyle( - fontSize: 17, - height: (contentLineHeight / 17), - color: designVariables.textInput)))); + fieldViewBuilder: (context) => _ShadowBox( + color: designVariables.bgComposeBox, + child: TextField( + controller: controller, + focusNode: focusNode, + decoration: InputDecoration.collapsed( + hintText: hintText, + hintStyle: TextStyle(color: designVariables.textInput.withOpacity(0.5))), + minLines: 2, + maxLines: null, + textCapitalization: TextCapitalization.sentences, + style: TextStyle( + fontSize: 17, + height: (contentLineHeight / 17), + color: designVariables.textInput)), + ))); + } +} + +/// Overlay inset shadows on the child from all scrollable directions. +class _ShadowBox extends StatefulWidget { + const _ShadowBox({required this.color, required this.child}); + + final Color color; + final Widget child; + + @override + State<_ShadowBox> createState() => _ShadowBoxState(); +} + +class _ShadowBoxState extends State<_ShadowBox> { + bool showTopShadow = false; bool showBottomShadow = false; + bool showLeftShadow = false; bool showRightShadow = false; + + bool handleScroll(ScrollNotification notification) { + final metrics = notification.metrics; + setState(() { + switch (metrics.axisDirection) { + case AxisDirection.up: + case AxisDirection.down: + showTopShadow = metrics.extentBefore != 0; + showBottomShadow = metrics.extentAfter != 0; + case AxisDirection.right: + case AxisDirection.left: + showLeftShadow = metrics.extentBefore != 0; + showRightShadow = metrics.extentAfter != 0; + } + }); + return false; + } + + @override + Widget build(BuildContext context) { + BoxDecoration shadowFrom(AlignmentGeometry begin) => + BoxDecoration(gradient: LinearGradient(begin: begin, end: -begin, + colors: [widget.color, widget.color.withOpacity(0)])); + + return NotificationListener( + onNotification: handleScroll, + child: Stack( + children: [ + widget.child, + if (showTopShadow) Positioned(top: 0, left: 0, right: 0, + child: Container(height: 8, decoration: shadowFrom(Alignment.topCenter))), + if (showBottomShadow) Positioned(bottom: 0, left: 0, right: 0, + child: Container(height: 8, decoration: shadowFrom(Alignment.bottomCenter))), + if (showLeftShadow) Positioned(left: 0, top: 0, bottom: 0, + child: Container(width: 8, decoration: shadowFrom(Alignment.centerLeft))), + if (showRightShadow) Positioned(right: 0, top: 0, bottom: 0, + child: Container(width: 8, decoration: shadowFrom(Alignment.centerRight))), + ], + )); } } + /// The content input for _StreamComposeBox. class _StreamContentInput extends StatefulWidget { const _StreamContentInput({ diff --git a/test/widgets/compose_box_test.dart b/test/widgets/compose_box_test.dart index 5fd72bdbc7..077f384a88 100644 --- a/test/widgets/compose_box_test.dart +++ b/test/widgets/compose_box_test.dart @@ -481,4 +481,34 @@ void main() { }); }); }); + + testWidgets('cast shadow when scrollable', (tester) async { + Finder shadowFinderFrom(Alignment alignment) => find.byWidgetPredicate((widget) => + widget is Container && ((widget.decoration as BoxDecoration?)?.gradient as LinearGradient?)?.begin == alignment); + + await prepareComposeBox(tester, narrow: const TopicNarrow(123, 'some topic')); + check(shadowFinderFrom(Alignment.topCenter).evaluate()).isEmpty(); + check(shadowFinderFrom(Alignment.bottomCenter).evaluate()).isEmpty(); + + final contentInputFinder = find.byWidgetPredicate( + (widget) => widget is TextField && widget.controller is ComposeContentController); + + // Entering more than 7 lines to fully extend the compose box. + await tester.enterText(contentInputFinder, 'newlines\n' * 8); + await tester.pumpAndSettle(); + check(shadowFinderFrom(Alignment.topCenter).evaluate()).single; + check(shadowFinderFrom(Alignment.bottomCenter).evaluate()).isEmpty(); + + // Scroll back up and the bottom shadow should be visible now. + await tester.drag(contentInputFinder, const Offset(0, 22)); + await tester.pumpAndSettle(); + check(shadowFinderFrom(Alignment.topCenter).evaluate()).single; + check(shadowFinderFrom(Alignment.bottomCenter).evaluate()).single; + + // Scroll back to the top and the top shadow is no longer visible. + await tester.drag(contentInputFinder, const Offset(0, 99)); + await tester.pumpAndSettle(); + check(shadowFinderFrom(Alignment.topCenter).evaluate()).isEmpty(); + check(shadowFinderFrom(Alignment.bottomCenter).evaluate()).single; + }); }