diff --git a/lib/widgets/inset_shadow.dart b/lib/widgets/inset_shadow.dart new file mode 100644 index 0000000000..a4133ac7de --- /dev/null +++ b/lib/widgets/inset_shadow.dart @@ -0,0 +1,59 @@ +import 'package:flutter/widgets.dart'; + +/// A widget that overlays rectangular inset shadows on a child. +/// +/// The use case of this is casting shadows on scrollable UI elements. +/// For example, when there is a list of items, the shadows could be +/// visual indicators for over scrolled areas. +/// +/// Note that this is a bit different from the CSS `box-shadow: inset`, +/// because it only supports rectangular shadows. +/// +/// See also: +/// * https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=3860-11890&node-type=frame&t=oOVTdwGZgtvKv9i8-0 +/// * https://developer.mozilla.org/en-US/docs/Web/CSS/box-shadow#inset +class InsetShadowBox extends StatelessWidget { + const InsetShadowBox({ + super.key, + this.top = 0, + this.bottom = 0, + required this.color, + required this.child, + }); + + /// The distance that the shadow from the child's top edge grows downwards. + /// + /// This does not pad the child widget. + final double top; + + /// The distance that the shadow from the child's bottom edge grows upwards. + /// + /// This does not pad the child widget. + final double bottom; + + /// The shadow color to fade into transparency from the top and bottom borders. + final Color color; + + final Widget child; + + BoxDecoration _shadowFrom(AlignmentGeometry begin) { + return BoxDecoration(gradient: LinearGradient( + begin: begin, end: -begin, + colors: [color, color.withValues(alpha: 0)])); + } + + @override + Widget build(BuildContext context) { + return Stack( + // This is necessary to pass the constraints as-is, + // so that the [Stack] is transparent during layout. + fit: StackFit.passthrough, + children: [ + child, + Positioned(top: 0, height: top, left: 0, right: 0, + child: DecoratedBox(decoration: _shadowFrom(Alignment.topCenter))), + Positioned(bottom: 0, height: bottom, left: 0, right: 0, + child: DecoratedBox(decoration: _shadowFrom(Alignment.bottomCenter))), + ]); + } +} diff --git a/test/flutter_checks.dart b/test/flutter_checks.dart index 3a2b2f2239..a7c9f1c185 100644 --- a/test/flutter_checks.dart +++ b/test/flutter_checks.dart @@ -6,6 +6,10 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +extension PaintChecks on Subject { + Subject get shader => has((x) => x.shader, 'shader'); +} + extension RectChecks on Subject { Subject get top => has((d) => d.top, 'top'); Subject get bottom => has((d) => d.bottom, 'bottom'); diff --git a/test/widgets/inset_shadow_test.dart b/test/widgets/inset_shadow_test.dart new file mode 100644 index 0000000000..a8e3d5f498 --- /dev/null +++ b/test/widgets/inset_shadow_test.dart @@ -0,0 +1,64 @@ +import 'dart:ui' as ui; +import 'package:checks/checks.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:legacy_checks/legacy_checks.dart'; +import 'package:zulip/widgets/inset_shadow.dart'; + +import '../flutter_checks.dart'; + +void main() { + testWidgets('constraints from the parent are not modified', (tester) async { + await tester.pumpWidget(const Directionality( + textDirection: TextDirection.ltr, + child: Align( + // Position child at the top-left corner of the box at (0, 0) + // to ease the check on [Rect] later. + alignment: Alignment.topLeft, + child: SizedBox(width: 20, height: 20, + child: InsetShadowBox(top: 7, bottom: 3, + color: Colors.red, + child: SizedBox.shrink()))))); + + // We expect that the child of [InsetShadowBox] gets the constraints + // from [InsetShadowBox]'s parent unmodified, so that the only effect of + // the widget is adding shadows. + final parentRect = tester.getRect(find.byType(SizedBox).at(0)); + final childRect = tester.getRect(find.byType(SizedBox).at(1)); + check(parentRect).equals(const Rect.fromLTRB(0, 0, 20, 20)); + check(childRect).equals(parentRect); + }); + + testWidgets('render shadow correctly', (tester) async { + PaintPatternPredicate paintGradient({required Rect rect}) { + // This is inspired by + // https://github.com/flutter/flutter/blob/7b5462cc34af903e2f2de4be7540ff858685cdfc/packages/flutter/test/cupertino/route_test.dart#L1449-L1475 + return (Symbol methodName, List arguments) { + check(methodName).equals(#drawRect); + check(arguments[0]).isA().equals(rect); + // We can't further check [ui.Gradient] because it is opaque: + // https://github.com/flutter/engine/blob/07d01ad1199522fa5889a10c1688c4e1812b6625/lib/ui/painting.dart#L4487 + check(arguments[1]).isA().shader.isA(); + return true; + }; + } + + await tester.pumpWidget(const Directionality( + textDirection: TextDirection.ltr, + child: Center( + // This would be forced to fill up the screen + // if not wrapped in a widget like [Center]. + child: SizedBox(width: 100, height: 100, + child: InsetShadowBox(top: 3, bottom: 7, + color: Colors.red, + child: SizedBox(width: 30, height: 30)))))); + + final box = tester.renderObject(find.byType(InsetShadowBox)); + check(box).legacyMatcher((paints + // The coordinate system of these [Rect]'s is relative to the parent + // of the [Gradient] from [InsetShadowBox], not the entire [FlutterView]. + ..something(paintGradient(rect: const Rect.fromLTRB(0, 0, 100, 0+3))) + ..something(paintGradient(rect: const Rect.fromLTRB(0, 100-7, 100, 100))) + ) as Matcher); + }); +}