Skip to content

Commit

Permalink
compose_box: Cast inset shadow for scrollable contents.
Browse files Browse the repository at this point in the history
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 <[email protected]>
  • Loading branch information
PIG208 committed Sep 6, 2024
1 parent 9ee3ed0 commit e16251f
Show file tree
Hide file tree
Showing 2 changed files with 109 additions and 19 deletions.
98 changes: 79 additions & 19 deletions lib/widgets/compose_box.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<ScrollNotification>(
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({
Expand Down
30 changes: 30 additions & 0 deletions test/widgets/compose_box_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
});
}

0 comments on commit e16251f

Please sign in to comment.