From e16251febac9b000c50969845025bc4a1b54e67a Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Fri, 6 Sep 2024 18:38:41 -0400 Subject: [PATCH] compose_box: Cast inset shadow for scrollable contents. This also supports horizontal scrolling, to later support more compose box icons. But we leave those for later. Fixex: #915 Signed-off-by: Zixuan James Li --- lib/widgets/compose_box.dart | 98 ++++++++++++++++++++++++------ test/widgets/compose_box_test.dart | 30 +++++++++ 2 files changed, 109 insertions(+), 19 deletions(-) diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart index 66a2bb1069..16663018a1 100644 --- a/lib/widgets/compose_box.dart +++ b/lib/widgets/compose_box.dart @@ -300,28 +300,88 @@ class _ContentInput extends StatelessWidget { narrow: narrow, controller: controller, focusNode: focusNode, - fieldViewBuilder: (context) => SingleChildScrollView( - // While the [TextField] is scrollable, we need to wrap it with - // [SingleChildScrollView] to prepend a fixed-height padding that can - // be scrolled along with the text. - child: Padding( - padding: const EdgeInsets.only(top: topPadding), - 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)))))); + fieldViewBuilder: (context) => _ShadowBox( + color: designVariables.bgComposeBox, + child: SingleChildScrollView( + // While the [TextField] is scrollable, we need to wrap it with + // [SingleChildScrollView] to prepend a fixed-height padding that can + // be scrolled along with the text. + child: Padding( + padding: const EdgeInsets.only(top: topPadding), + 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 e0c264a9e4..f7de77de75 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 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; + }); }