Skip to content

Commit d25c594

Browse files
committed
compose_box [nfc]: Add InsetShadowBox
This casts a static shadow on top of a child widget from its top edge and bottom edge. Signed-off-by: Zixuan James Li <[email protected]>
1 parent 701763d commit d25c594

File tree

3 files changed

+116
-0
lines changed

3 files changed

+116
-0
lines changed

lib/widgets/inset_shadow.dart

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
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 shadow on scrollable UI elements.
6+
/// For example, when there is a list of items, the shadow could be
7+
/// a visual indicator 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 shadows 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 shadows 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(children: [
48+
child,
49+
Positioned(top: 0, height: top, left: 0, right: 0,
50+
child: DecoratedBox(decoration: _shadowFrom(Alignment.topCenter))),
51+
Positioned(bottom: 0, height: bottom, left: 0, right: 0,
52+
child: DecoratedBox(decoration: _shadowFrom(Alignment.bottomCenter))),
53+
]);
54+
}
55+
}

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.dart

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
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('no additional padding added', (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: Placeholder())))));
22+
23+
final childRect = tester.getRect(find.byType(Placeholder));
24+
check(childRect).equals(const Rect.fromLTRB(0, 0, 20, 20));
25+
});
26+
27+
testWidgets('render shadow correctly', (tester) async {
28+
PaintPatternPredicate paintGradient({required Rect rect}) {
29+
// This is inspired by
30+
// https://github.com/flutter/flutter/blob/7b5462cc34af903e2f2de4be7540ff858685cdfc/packages/flutter/test/cupertino/route_test.dart#L1449-L1475
31+
return (Symbol methodName, List<dynamic> arguments) {
32+
check(methodName).equals(#drawRect);
33+
check(arguments[0]).isA<Rect>().equals(rect);
34+
check(arguments[1]).isA<Paint>().shader.isA<ui.Gradient>();
35+
return true;
36+
};
37+
}
38+
39+
await tester.pumpWidget(const Directionality(
40+
textDirection: TextDirection.ltr,
41+
child: Center(
42+
// This would be forced to fill up the screen
43+
// if not wrapped in a widget like [Center].
44+
child: SizedBox(width: 100, height: 100,
45+
child: InsetShadowBox(top: 3, bottom: 7,
46+
color: Colors.red,
47+
child: SizedBox(width: 30, height: 30))))));
48+
49+
final box = tester.renderObject(find.byType(InsetShadowBox));
50+
check(box).legacyMatcher((paints
51+
// The coordinate system of these [Rect]'s is relative to the parent
52+
// of the [Gradient] from [InsetShadowBox], not the entire [FlutterView].
53+
..something(paintGradient(rect: const Rect.fromLTRB(0, 0, 100, 0+3)))
54+
..something(paintGradient(rect: const Rect.fromLTRB(0, 100-7, 100, 100)))
55+
) as Matcher);
56+
});
57+
}

0 commit comments

Comments
 (0)