Skip to content

compose_box [nfc]: Add InsetShadowBox #990

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Oct 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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);
});
}
Loading