Skip to content

Commit

Permalink
compose_box [nfc]: Add InsetShadowBox
Browse files Browse the repository at this point in the history
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]>
  • Loading branch information
PIG208 committed Oct 10, 2024
1 parent 701763d commit fb1ef14
Show file tree
Hide file tree
Showing 3 changed files with 127 additions and 0 deletions.
59 changes: 59 additions & 0 deletions lib/widgets/inset_shadow.dart
Original file line number Diff line number Diff line change
@@ -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))),
]);
}
}
4 changes: 4 additions & 0 deletions test/flutter_checks.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

extension PaintChecks on Subject<Paint> {
Subject<Shader?> get shader => has((x) => x.shader, 'shader');
}

extension RectChecks on Subject<Rect> {
Subject<double> get top => has((d) => d.top, 'top');
Subject<double> get bottom => has((d) => d.bottom, 'bottom');
Expand Down
64 changes: 64 additions & 0 deletions test/widgets/inset_shadow_test.dart
Original file line number Diff line number Diff line change
@@ -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<dynamic> arguments) {
check(methodName).equals(#drawRect);
check(arguments[0]).isA<Rect>().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<Paint>().shader.isA<ui.Gradient>();
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);
});
}

0 comments on commit fb1ef14

Please sign in to comment.