Unverified Commit f4d5646b authored by Ian Hickson's avatar Ian Hickson Committed by GitHub

Make NotchedShape more practical to use (#27487)

This PR does two things:

- It allows BottomAppBar to have a custom shape even when it doesn't
  have a notch.

- It adds AutomaticNotchedShape, an adapter from ShapeBorder to
  NotchedShape.
parent 51780b87
cbd3fa445868962b7e910e498791755c988e9890 ec90c64e598804d691c1c6bfcd191a63480e3053
...@@ -156,19 +156,20 @@ class _BottomAppBarClipper extends CustomClipper<Path> { ...@@ -156,19 +156,20 @@ class _BottomAppBarClipper extends CustomClipper<Path> {
@override @override
Path getClip(Size size) { Path getClip(Size size) {
final Rect appBar = Offset.zero & size;
if (geometry.value.floatingActionButtonArea == null) {
return Path()..addRect(appBar);
}
// button is the floating action button's bounding rectangle in the // button is the floating action button's bounding rectangle in the
// coordinate system that origins at the appBar's top left corner. // coordinate system whose origin is at the appBar's top left corner,
final Rect button = geometry.value.floatingActionButtonArea // or null if there is no floating action button.
.translate(0.0, geometry.value.bottomNavigationBarTop * -1.0); final Rect button = geometry.value.floatingActionButtonArea?.translate(
0.0,
return shape.getOuterPath(appBar, button.inflate(notchMargin)); geometry.value.bottomNavigationBarTop * -1.0,
);
return shape.getOuterPath(Offset.zero & size, button?.inflate(notchMargin));
} }
@override @override
bool shouldReclip(_BottomAppBarClipper oldClipper) => oldClipper.geometry != geometry; bool shouldReclip(_BottomAppBarClipper oldClipper) {
return oldClipper.geometry != geometry
|| oldClipper.shape != shape
|| oldClipper.notchMargin != notchMargin;
}
} }
...@@ -263,7 +263,17 @@ class BorderSide { ...@@ -263,7 +263,17 @@ class BorderSide {
/// Base class for shape outlines. /// Base class for shape outlines.
/// ///
/// This class handles how to add multiple borders together. /// This class handles how to add multiple borders together. Subclasses define
/// various shapes, like circles ([CircleBorder]), rounded rectangles
/// ([RoundedRectangleBorder]), superellipses ([SuperellipseShape]), or beveled
/// rectangles ([BeveledRectangleBorder]).
///
/// See also:
///
/// * [ShapeDecoration], which can be used with [DecoratedBox] to show a shape.
/// * [Material] (and many other widgets in the Material library), which takes
/// a [ShapeBorder] to define its shape.
/// * [NotchedShape], which describes a shape with a hole in it.
@immutable @immutable
abstract class ShapeBorder { abstract class ShapeBorder {
/// Abstract const constructor. This constructor enables subclasses to provide /// Abstract const constructor. This constructor enables subclasses to provide
......
...@@ -5,15 +5,18 @@ ...@@ -5,15 +5,18 @@
import 'dart:math' as math; import 'dart:math' as math;
import 'basic_types.dart'; import 'basic_types.dart';
import 'borders.dart';
/// A shape with a notch in its outline. /// A shape with a notch in its outline.
/// ///
/// Typically used as the outline of a 'host' widget to make a notch that /// Typically used as the outline of a 'host' widget to make a notch that
/// accommodates a 'guest' widget. e.g the [BottomAppBar] may have a notch to /// accommodates a 'guest' widget. e.g the [BottomAppBar] may have a notch to
/// accommodate the [FloatingActionButton]. /// accommodate the [FloatingActionButton].
///
/// See also: [ShapeBorder], which defines a shaped border without a dynamic /// See also:
/// notch. ///
/// * [ShapeBorder], which defines a shaped border without a dynamic notch.
/// * [AutomaticNotchedShape], an adapter from [ShapeBorder] to [NotchedShape].
abstract class NotchedShape { abstract class NotchedShape {
/// Abstract const constructor. This constructor enables subclasses to provide /// Abstract const constructor. This constructor enables subclasses to provide
/// const constructors so that they can be used in const expressions. /// const constructors so that they can be used in const expressions.
...@@ -23,13 +26,17 @@ abstract class NotchedShape { ...@@ -23,13 +26,17 @@ abstract class NotchedShape {
/// ///
/// The `host` is the bounding rectangle of the shape. /// The `host` is the bounding rectangle of the shape.
/// ///
/// Rhe `guest` is the bounding rectangle of the shape for which a notch will /// The `guest` is the bounding rectangle of the shape for which a notch will
/// be made. /// be made. It is null when there is no guest.
Path getOuterPath(Rect host, Rect guest); Path getOuterPath(Rect host, Rect guest);
} }
/// A rectangle with a smooth circular notch. /// A rectangle with a smooth circular notch.
class CircularNotchedRectangle implements NotchedShape { ///
/// See also:
///
/// * [CircleBorder], a [ShapeBorder] that describes a circle.
class CircularNotchedRectangle extends NotchedShape {
/// Creates a [CircularNotchedRectangle]. /// Creates a [CircularNotchedRectangle].
/// ///
/// The same object can be used to create multiple shapes. /// The same object can be used to create multiple shapes.
...@@ -49,7 +56,7 @@ class CircularNotchedRectangle implements NotchedShape { ...@@ -49,7 +56,7 @@ class CircularNotchedRectangle implements NotchedShape {
// TODO(amirh): add an example diagram here. // TODO(amirh): add an example diagram here.
@override @override
Path getOuterPath(Rect host, Rect guest) { Path getOuterPath(Rect host, Rect guest) {
if (!host.overlaps(guest)) if (guest == null || !host.overlaps(guest))
return Path()..addRect(host); return Path()..addRect(host);
// The guest's shape is a circle bounded by the guest rectangle. // The guest's shape is a circle bounded by the guest rectangle.
...@@ -111,3 +118,47 @@ class CircularNotchedRectangle implements NotchedShape { ...@@ -111,3 +118,47 @@ class CircularNotchedRectangle implements NotchedShape {
..close(); ..close();
} }
} }
/// A [NotchedShape] created from [ShapeBorder]s.
///
/// Two shapes can be provided. The [host] is the shape of the widget that
/// uses the [NotchedShape] (typically a [BottomAppBar]). The [guest] is
/// subtracted from the [host] to create the notch (typically to make room
/// for a [FloatingActionButton]).
class AutomaticNotchedShape extends NotchedShape {
/// Creates a [NotchedShape] that is defined by two [ShapeBorder]s.
///
/// The [host] must not be null.
///
/// The [guest] may be null, in which case no notch is created even
/// if a guest rectangle is provided to [getOuterPath].
const AutomaticNotchedShape(this.host, [ this.guest ]);
/// The shape of the widget that uses the [NotchedShape] (typically a
/// [BottomAppBar]).
///
/// This shape cannot depend on the [TextDirection], as no text direction
/// is available to [NotchedShape]s.
final ShapeBorder host;
/// The shape to subtract from the [host] to make the notch.
///
/// This shape cannot depend on the [TextDirection], as no text direction
/// is available to [NotchedShape]s.
///
/// If this is null, [getOuterPath] ignores the guest rectangle.
final ShapeBorder guest;
@override
Path getOuterPath(Rect hostRect, Rect guestRect) { // ignore: avoid_renaming_method_parameters, the
// parameters are renamed over the baseclass because they would clash
// with properties of this object, and the use of all four of them in
// the code below is really confusing if they have the same names.
final Path hostPath = host.getOuterPath(hostRect);
if (guest != null && guestRect != null) {
final Path guestPath = guest.getOuterPath(guestRect);
return Path.combine(PathOperation.difference, hostPath, guestPath);
}
return hostPath;
}
}
...@@ -38,6 +38,49 @@ void main() { ...@@ -38,6 +38,49 @@ void main() {
); );
}); });
testWidgets('custom shape', (WidgetTester tester) async {
final Key key = UniqueKey();
Future<void> pump(FloatingActionButtonLocation location) async {
await tester.pumpWidget(
SizedBox(
width: 200,
height: 200,
child: RepaintBoundary(
key: key,
child: MaterialApp(
home: Scaffold(
floatingActionButton: FloatingActionButton(
onPressed: () { },
),
floatingActionButtonLocation: location,
bottomNavigationBar: BottomAppBar(
shape: AutomaticNotchedShape(
BeveledRectangleBorder(borderRadius: BorderRadius.circular(50.0)),
SuperellipseShape(borderRadius: BorderRadius.circular(30.0)),
),
notchMargin: 10.0,
color: Colors.green,
child: const SizedBox(height: 100.0),
),
),
),
),
),
);
}
await pump(FloatingActionButtonLocation.endDocked);
await expectLater(
find.byKey(key),
matchesGoldenFile('bottom_app_bar.custom_shape.1.png'),
);
await pump(FloatingActionButtonLocation.centerDocked);
await tester.pumpAndSettle();
await expectLater(
find.byKey(key),
matchesGoldenFile('bottom_app_bar.custom_shape.2.png'),
);
});
testWidgets('color defaults to Theme.bottomAppBarColor', (WidgetTester tester) async { testWidgets('color defaults to Theme.bottomAppBarColor', (WidgetTester tester) async {
await tester.pumpWidget( await tester.pumpWidget(
MaterialApp( MaterialApp(
...@@ -92,7 +135,7 @@ void main() { ...@@ -92,7 +135,7 @@ void main() {
// This is a regression test for a bug we had where toggling the notch on/off // This is a regression test for a bug we had where toggling the notch on/off
// would crash, as the shouldReclip method of ShapeBorderClipper or // would crash, as the shouldReclip method of ShapeBorderClipper or
// _BottomAppBarClipper will try an illegal downcast. // _BottomAppBarClipper would try an illegal downcast.
testWidgets('toggle shape to null', (WidgetTester tester) async { testWidgets('toggle shape to null', (WidgetTester tester) async {
await tester.pumpWidget( await tester.pumpWidget(
const MaterialApp( const MaterialApp(
...@@ -386,11 +429,13 @@ class ShapeListenerState extends State<ShapeListener> { ...@@ -386,11 +429,13 @@ class ShapeListenerState extends State<ShapeListener> {
} }
class RectangularNotch implements NotchedShape { class RectangularNotch extends NotchedShape {
const RectangularNotch(); const RectangularNotch();
@override @override
Path getOuterPath(Rect host, Rect guest) { Path getOuterPath(Rect host, Rect guest) {
if (guest == null)
return Path()..addRect(host);
return Path() return Path()
..moveTo(host.left, host.top) ..moveTo(host.left, host.top)
..lineTo(guest.left, host.top) ..lineTo(guest.left, host.top)
......
...@@ -47,6 +47,63 @@ void main() { ...@@ -47,6 +47,63 @@ void main() {
expect(pathDoesNotContainCircle(actualPath, guest), isTrue); expect(pathDoesNotContainCircle(actualPath, guest), isTrue);
}); });
test('no guest is ok', () {
final Rect host = Rect.fromLTRB(0.0, 100.0, 300.0, 300.0);
expect(
const CircularNotchedRectangle().getOuterPath(host, null),
coversSameAreaAs(
Path()..addRect(host),
areaToCompare: host.inflate(800.0),
sampleSize: 100,
)
);
});
test('AutomaticNotchedShape - with guest', () {
expect(
const AutomaticNotchedShape(
RoundedRectangleBorder(),
RoundedRectangleBorder(),
).getOuterPath(
Rect.fromLTWH(-200.0, -100.0, 50.0, 100.0),
Rect.fromLTWH(-175.0, -110.0, 100.0, 100.0),
),
coversSameAreaAs(
Path()
..moveTo(-200.0, -100.0)
..lineTo(-150.0, -100.0)
..lineTo(-150.0, -10.0)
..lineTo(-175.0, -10.0)
..lineTo(-175.0, 0.0)
..lineTo(-200.0, 0.0)
..close(),
areaToCompare: Rect.fromLTWH(-300.0, -300.0, 600.0, 600.0),
sampleSize: 100,
)
);
});
test('AutomaticNotchedShape - no guest', () {
expect(
const AutomaticNotchedShape(
RoundedRectangleBorder(),
RoundedRectangleBorder(),
).getOuterPath(
Rect.fromLTWH(-200.0, -100.0, 50.0, 100.0),
null,
),
coversSameAreaAs(
Path()
..moveTo(-200.0, -100.0)
..lineTo(-150.0, -100.0)
..lineTo(-150.0, 0.0)
..lineTo(-200.0, 0.0)
..close(),
areaToCompare: Rect.fromLTWH(-300.0, -300.0, 600.0, 600.0),
sampleSize: 100,
)
);
});
}); });
} }
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment