Skip to content

Commit 7cf5caa

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 6a798f7 commit 7cf5caa

File tree

2 files changed

+113
-25
lines changed

2 files changed

+113
-25
lines changed

lib/widgets/compose_box.dart

+83-25
Original file line numberDiff line numberDiff line change
@@ -296,33 +296,91 @@ class _ContentInput extends StatelessWidget {
296296
// clip the 8th line, where the height matches the spec of 178 logical
297297
// pixels. The partial line hints that the content input is scrollable.
298298
maxHeight: topPadding + contentLineHeight * 7 + contentLineHeight * 0.727),
299-
child: ClipRect(
300-
child: ComposeAutocomplete(
301-
narrow: narrow,
302-
controller: controller,
303-
focusNode: focusNode,
304-
fieldViewBuilder: (context) => TextField(
299+
child: ClipRect(
300+
child: ComposeAutocomplete(
301+
narrow: narrow,
305302
controller: controller,
306303
focusNode: focusNode,
307-
// `contentPadding` causes the text to be clipped while leaving
308-
// a gap to the top border, because it shrinks the size of the
309-
// body of `TextField`. Overriding this gives us full control
310-
// over the clipping behavior with the `ConstrainedBox`.
311-
clipBehavior: Clip.none,
312-
minLines: 2,
313-
maxLines: null,
314-
textCapitalization: TextCapitalization.sentences,
315-
style: TextStyle(
316-
fontSize: 17,
317-
height: (contentLineHeight / 17),
318-
color: designVariables.textInput),
319-
decoration: InputDecoration(
320-
isDense: true,
321-
border: InputBorder.none,
322-
contentPadding: const EdgeInsets.only(top: topPadding),
323-
hintText: hintText,
324-
hintStyle: TextStyle(
325-
color: designVariables.textInput.withValues(alpha: 0.5)))))));
304+
fieldViewBuilder: (context) => _ShadowBox(
305+
color: designVariables.bgComposeBox,
306+
child: TextField(
307+
controller: controller,
308+
focusNode: focusNode,
309+
// `contentPadding` causes the text to be clipped while leaving
310+
// a gap to the top border, because it shrinks the size of the
311+
// body of `TextField`. Overriding this gives us full control
312+
// over the clipping behavior with the `ConstrainedBox`.
313+
clipBehavior: Clip.none,
314+
minLines: 2,
315+
maxLines: null,
316+
textCapitalization: TextCapitalization.sentences,
317+
style: TextStyle(
318+
fontSize: 17,
319+
height: (contentLineHeight / 17),
320+
color: designVariables.textInput),
321+
decoration: InputDecoration(
322+
isDense: true,
323+
border: InputBorder.none,
324+
contentPadding: const EdgeInsets.only(top: topPadding),
325+
hintText: hintText,
326+
hintStyle: TextStyle(
327+
color: designVariables.textInput.withValues(alpha: 0.5))))))));
328+
}
329+
}
330+
331+
/// Overlay inset shadows on the child from all scrollable directions.
332+
class _ShadowBox extends StatefulWidget {
333+
const _ShadowBox({required this.color, required this.child});
334+
335+
final Color color;
336+
final Widget child;
337+
338+
@override
339+
State<_ShadowBox> createState() => _ShadowBoxState();
340+
}
341+
342+
class _ShadowBoxState extends State<_ShadowBox> {
343+
bool showTopShadow = false; bool showBottomShadow = false;
344+
bool showLeftShadow = false; bool showRightShadow = false;
345+
346+
bool handleScroll(ScrollNotification notification) {
347+
final metrics = notification.metrics;
348+
setState(() {
349+
switch (metrics.axisDirection) {
350+
case AxisDirection.up:
351+
case AxisDirection.down:
352+
showTopShadow = metrics.extentBefore != 0;
353+
showBottomShadow = metrics.extentAfter != 0;
354+
case AxisDirection.right:
355+
case AxisDirection.left:
356+
showLeftShadow = metrics.extentBefore != 0;
357+
showRightShadow = metrics.extentAfter != 0;
358+
}
359+
});
360+
return false;
361+
}
362+
363+
@override
364+
Widget build(BuildContext context) {
365+
BoxDecoration shadowFrom(AlignmentGeometry begin) =>
366+
BoxDecoration(gradient: LinearGradient(begin: begin, end: -begin,
367+
colors: [widget.color, widget.color.withValues(alpha: 0)]));
368+
369+
return NotificationListener<ScrollNotification>(
370+
onNotification: handleScroll,
371+
child: Stack(
372+
children: [
373+
widget.child,
374+
if (showTopShadow) Positioned(top: 0, left: 0, right: 0,
375+
child: Container(height: 8, decoration: shadowFrom(Alignment.topCenter))),
376+
if (showBottomShadow) Positioned(bottom: 0, left: 0, right: 0,
377+
child: Container(height: 8, decoration: shadowFrom(Alignment.bottomCenter))),
378+
if (showLeftShadow) Positioned(left: 0, top: 0, bottom: 0,
379+
child: Container(width: 8, decoration: shadowFrom(Alignment.centerLeft))),
380+
if (showRightShadow) Positioned(right: 0, top: 0, bottom: 0,
381+
child: Container(width: 8, decoration: shadowFrom(Alignment.centerRight))),
382+
],
383+
));
326384
}
327385
}
328386

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)