Skip to content

Commit a1b5579

Browse files
PIG208gnprice
authored andcommitted
compose_box [nfc]: Add InsetShadowBox
This casts fixed size shadows on top of a child widget from its top edge and bottom edge. Signed-off-by: Zixuan James Li <[email protected]>
1 parent d789cc1 commit a1b5579

File tree

3 files changed

+127
-0
lines changed

3 files changed

+127
-0
lines changed

lib/widgets/inset_shadow.dart

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import 'package:flutter/widgets.dart';
2+
3+
/// A widget that overlays rectangular inset shadows on a child.
4+
///
5+
/// The use case of this is casting shadows on scrollable UI elements.
6+
/// For example, when there is a list of items, the shadows could be
7+
/// visual indicators for over scrolled areas.
8+
///
9+
/// Note that this is a bit different from the CSS `box-shadow: inset`,
10+
/// because it only supports rectangular shadows.
11+
///
12+
/// See also:
13+
/// * https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=3860-11890&node-type=frame&t=oOVTdwGZgtvKv9i8-0
14+
/// * https://developer.mozilla.org/en-US/docs/Web/CSS/box-shadow#inset
15+
class InsetShadowBox extends StatelessWidget {
16+
const InsetShadowBox({
17+
super.key,
18+
this.top = 0,
19+
this.bottom = 0,
20+
required this.color,
21+
required this.child,
22+
});
23+
24+
/// The distance that the shadow from the child's top edge grows downwards.
25+
///
26+
/// This does not pad the child widget.
27+
final double top;
28+
29+
/// The distance that the shadow from the child's bottom edge grows upwards.
30+
///
31+
/// This does not pad the child widget.
32+
final double bottom;
33+
34+
/// The shadow color to fade into transparency from the top and bottom borders.
35+
final Color color;
36+
37+
final Widget child;
38+
39+
BoxDecoration _shadowFrom(AlignmentGeometry begin) {
40+
return BoxDecoration(gradient: LinearGradient(
41+
begin: begin, end: -begin,
42+
colors: [color, color.withValues(alpha: 0)]));
43+
}
44+
45+
@override
46+
Widget build(BuildContext context) {
47+
return Stack(
48+
// This is necessary to pass the constraints as-is,
49+
// so that the [Stack] is transparent during layout.
50+
fit: StackFit.passthrough,
51+
children: [
52+
child,
53+
Positioned(top: 0, height: top, left: 0, right: 0,
54+
child: DecoratedBox(decoration: _shadowFrom(Alignment.topCenter))),
55+
Positioned(bottom: 0, height: bottom, left: 0, right: 0,
56+
child: DecoratedBox(decoration: _shadowFrom(Alignment.bottomCenter))),
57+
]);
58+
}
59+
}

test/flutter_checks.dart

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ import 'package:flutter/foundation.dart';
66
import 'package:flutter/material.dart';
77
import 'package:flutter/services.dart';
88

9+
extension PaintChecks on Subject<Paint> {
10+
Subject<Shader?> get shader => has((x) => x.shader, 'shader');
11+
}
12+
913
extension RectChecks on Subject<Rect> {
1014
Subject<double> get top => has((d) => d.top, 'top');
1115
Subject<double> get bottom => has((d) => d.bottom, 'bottom');

test/widgets/inset_shadow_test.dart

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import 'dart:ui' as ui;
2+
import 'package:checks/checks.dart';
3+
import 'package:flutter/material.dart';
4+
import 'package:flutter_test/flutter_test.dart';
5+
import 'package:legacy_checks/legacy_checks.dart';
6+
import 'package:zulip/widgets/inset_shadow.dart';
7+
8+
import '../flutter_checks.dart';
9+
10+
void main() {
11+
testWidgets('constraints from the parent are not modified', (tester) async {
12+
await tester.pumpWidget(const Directionality(
13+
textDirection: TextDirection.ltr,
14+
child: Align(
15+
// Position child at the top-left corner of the box at (0, 0)
16+
// to ease the check on [Rect] later.
17+
alignment: Alignment.topLeft,
18+
child: SizedBox(width: 20, height: 20,
19+
child: InsetShadowBox(top: 7, bottom: 3,
20+
color: Colors.red,
21+
child: SizedBox.shrink())))));
22+
23+
// We expect that the child of [InsetShadowBox] gets the constraints
24+
// from [InsetShadowBox]'s parent unmodified, so that the only effect of
25+
// the widget is adding shadows.
26+
final parentRect = tester.getRect(find.byType(SizedBox).at(0));
27+
final childRect = tester.getRect(find.byType(SizedBox).at(1));
28+
check(parentRect).equals(const Rect.fromLTRB(0, 0, 20, 20));
29+
check(childRect).equals(parentRect);
30+
});
31+
32+
testWidgets('render shadow correctly', (tester) async {
33+
PaintPatternPredicate paintGradient({required Rect rect}) {
34+
// This is inspired by
35+
// https://github.com/flutter/flutter/blob/7b5462cc34af903e2f2de4be7540ff858685cdfc/packages/flutter/test/cupertino/route_test.dart#L1449-L1475
36+
return (Symbol methodName, List<dynamic> arguments) {
37+
check(methodName).equals(#drawRect);
38+
check(arguments[0]).isA<Rect>().equals(rect);
39+
// We can't further check [ui.Gradient] because it is opaque:
40+
// https://github.com/flutter/engine/blob/07d01ad1199522fa5889a10c1688c4e1812b6625/lib/ui/painting.dart#L4487
41+
check(arguments[1]).isA<Paint>().shader.isA<ui.Gradient>();
42+
return true;
43+
};
44+
}
45+
46+
await tester.pumpWidget(const Directionality(
47+
textDirection: TextDirection.ltr,
48+
child: Center(
49+
// This would be forced to fill up the screen
50+
// if not wrapped in a widget like [Center].
51+
child: SizedBox(width: 100, height: 100,
52+
child: InsetShadowBox(top: 3, bottom: 7,
53+
color: Colors.red,
54+
child: SizedBox(width: 30, height: 30))))));
55+
56+
final box = tester.renderObject(find.byType(InsetShadowBox));
57+
check(box).legacyMatcher((paints
58+
// The coordinate system of these [Rect]'s is relative to the parent
59+
// of the [Gradient] from [InsetShadowBox], not the entire [FlutterView].
60+
..something(paintGradient(rect: const Rect.fromLTRB(0, 0, 100, 0+3)))
61+
..something(paintGradient(rect: const Rect.fromLTRB(0, 100-7, 100, 100)))
62+
) as Matcher);
63+
});
64+
}

0 commit comments

Comments
 (0)