diff --git a/bin/internal/goldens.version b/bin/internal/goldens.version index 3c649022226..9bace5c36fb 100644 --- a/bin/internal/goldens.version +++ b/bin/internal/goldens.version @@ -1 +1 @@ -cbd3fa445868962b7e910e498791755c988e9890 +ec90c64e598804d691c1c6bfcd191a63480e3053 diff --git a/packages/flutter/lib/src/material/bottom_app_bar.dart b/packages/flutter/lib/src/material/bottom_app_bar.dart index 8a40d7a130c..682dc04ac0d 100644 --- a/packages/flutter/lib/src/material/bottom_app_bar.dart +++ b/packages/flutter/lib/src/material/bottom_app_bar.dart @@ -156,19 +156,20 @@ class _BottomAppBarClipper extends CustomClipper { @override 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 - // coordinate system that origins at the appBar's top left corner. - final Rect button = geometry.value.floatingActionButtonArea - .translate(0.0, geometry.value.bottomNavigationBarTop * -1.0); - - return shape.getOuterPath(appBar, button.inflate(notchMargin)); + // coordinate system whose origin is at the appBar's top left corner, + // or null if there is no floating action button. + final Rect button = geometry.value.floatingActionButtonArea?.translate( + 0.0, + geometry.value.bottomNavigationBarTop * -1.0, + ); + return shape.getOuterPath(Offset.zero & size, button?.inflate(notchMargin)); } @override - bool shouldReclip(_BottomAppBarClipper oldClipper) => oldClipper.geometry != geometry; + bool shouldReclip(_BottomAppBarClipper oldClipper) { + return oldClipper.geometry != geometry + || oldClipper.shape != shape + || oldClipper.notchMargin != notchMargin; + } } diff --git a/packages/flutter/lib/src/painting/borders.dart b/packages/flutter/lib/src/painting/borders.dart index 2e8bcda4a22..51ac9cfea95 100644 --- a/packages/flutter/lib/src/painting/borders.dart +++ b/packages/flutter/lib/src/painting/borders.dart @@ -263,7 +263,17 @@ class BorderSide { /// 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 abstract class ShapeBorder { /// Abstract const constructor. This constructor enables subclasses to provide diff --git a/packages/flutter/lib/src/painting/notched_shapes.dart b/packages/flutter/lib/src/painting/notched_shapes.dart index d5fb7a733bb..2145eaaeddc 100644 --- a/packages/flutter/lib/src/painting/notched_shapes.dart +++ b/packages/flutter/lib/src/painting/notched_shapes.dart @@ -5,15 +5,18 @@ import 'dart:math' as math; import 'basic_types.dart'; +import 'borders.dart'; /// A shape with a notch in its outline. /// /// 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 /// accommodate the [FloatingActionButton]. - -/// See also: [ShapeBorder], which defines a shaped border without a dynamic -/// notch. +/// +/// See also: +/// +/// * [ShapeBorder], which defines a shaped border without a dynamic notch. +/// * [AutomaticNotchedShape], an adapter from [ShapeBorder] to [NotchedShape]. abstract class NotchedShape { /// Abstract const constructor. This constructor enables subclasses to provide /// const constructors so that they can be used in const expressions. @@ -23,13 +26,17 @@ abstract class NotchedShape { /// /// The `host` is the bounding rectangle of the shape. /// - /// Rhe `guest` is the bounding rectangle of the shape for which a notch will - /// be made. + /// The `guest` is the bounding rectangle of the shape for which a notch will + /// be made. It is null when there is no guest. Path getOuterPath(Rect host, Rect guest); } /// 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]. /// /// The same object can be used to create multiple shapes. @@ -49,7 +56,7 @@ class CircularNotchedRectangle implements NotchedShape { // TODO(amirh): add an example diagram here. @override Path getOuterPath(Rect host, Rect guest) { - if (!host.overlaps(guest)) + if (guest == null || !host.overlaps(guest)) return Path()..addRect(host); // The guest's shape is a circle bounded by the guest rectangle. @@ -111,3 +118,47 @@ class CircularNotchedRectangle implements NotchedShape { ..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; + } +} diff --git a/packages/flutter/test/material/bottom_app_bar_test.dart b/packages/flutter/test/material/bottom_app_bar_test.dart index 2d020163450..e8f6f32d1db 100644 --- a/packages/flutter/test/material/bottom_app_bar_test.dart +++ b/packages/flutter/test/material/bottom_app_bar_test.dart @@ -38,6 +38,49 @@ void main() { ); }); + testWidgets('custom shape', (WidgetTester tester) async { + final Key key = UniqueKey(); + Future 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 { await tester.pumpWidget( MaterialApp( @@ -92,7 +135,7 @@ void main() { // 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 - // _BottomAppBarClipper will try an illegal downcast. + // _BottomAppBarClipper would try an illegal downcast. testWidgets('toggle shape to null', (WidgetTester tester) async { await tester.pumpWidget( const MaterialApp( @@ -386,11 +429,13 @@ class ShapeListenerState extends State { } -class RectangularNotch implements NotchedShape { +class RectangularNotch extends NotchedShape { const RectangularNotch(); @override Path getOuterPath(Rect host, Rect guest) { + if (guest == null) + return Path()..addRect(host); return Path() ..moveTo(host.left, host.top) ..lineTo(guest.left, host.top) diff --git a/packages/flutter/test/painting/notched_shapes_test.dart b/packages/flutter/test/painting/notched_shapes_test.dart index 7eadb0926f1..3d9edf34ba8 100644 --- a/packages/flutter/test/painting/notched_shapes_test.dart +++ b/packages/flutter/test/painting/notched_shapes_test.dart @@ -47,6 +47,63 @@ void main() { 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, + ) + ); + }); }); }