diff --git a/examples/flutter_gallery/lib/demo/material/bottom_app_bar_demo.dart b/examples/flutter_gallery/lib/demo/material/bottom_app_bar_demo.dart index 19a0a857cfd..d5ef25663af 100644 --- a/examples/flutter_gallery/lib/demo/material/bottom_app_bar_demo.dart +++ b/examples/flutter_gallery/lib/demo/material/bottom_app_bar_demo.dart @@ -187,10 +187,20 @@ class _BottomAppBarDemoState extends State { bottomNavigationBar: new _DemoBottomAppBar( color: _babColor, fabLocation: _fabLocation.value, - showNotch: _showNotch.value, + shape: _selectNotch(), ), ); } + + NotchedShape _selectNotch() { + if (!_showNotch.value) + return null; + if (_fabShape == kCircularFab) + return const CircularNotchedRectangle(); + if (_fabShape == kDiamondFab) + return const _DiamondNotchedRectangle(); + return null; + } } class _ChoiceValue { @@ -317,11 +327,15 @@ class _Heading extends StatelessWidget { } class _DemoBottomAppBar extends StatelessWidget { - const _DemoBottomAppBar({ this.color, this.fabLocation, this.showNotch }); + const _DemoBottomAppBar({ + this.color, + this.fabLocation, + this.shape + }); final Color color; final FloatingActionButtonLocation fabLocation; - final bool showNotch; + final NotchedShape shape; static final List kCenterLocations = [ FloatingActionButtonLocation.centerDocked, @@ -369,8 +383,8 @@ class _DemoBottomAppBar extends StatelessWidget { return new BottomAppBar( color: color, - hasNotch: showNotch, child: new Row(children: rowContents), + shape: shape, ); } } @@ -399,64 +413,46 @@ class _DemoDrawer extends StatelessWidget { } // A diamond-shaped floating action button. -class _DiamondFab extends StatefulWidget { +class _DiamondFab extends StatelessWidget { const _DiamondFab({ this.child, - this.notchMargin = 6.0, this.onPressed, }); final Widget child; - final double notchMargin; final VoidCallback onPressed; - @override - State createState() => new _DiamondFabState(); -} - -class _DiamondFabState extends State<_DiamondFab> { - - VoidCallback _clearComputeNotch; - @override Widget build(BuildContext context) { return new Material( shape: const _DiamondBorder(), color: Colors.orange, child: new InkWell( - onTap: widget.onPressed, + onTap: onPressed, child: new Container( width: 56.0, height: 56.0, child: IconTheme.merge( data: new IconThemeData(color: Theme.of(context).accentIconTheme.color), - child: widget.child, + child: child, ), ), ), elevation: 6.0, ); } +} + +class _DiamondNotchedRectangle implements NotchedShape { + const _DiamondNotchedRectangle(); @override - void didChangeDependencies() { - super.didChangeDependencies(); - _clearComputeNotch = Scaffold.setFloatingActionButtonNotchFor(context, _computeNotch); - } + Path getOuterPath(Rect host, Rect guest) { + if (!host.overlaps(guest)) + return new Path()..addRect(host); + assert(guest.width > 0.0); - @override - void deactivate() { - if (_clearComputeNotch != null) - _clearComputeNotch(); - super.deactivate(); - } - - Path _computeNotch(Rect host, Rect guest, Offset start, Offset end) { - final Rect marginedGuest = guest.inflate(widget.notchMargin); - if (!host.overlaps(marginedGuest)) - return new Path()..lineTo(end.dx, end.dy); - - final Rect intersection = marginedGuest.intersect(host); + final Rect intersection = guest.intersect(host); // We are computing a "V" shaped notch, as in this diagram: // -----\**** /----- // \ / @@ -470,14 +466,18 @@ class _DiamondFabState extends State<_DiamondFab> { // the host's top edge where the notch starts (marked with "*"). // We compute notchToCenter by similar triangles: final double notchToCenter = - intersection.height * (marginedGuest.height / 2.0) - / (marginedGuest.width / 2.0); + intersection.height * (guest.height / 2.0) + / (guest.width / 2.0); return new Path() - ..lineTo(marginedGuest.center.dx - notchToCenter, host.top) - ..lineTo(marginedGuest.left + marginedGuest.width / 2.0, marginedGuest.bottom) - ..lineTo(marginedGuest.center.dx + notchToCenter, host.top) - ..lineTo(end.dx, end.dy); + ..moveTo(host.left, host.top) + ..lineTo(guest.center.dx - notchToCenter, host.top) + ..lineTo(guest.left + guest.width / 2.0, guest.bottom) + ..lineTo(guest.center.dx + notchToCenter, host.top) + ..lineTo(host.right, host.top) + ..lineTo(host.right, host.bottom) + ..lineTo(host.left, host.bottom) + ..close(); } } diff --git a/packages/flutter/lib/painting.dart b/packages/flutter/lib/painting.dart index b673f6cf13e..e1d202b96b2 100644 --- a/packages/flutter/lib/painting.dart +++ b/packages/flutter/lib/painting.dart @@ -43,6 +43,7 @@ export 'src/painting/image_provider.dart'; export 'src/painting/image_resolution.dart'; export 'src/painting/image_stream.dart'; export 'src/painting/matrix_utils.dart'; +export 'src/painting/notched_shapes.dart'; export 'src/painting/paint_utilities.dart'; export 'src/painting/rounded_rectangle_border.dart'; export 'src/painting/shape_decoration.dart'; diff --git a/packages/flutter/lib/src/material/bottom_app_bar.dart b/packages/flutter/lib/src/material/bottom_app_bar.dart index ad4ad563efb..1bd406b2fcd 100644 --- a/packages/flutter/lib/src/material/bottom_app_bar.dart +++ b/packages/flutter/lib/src/material/bottom_app_bar.dart @@ -46,7 +46,8 @@ class BottomAppBar extends StatefulWidget { Key key, this.color, this.elevation = 8.0, - this.hasNotch = true, + this.shape, + this.notchMargin = 4.0, this.child, }) : assert(elevation != null), assert(elevation >= 0.0), @@ -71,16 +72,16 @@ class BottomAppBar extends StatefulWidget { /// Defaults to 8, the appropriate elevation for bottom app bars. final double elevation; - /// Whether to make a notch in the bottom app bar's shape for the floating - /// action button. + /// The notch that is made for the floating action button. /// - /// When true, the bottom app bar uses - /// [ScaffoldGeometry.floatingActionButtonNotch] to make a notch along its - /// top edge, where it is overlapped by the - /// [ScaffoldGeometry.floatingActionButtonArea]. + /// If null the bottom app bar will be rectangular with no notch. + final NotchedShape shape; + + /// The margin between the [FloatingActionButton] and the [BottomAppBar]'s + /// notch. /// - /// When false, the shape of the bottom app bar is a rectangle. - final bool hasNotch; + /// Not used if [shape] is null. + final double notchMargin; @override State createState() => new _BottomAppBarState(); @@ -97,8 +98,12 @@ class _BottomAppBarState extends State { @override Widget build(BuildContext context) { - final CustomClipper clipper = widget.hasNotch - ? new _BottomAppBarClipper(geometry: geometryListenable) + final CustomClipper clipper = widget.shape != null + ? new _BottomAppBarClipper( + geometry: geometryListenable, + shape: widget.shape, + notchMargin: widget.notchMargin, + ) : const ShapeBorderClipper(shape: const RoundedRectangleBorder()); return new PhysicalShape( clipper: clipper, @@ -116,17 +121,22 @@ class _BottomAppBarState extends State { class _BottomAppBarClipper extends CustomClipper { const _BottomAppBarClipper({ - @required this.geometry + @required this.geometry, + @required this.shape, + @required this.notchMargin, }) : assert(geometry != null), + assert(shape != null), + assert(notchMargin != null), super(reclip: geometry); final ValueListenable geometry; + final NotchedShape shape; + final double notchMargin; @override Path getClip(Size size) { final Rect appBar = Offset.zero & size; - if (geometry.value.floatingActionButtonArea == null || - geometry.value.floatingActionButtonNotch == null) { + if (geometry.value.floatingActionButtonArea == null) { return new Path()..addRect(appBar); } @@ -135,22 +145,7 @@ class _BottomAppBarClipper extends CustomClipper { final Rect button = geometry.value.floatingActionButtonArea .translate(0.0, geometry.value.bottomNavigationBarTop * -1.0); - final ComputeNotch computeNotch = geometry.value.floatingActionButtonNotch; - return new Path() - ..moveTo(appBar.left, appBar.top) - ..addPath( - computeNotch( - appBar, - button, - new Offset(appBar.left, appBar.top), - new Offset(appBar.right, appBar.top) - ), - Offset.zero - ) - ..lineTo(appBar.right, appBar.top) - ..lineTo(appBar.right, appBar.bottom) - ..lineTo(appBar.left, appBar.bottom) - ..close(); + return shape.getOuterPath(appBar, button.inflate(notchMargin)); } @override diff --git a/packages/flutter/lib/src/material/floating_action_button.dart b/packages/flutter/lib/src/material/floating_action_button.dart index ac92c4f0c59..86f93e42686 100644 --- a/packages/flutter/lib/src/material/floating_action_button.dart +++ b/packages/flutter/lib/src/material/floating_action_button.dart @@ -2,8 +2,6 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'dart:math' as math; - import 'package:flutter/painting.dart'; import 'package:flutter/widgets.dart'; @@ -33,7 +31,6 @@ class _DefaultHeroTag { String toString() => ''; } -// TODO(amirh): update the documentation once the BAB notch can be disabled. /// A material design floating action button. /// /// A floating action button is a circular icon button that hovers over content @@ -47,12 +44,6 @@ class _DefaultHeroTag { /// If the [onPressed] callback is null, then the button will be disabled and /// will not react to touch. /// -/// If the floating action button is a descendant of a [Scaffold] that also has a -/// [BottomAppBar], the [BottomAppBar] will show a notch to accomodate the -/// [FloatingActionButton] when it overlaps the [BottomAppBar]. The notch's -/// shape is an arc for a circle whose radius is the floating action button's -/// radius plus [FloatingActionButton.notchMargin]. -/// /// See also: /// /// * [Scaffold] @@ -62,7 +53,7 @@ class _DefaultHeroTag { class FloatingActionButton extends StatefulWidget { /// Creates a circular floating action button. /// - /// The [elevation], [highlightElevation], [mini], [notchMargin], and [shape] + /// The [elevation], [highlightElevation], [mini], and [shape] /// arguments must not be null. const FloatingActionButton({ Key key, @@ -75,13 +66,11 @@ class FloatingActionButton extends StatefulWidget { this.highlightElevation = 12.0, @required this.onPressed, this.mini = false, - this.notchMargin = 4.0, this.shape = const CircleBorder(), this.isExtended = false, }) : assert(elevation != null), assert(highlightElevation != null), assert(mini != null), - assert(notchMargin != null), assert(shape != null), assert(isExtended != null), _sizeConstraints = mini ? _kMiniSizeConstraints : _kSizeConstraints, @@ -91,7 +80,7 @@ class FloatingActionButton extends StatefulWidget { /// an [icon] and a [label]. /// /// The [label], [icon], [elevation], [highlightElevation] - /// [notchMargin], and [shape] arguments must not be null. + /// and [shape] arguments must not be null. FloatingActionButton.extended({ Key key, this.tooltip, @@ -101,14 +90,12 @@ class FloatingActionButton extends StatefulWidget { this.elevation = 6.0, this.highlightElevation = 12.0, @required this.onPressed, - this.notchMargin = 4.0, this.shape = const StadiumBorder(), this.isExtended = true, @required Widget icon, @required Widget label, }) : assert(elevation != null), assert(highlightElevation != null), - assert(notchMargin != null), assert(shape != null), assert(isExtended != null), _sizeConstraints = _kExtendedSizeConstraints, @@ -191,19 +178,6 @@ class FloatingActionButton extends StatefulWidget { /// logical pixels. final bool mini; - /// The margin to keep around the floating action button when creating a - /// notch for it. - /// - /// The notch is an arc of a circle with radius r+[notchMargin] where r is the - /// radius of the floating action button. This expanded radius leaves a margin - /// around the floating action button. - /// - /// See also: - /// - /// * [BottomAppBar], a material design elements that shows a notch for the - /// floating action button. - final double notchMargin; - /// The shape of the button's [Material]. /// /// The button's highlight and splash are clipped to this shape. If the @@ -230,7 +204,6 @@ class FloatingActionButton extends StatefulWidget { class _FloatingActionButtonState extends State { bool _highlight = false; - VoidCallback _clearComputeNotch; void _handleHighlightChanged(bool value) { setState(() { @@ -287,110 +260,4 @@ class _FloatingActionButtonState extends State { return result; } - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - _clearComputeNotch = Scaffold.setFloatingActionButtonNotchFor(context, _computeNotch); - } - - @override - void deactivate() { - if (_clearComputeNotch != null) - _clearComputeNotch(); - super.deactivate(); - } - - Path _computeNotch(Rect host, Rect guest, Offset start, Offset end) { - // The FAB's shape is a circle bounded by the guest rectangle. - // So the FAB's radius is half the guest width. - final double fabRadius = guest.width / 2.0; - final double notchRadius = fabRadius + widget.notchMargin; - - assert(_notchAssertions(host, guest, start, end, fabRadius, notchRadius)); - - // If there's no overlap between the guest's margin boundary and the host, - // don't make a notch, just return a straight line from start to end. - if (!host.overlaps(guest.inflate(widget.notchMargin))) - return new Path()..lineTo(end.dx, end.dy); - - // We build a path for the notch from 3 segments: - // Segment A - a Bezier curve from the host's top edge to segment B. - // Segment B - an arc with radius notchRadius. - // Segment C - a Bezier curver from segment B back to the host's top edge. - // - // A detailed explanation and the derivation of the formulas below is - // available at: https://goo.gl/Ufzrqn - - const double s1 = 15.0; - const double s2 = 1.0; - - final double r = notchRadius; - final double a = -1.0 * r - s2; - final double b = host.top - guest.center.dy; - - final double n2 = math.sqrt(b * b * r * r * (a * a + b * b - r * r)); - final double p2xA = ((a * r * r) - n2) / (a * a + b * b); - final double p2xB = ((a * r * r) + n2) / (a * a + b * b); - final double p2yA = math.sqrt(r * r - p2xA * p2xA); - final double p2yB = math.sqrt(r * r - p2xB * p2xB); - - final List p = new List(6); - - // p0, p1, and p2 are the control points for segment A. - p[0] = new Offset(a - s1, b); - p[1] = new Offset(a, b); - final double cmp = b < 0 ? -1.0 : 1.0; - p[2] = cmp * p2yA > cmp * p2yB ? new Offset(p2xA, p2yA) : new Offset(p2xB, p2yB); - - // p3, p4, and p5 are the control points for segment B, which is a mirror - // of segment A around the y axis. - p[3] = new Offset(-1.0 * p[2].dx, p[2].dy); - p[4] = new Offset(-1.0 * p[1].dx, p[1].dy); - p[5] = new Offset(-1.0 * p[0].dx, p[0].dy); - - // translate all points back to the absolute coordinate system. - for (int i = 0; i < p.length; i += 1) - p[i] += guest.center; - - return new Path() - ..lineTo(p[0].dx, p[0].dy) - ..quadraticBezierTo(p[1].dx, p[1].dy, p[2].dx, p[2].dy) - ..arcToPoint( - p[3], - radius: new Radius.circular(notchRadius), - clockwise: false, - ) - ..quadraticBezierTo(p[4].dx, p[4].dy, p[5].dx, p[5].dy) - ..lineTo(end.dx, end.dy); - } - - bool _notchAssertions(Rect host, Rect guest, Offset start, Offset end, - double fabRadius, double notchRadius) { - if (end.dy != host.top) - throw new FlutterError( - 'The notch of the floating action button must end at the top edge of the host.\n' - 'The notch\'s path end point: $end is not in the top edge of $host' - ); - - if (start.dy != host.top) - throw new FlutterError( - 'The notch of the floating action button must start at the top edge of the host.\n' - 'The notch\'s path start point: $start is not in the top edge of $host' - ); - - if (guest.center.dx - notchRadius < start.dx) - throw new FlutterError( - 'The notch\'s path start point must be to the left of the floating action button.\n' - 'Start point was $start, guest was $guest, notchMargin was ${widget.notchMargin}.' - ); - - if (guest.center.dx + notchRadius > end.dx) - throw new FlutterError( - 'The notch\'s end point must be to the right of the floating action button.\n' - 'End point was $start, notch was $guest, notchMargin was ${widget.notchMargin}.' - ); - - return true; - } } diff --git a/packages/flutter/lib/src/material/scaffold.dart b/packages/flutter/lib/src/material/scaffold.dart index 45e5d3e42e2..e30d4f11e06 100644 --- a/packages/flutter/lib/src/material/scaffold.dart +++ b/packages/flutter/lib/src/material/scaffold.dart @@ -27,24 +27,6 @@ import 'theme.dart'; const FloatingActionButtonLocation _kDefaultFloatingActionButtonLocation = FloatingActionButtonLocation.endFloat; const FloatingActionButtonAnimator _kDefaultFloatingActionButtonAnimator = FloatingActionButtonAnimator.scaling; -/// Returns a path for a notch in the outline of a shape. -/// -/// The path makes a notch in the host shape that can contain the guest shape. -/// -/// The `host` is the bounding rectangle for the shape into which the notch will -/// be applied. The `guest` is the bounding rectangle of the shape for which we -/// are creating a notch in the host. -/// -/// The `start` and `end` arguments are points on the outline of the host shape -/// that will be connected by the returned path. -/// -/// The returned path may pass anywhere, including inside the guest bounds area, -/// and may contain multiple subpaths. The returned path ends at `end` and does -/// not end with a [Path.close]. The returned [Path] is built under the -/// assumption it will be added to an existing path that is at the `start` -/// coordinates using [Path.addPath]. -typedef Path ComputeNotch(Rect host, Rect guest, Offset start, Offset end); - enum _ScaffoldSlot { body, appBar, @@ -203,7 +185,6 @@ class ScaffoldGeometry { const ScaffoldGeometry({ this.bottomNavigationBarTop, this.floatingActionButtonArea, - this.floatingActionButtonNotch, }); /// The distance from the [Scaffold]'s top edge to the top edge of the @@ -217,12 +198,6 @@ class ScaffoldGeometry { /// This is null when there is no floating action button showing. final Rect floatingActionButtonArea; - /// A [ComputeNotch] for the floating action button. - /// - /// The contract for this [ComputeNotch] is described in [ComputeNotch] and - /// [Scaffold.setFloatingActionButtonNotchFor]. - final ComputeNotch floatingActionButtonNotch; - ScaffoldGeometry _scaleFloatingActionButton(double scaleFactor) { if (scaleFactor == 1.0) return this; @@ -230,7 +205,6 @@ class ScaffoldGeometry { if (scaleFactor == 0.0) { return new ScaffoldGeometry( bottomNavigationBarTop: bottomNavigationBarTop, - floatingActionButtonNotch: floatingActionButtonNotch, ); } @@ -247,30 +221,14 @@ class ScaffoldGeometry { ScaffoldGeometry copyWith({ double bottomNavigationBarTop, Rect floatingActionButtonArea, - ComputeNotch floatingActionButtonNotch, }) { return new ScaffoldGeometry( bottomNavigationBarTop: bottomNavigationBarTop ?? this.bottomNavigationBarTop, floatingActionButtonArea: floatingActionButtonArea ?? this.floatingActionButtonArea, - floatingActionButtonNotch: floatingActionButtonNotch ?? this.floatingActionButtonNotch, ); } } - -class _Closeable { - _Closeable(this.closeCallback) : assert(closeCallback != null); - - VoidCallback closeCallback; - - void close() { - if (closeCallback == null) - return; - closeCallback(); - closeCallback = null; - } -} - class _ScaffoldGeometryNotifier extends ChangeNotifier implements ValueListenable { _ScaffoldGeometryNotifier(this.geometry, this.context) : assert (context != null); @@ -278,7 +236,6 @@ class _ScaffoldGeometryNotifier extends ChangeNotifier implements ValueListenabl final BuildContext context; double floatingActionButtonScale; ScaffoldGeometry geometry; - _Closeable computeNotchCloseable; @override ScaffoldGeometry get value { @@ -299,29 +256,11 @@ class _ScaffoldGeometryNotifier extends ChangeNotifier implements ValueListenabl double bottomNavigationBarTop, Rect floatingActionButtonArea, double floatingActionButtonScale, - ComputeNotch floatingActionButtonNotch, }) { this.floatingActionButtonScale = floatingActionButtonScale ?? this.floatingActionButtonScale; geometry = geometry.copyWith( bottomNavigationBarTop: bottomNavigationBarTop, floatingActionButtonArea: floatingActionButtonArea, - floatingActionButtonNotch: floatingActionButtonNotch, - ); - notifyListeners(); - } - - VoidCallback _updateFloatingActionButtonNotch(ComputeNotch fabComputeNotch) { - computeNotchCloseable?.close(); - _setFloatingActionButtonNotchAndNotify(fabComputeNotch); - computeNotchCloseable = new _Closeable(() { _setFloatingActionButtonNotchAndNotify(null); }); - return computeNotchCloseable.close; - } - - void _setFloatingActionButtonNotchAndNotify(ComputeNotch fabComputeNotch) { - geometry = new ScaffoldGeometry( - bottomNavigationBarTop: geometry.bottomNavigationBarTop, - floatingActionButtonArea: geometry.floatingActionButtonArea, - floatingActionButtonNotch: fabComputeNotch, ); notifyListeners(); } @@ -1036,32 +975,6 @@ class Scaffold extends StatefulWidget { return scaffoldScope.geometryNotifier; } - /// Sets the [ScaffoldGeometry.floatingActionButtonNotch] for the closest - /// [Scaffold] ancestor of the given context, if one exists. - /// - /// It is guaranteed that `computeNotch` will only be used for making notches - /// in the top edge of the [bottomNavigationBar], the start and end offsets given to - /// it will always be on the top edge of the [bottomNavigationBar], the start offset - /// will be to the left of the floating action button's bounds, and the end - /// offset will be to the right of the floating action button's bounds. - /// - /// Returns null if there was no [Scaffold] ancestor. - /// Otherwise, returns a [VoidCallback] that clears the notch maker that was - /// set. - /// - /// Callers must invoke the callback when the notch is no longer required. - /// This method is typically called from [State.didChangeDependencies] and the - /// callback should then be invoked from [State.deactivate]. - /// - /// If there was a previously set [ScaffoldGeometry.floatingActionButtonNotch] - /// it will be overridden. - static VoidCallback setFloatingActionButtonNotchFor(BuildContext context, ComputeNotch computeNotch) { - final _ScaffoldScope scaffoldScope = context.inheritFromWidgetOfExactType(_ScaffoldScope); - if (scaffoldScope == null) - return null; - return scaffoldScope.geometryNotifier._updateFloatingActionButtonNotch(computeNotch); - } - /// Whether the Scaffold that most tightly encloses the given context has a /// drawer. /// diff --git a/packages/flutter/lib/src/painting/notched_shapes.dart b/packages/flutter/lib/src/painting/notched_shapes.dart new file mode 100644 index 00000000000..23789120ebd --- /dev/null +++ b/packages/flutter/lib/src/painting/notched_shapes.dart @@ -0,0 +1,109 @@ +import 'dart:math' as math; + +import 'basic_types.dart'; + +/// A shape with a notch in its outline. +/// +/// Typically used as the outline of a 'host' widget to make a notch that +/// accomodates a 'guest' widget. e.g the [BottomAppBar] may have a notch to +/// accomodate the [FloatingActionBar]. + +/// See also: [ShapeBorder], which defines a shaped border without a dynamic +/// notch. +abstract class NotchedShape { + /// Abstract const constructor. This constructor enables subclasses to provide + /// const constructors so that they can be used in const expressions. + const NotchedShape(); + + /// Creates a [Path] that describes the outline 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 + /// be made. + Path getOuterPath(Rect host, Rect guest); +} + +/// A rectangle with a smooth circular notch. +class CircularNotchedRectangle implements NotchedShape { + /// Creates a `CircularNotchedRectangle`. + /// + /// The same object can be used to create multiple shapes. + const CircularNotchedRectangle(); + + /// Creates a [Path] that describes a rectangle with a smooth circular notch. + /// + /// `host` is the bounding box for the returned shape. Conceptually this is + /// the rectangle to which the notch will be applied. + /// + /// `guest` is the bounding box of a circle that the notch accomodates. All + /// points in the circle bounded by `guest` will be outside of the returned + /// path. + /// + /// The notch is curve that smoothly connects the host's top edge and + /// the guest circle. + // TODO(amirh): add an example diagram here. + @override + Path getOuterPath(Rect host, Rect guest) { + if (!host.overlaps(guest)) + return new Path()..addRect(host); + + // The guest's shape is a circle bounded by the guest rectangle. + // So the guest's radius is half the guest width. + final double notchRadius = guest.width / 2.0; + + // We build a path for the notch from 3 segments: + // Segment A - a Bezier curve from the host's top edge to segment B. + // Segment B - an arc with radius notchRadius. + // Segment C - a Bezier curver from segment B back to the host's top edge. + // + // A detailed explanation and the derivation of the formulas below is + // available at: https://goo.gl/Ufzrqn + + const double s1 = 15.0; + const double s2 = 1.0; + + final double r = notchRadius; + final double a = -1.0 * r - s2; + final double b = host.top - guest.center.dy; + + final double n2 = math.sqrt(b * b * r * r * (a * a + b * b - r * r)); + final double p2xA = ((a * r * r) - n2) / (a * a + b * b); + final double p2xB = ((a * r * r) + n2) / (a * a + b * b); + final double p2yA = math.sqrt(r * r - p2xA * p2xA); + final double p2yB = math.sqrt(r * r - p2xB * p2xB); + + final List p = new List(6); + + // p0, p1, and p2 are the control points for segment A. + p[0] = new Offset(a - s1, b); + p[1] = new Offset(a, b); + final double cmp = b < 0 ? -1.0 : 1.0; + p[2] = cmp * p2yA > cmp * p2yB ? new Offset(p2xA, p2yA) : new Offset(p2xB, p2yB); + + // p3, p4, and p5 are the control points for segment B, which is a mirror + // of segment A around the y axis. + p[3] = new Offset(-1.0 * p[2].dx, p[2].dy); + p[4] = new Offset(-1.0 * p[1].dx, p[1].dy); + p[5] = new Offset(-1.0 * p[0].dx, p[0].dy); + + // translate all points back to the absolute coordinate system. + for (int i = 0; i < p.length; i += 1) + p[i] += guest.center; + + return new Path() + ..moveTo(host.left, host.top) + ..lineTo(p[0].dx, p[0].dy) + ..quadraticBezierTo(p[1].dx, p[1].dy, p[2].dx, p[2].dy) + ..arcToPoint( + p[3], + radius: new Radius.circular(notchRadius), + clockwise: false, + ) + ..quadraticBezierTo(p[4].dx, p[4].dy, p[5].dx, p[5].dy) + ..lineTo(host.right, host.top) + ..lineTo(host.right, host.bottom) + ..lineTo(host.left, host.bottom) + ..close(); + } +} diff --git a/packages/flutter/test/material/bottom_app_bar_test.dart b/packages/flutter/test/material/bottom_app_bar_test.dart index 492694c8ded..3963cb6c58d 100644 --- a/packages/flutter/test/material/bottom_app_bar_test.dart +++ b/packages/flutter/test/material/bottom_app_bar_test.dart @@ -14,7 +14,11 @@ void main() { floatingActionButton: const FloatingActionButton( onPressed: null, ), - bottomNavigationBar: const ShapeListener(const BottomAppBar()), + bottomNavigationBar: const ShapeListener( + const BottomAppBar( + child: const SizedBox(height: 100.0), + ) + ), ), ), ); @@ -86,15 +90,15 @@ void main() { expect(physicalShape.color, const Color(0xff0000ff)); }); - // This is a regression test for a bug we had where toggling hasNotch - // will crash, as the shouldReclip method of ShapeBorderClipper or + // 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. - testWidgets('toggle hasNotch', (WidgetTester tester) async { + testWidgets('toggle shape to null', (WidgetTester tester) async { await tester.pumpWidget( new MaterialApp( home: const Scaffold( bottomNavigationBar: const BottomAppBar( - hasNotch: true, + shape: const RectangularNotch(), ), ), ), @@ -104,7 +108,7 @@ void main() { new MaterialApp( home: const Scaffold( bottomNavigationBar: const BottomAppBar( - hasNotch: false, + shape: null, ), ), ), @@ -114,17 +118,148 @@ void main() { new MaterialApp( home: const Scaffold( bottomNavigationBar: const BottomAppBar( - hasNotch: true, + shape: const RectangularNotch(), ), ), ), ); }); - // TODO(amirh): test a BottomAppBar with hasNotch=false and an overlapping - // FAB. - // - // Cannot test this before https://github.com/flutter/flutter/pull/14368 - // as there is no way to make the FAB and BAB overlap. + + testWidgets('no notch when notch param is null', (WidgetTester tester) async { + await tester.pumpWidget( + new MaterialApp( + home: const Scaffold( + bottomNavigationBar: const ShapeListener(const BottomAppBar( + shape: null, + )), + floatingActionButton: const FloatingActionButton( + onPressed: null, + child: const Icon(Icons.add), + ), + floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked, + ), + ), + ); + + final ShapeListenerState shapeListenerState = tester.state(find.byType(ShapeListener)); + final RenderBox renderBox = tester.renderObject(find.byType(BottomAppBar)); + final Path expectedPath = new Path() + ..addRect(Offset.zero & renderBox.size); + + final Path actualPath = shapeListenerState.cache.value; + + expect( + actualPath, + coversSameAreaAs( + expectedPath, + areaToCompare: (Offset.zero & renderBox.size).inflate(5.0), + ) + ); + }); + + testWidgets('notch no margin', (WidgetTester tester) async { + await tester.pumpWidget( + new MaterialApp( + home: const Scaffold( + bottomNavigationBar: const ShapeListener( + const BottomAppBar( + child: const SizedBox(height: 100.0), + shape: const RectangularNotch(), + notchMargin: 0.0, + ) + ), + floatingActionButton: const FloatingActionButton( + onPressed: null, + child: const Icon(Icons.add), + ), + floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked, + ), + ), + ); + + final ShapeListenerState shapeListenerState = tester.state(find.byType(ShapeListener)); + final RenderBox babBox = tester.renderObject(find.byType(BottomAppBar)); + final Size babSize = babBox.size; + final RenderBox fabBox = tester.renderObject(find.byType(FloatingActionButton)); + final Size fabSize = fabBox.size; + + final double fabLeft = (babSize.width / 2.0) - (fabSize.width / 2.0); + final double fabRight = fabLeft + fabSize.width; + final double fabBottom = fabSize.height / 2.0; + + final Path expectedPath = new Path() + ..moveTo(0.0, 0.0) + ..lineTo(fabLeft, 0.0) + ..lineTo(fabLeft, fabBottom) + ..lineTo(fabRight, fabBottom) + ..lineTo(fabRight, 0.0) + ..lineTo(babSize.width, 0.0) + ..lineTo(babSize.width, babSize.height) + ..lineTo(0.0, babSize.height) + ..close(); + + final Path actualPath = shapeListenerState.cache.value; + + expect( + actualPath, + coversSameAreaAs( + expectedPath, + areaToCompare: (Offset.zero & babSize).inflate(5.0), + ) + ); + }); + + testWidgets('notch with margin', (WidgetTester tester) async { + await tester.pumpWidget( + new MaterialApp( + home: const Scaffold( + bottomNavigationBar: const ShapeListener( + const BottomAppBar( + child: const SizedBox(height: 100.0), + shape: const RectangularNotch(), + notchMargin: 6.0, + ) + ), + floatingActionButton: const FloatingActionButton( + onPressed: null, + child: const Icon(Icons.add), + ), + floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked, + ), + ), + ); + + final ShapeListenerState shapeListenerState = tester.state(find.byType(ShapeListener)); + final RenderBox babBox = tester.renderObject(find.byType(BottomAppBar)); + final Size babSize = babBox.size; + final RenderBox fabBox = tester.renderObject(find.byType(FloatingActionButton)); + final Size fabSize = fabBox.size; + + final double fabLeft = (babSize.width / 2.0) - (fabSize.width / 2.0) - 6.0; + final double fabRight = fabLeft + fabSize.width + 6.0; + final double fabBottom = 6.0 + fabSize.height / 2.0; + + final Path expectedPath = new Path() + ..moveTo(0.0, 0.0) + ..lineTo(fabLeft, 0.0) + ..lineTo(fabLeft, fabBottom) + ..lineTo(fabRight, fabBottom) + ..lineTo(fabRight, 0.0) + ..lineTo(babSize.width, 0.0) + ..lineTo(babSize.width, babSize.height) + ..lineTo(0.0, babSize.height) + ..close(); + + final Path actualPath = shapeListenerState.cache.value; + + expect( + actualPath, + coversSameAreaAs( + expectedPath, + areaToCompare: (Offset.zero & babSize).inflate(5.0), + ) + ); + }); testWidgets('observes safe area', (WidgetTester tester) async { await tester.pumpWidget( @@ -215,3 +350,22 @@ class ShapeListenerState extends State { } } + +class RectangularNotch implements NotchedShape { + const RectangularNotch(); + + @override + Path getOuterPath(Rect host, Rect guest) { + return new Path() + ..moveTo(host.left, host.top) + ..lineTo(guest.left, host.top) + ..lineTo(guest.left, guest.bottom) + ..lineTo(guest.right, guest.bottom) + ..lineTo(guest.right, host.top) + ..lineTo(host.right, host.top) + ..lineTo(host.right, host.bottom) + ..lineTo(host.left, host.bottom) + ..close(); + } +} + diff --git a/packages/flutter/test/material/floating_action_button_test.dart b/packages/flutter/test/material/floating_action_button_test.dart index 85621f8ecba..df98c447b46 100644 --- a/packages/flutter/test/material/floating_action_button_test.dart +++ b/packages/flutter/test/material/floating_action_button_test.dart @@ -2,10 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'dart:math' as math; import 'dart:ui'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -308,212 +306,5 @@ void main() { semantics.dispose(); }); - group('ComputeNotch', () { - testWidgets('host and guest must intersect', (WidgetTester tester) async { - final ComputeNotch computeNotch = await fetchComputeNotch(tester, const FloatingActionButton(onPressed: null)); - final Rect host = new Rect.fromLTRB(0.0, 100.0, 300.0, 300.0); - final Rect guest = new Rect.fromLTWH(50.0, 50.0, 10.0, 10.0); - const Offset start = const Offset(10.0, 100.0); - const Offset end = const Offset(60.0, 100.0); - expect(() {computeNotch(host, guest, start, end);}, throwsFlutterError); - }); - - testWidgets('start/end must be on top edge', (WidgetTester tester) async { - final ComputeNotch computeNotch = await fetchComputeNotch(tester, const FloatingActionButton(onPressed: null)); - final Rect host = new Rect.fromLTRB(0.0, 100.0, 300.0, 300.0); - final Rect guest = new Rect.fromLTRB(190.0, 90.0, 210.0, 110.0); - - Offset start = const Offset(180.0, 100.0); - Offset end = const Offset(220.0, 110.0); - expect(() {computeNotch(host, guest, start, end);}, throwsFlutterError); - - start = const Offset(180.0, 110.0); - end = const Offset(220.0, 100.0); - expect(() {computeNotch(host, guest, start, end);}, throwsFlutterError); - }); - - testWidgets('start must be to the left of the notch', (WidgetTester tester) async { - final ComputeNotch computeNotch = await fetchComputeNotch(tester, const FloatingActionButton(onPressed: null)); - final Rect host = new Rect.fromLTRB(0.0, 100.0, 300.0, 300.0); - final Rect guest = new Rect.fromLTRB(190.0, 90.0, 210.0, 110.0); - - const Offset start = const Offset(191.0, 100.0); - const Offset end = const Offset(220.0, 100.0); - expect(() {computeNotch(host, guest, start, end);}, throwsFlutterError); - }); - - testWidgets('end must be to the right of the notch', (WidgetTester tester) async { - final ComputeNotch computeNotch = await fetchComputeNotch(tester, const FloatingActionButton(onPressed: null)); - final Rect host = new Rect.fromLTRB(0.0, 100.0, 300.0, 300.0); - final Rect guest = new Rect.fromLTRB(190.0, 90.0, 210.0, 110.0); - - const Offset start = const Offset(180.0, 100.0); - const Offset end = const Offset(209.0, 100.0); - expect(() {computeNotch(host, guest, start, end);}, throwsFlutterError); - }); - - testWidgets('notch no margin', (WidgetTester tester) async { - final ComputeNotch computeNotch = await fetchComputeNotch(tester, const FloatingActionButton(onPressed: null, notchMargin: 0.0)); - final Rect host = new Rect.fromLTRB(0.0, 100.0, 300.0, 300.0); - final Rect guest = new Rect.fromLTRB(190.0, 90.0, 210.0, 110.0); - const Offset start = const Offset(180.0, 100.0); - const Offset end = const Offset(220.0, 100.0); - - final Path actualNotch = computeNotch(host, guest, start, end); - final Path notchedRectangle = - createNotchedRectangle(host, start.dx, end.dx, actualNotch); - - expect(pathDoesNotContainCircle(notchedRectangle, guest), isTrue); - }); - - testWidgets('notch with margin', (WidgetTester tester) async { - final ComputeNotch computeNotch = await fetchComputeNotch(tester, - const FloatingActionButton(onPressed: null, notchMargin: 4.0) - ); - final Rect host = new Rect.fromLTRB(0.0, 100.0, 300.0, 300.0); - final Rect guest = new Rect.fromLTRB(190.0, 90.0, 210.0, 110.0); - const Offset start = const Offset(180.0, 100.0); - const Offset end = const Offset(220.0, 100.0); - - final Path actualNotch = computeNotch(host, guest, start, end); - final Path notchedRectangle = - createNotchedRectangle(host, start.dx, end.dx, actualNotch); - expect(pathDoesNotContainCircle(notchedRectangle, guest.inflate(4.0)), isTrue); - }); - - testWidgets('notch circle center above BAB', (WidgetTester tester) async { - final ComputeNotch computeNotch = await fetchComputeNotch(tester, - const FloatingActionButton(onPressed: null, notchMargin: 4.0) - ); - final Rect host = new Rect.fromLTRB(0.0, 100.0, 300.0, 300.0); - final Rect guest = new Rect.fromLTRB(190.0, 85.0, 210.0, 105.0); - const Offset start = const Offset(180.0, 100.0); - const Offset end = const Offset(220.0, 100.0); - - final Path actualNotch = computeNotch(host, guest, start, end); - final Path notchedRectangle = - createNotchedRectangle(host, start.dx, end.dx, actualNotch); - expect(pathDoesNotContainCircle(notchedRectangle, guest.inflate(4.0)), isTrue); - }); - - testWidgets('notch circle center below BAB', (WidgetTester tester) async { - final ComputeNotch computeNotch = await fetchComputeNotch(tester, - const FloatingActionButton(onPressed: null, notchMargin: 4.0) - ); - final Rect host = new Rect.fromLTRB(0.0, 100.0, 300.0, 300.0); - final Rect guest = new Rect.fromLTRB(190.0, 95.0, 210.0, 115.0); - const Offset start = const Offset(180.0, 100.0); - const Offset end = const Offset(220.0, 100.0); - - final Path actualNotch = computeNotch(host, guest, start, end); - final Path notchedRectangle = - createNotchedRectangle(host, start.dx, end.dx, actualNotch); - expect(pathDoesNotContainCircle(notchedRectangle, guest.inflate(4.0)), isTrue); - }); - - testWidgets('no notch when there is no overlap', (WidgetTester tester) async { - final ComputeNotch computeNotch = await fetchComputeNotch(tester, - const FloatingActionButton(onPressed: null, notchMargin: 4.0) - ); - final Rect host = new Rect.fromLTRB(0.0, 100.0, 300.0, 300.0); - final Rect guest = new Rect.fromLTRB(190.0, 40.0, 210.0, 60.0); - const Offset start = const Offset(180.0, 100.0); - const Offset end = const Offset(220.0, 100.0); - - final Path actualNotch = computeNotch(host, guest, start, end); - final Path notchedRectangle = - createNotchedRectangle(host, start.dx, end.dx, actualNotch); - expect(pathDoesNotContainCircle(notchedRectangle, guest.inflate(4.0)), isTrue); - }); - - }); } - -Path createNotchedRectangle(Rect container, double startX, double endX, Path notch) { - return new Path() - ..moveTo(container.left, container.top) - ..lineTo(startX, container.top) - ..addPath(notch, Offset.zero) - ..lineTo(container.right, container.top) - ..lineTo(container.right, container.bottom) - ..lineTo(container.left, container.bottom) - ..close(); -} -Future fetchComputeNotch(WidgetTester tester, FloatingActionButton fab) async { - await tester.pumpWidget(new MaterialApp( - home: new Scaffold( - body: new ConstrainedBox( - constraints: const BoxConstraints.expand(height: 80.0), - child: new GeometryListener(), - ), - floatingActionButton: fab, - ) - )); - final GeometryListenerState listenerState = tester.state(find.byType(GeometryListener)); - return listenerState.cache.value.floatingActionButtonNotch; -} - -class GeometryListener extends StatefulWidget { - @override - State createState() => new GeometryListenerState(); -} - -class GeometryListenerState extends State { - @override - Widget build(BuildContext context) { - return new CustomPaint( - painter: cache - ); - } - - ValueListenable geometryListenable; - GeometryCachePainter cache; - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - final ValueListenable newListenable = Scaffold.geometryOf(context); - if (geometryListenable == newListenable) - return; - - geometryListenable = newListenable; - cache = new GeometryCachePainter(geometryListenable); - } - -} - -// The Scaffold.geometryOf() value is only available at paint time. -// To fetch it for the tests we implement this CustomPainter that just -// caches the ScaffoldGeometry value in its paint method. -class GeometryCachePainter extends CustomPainter { - GeometryCachePainter(this.geometryListenable) : super(repaint: geometryListenable); - - final ValueListenable geometryListenable; - - ScaffoldGeometry value; - @override - void paint(Canvas canvas, Size size) { - value = geometryListenable.value; - } - - @override - bool shouldRepaint(GeometryCachePainter oldDelegate) { - return true; - } -} - -bool pathDoesNotContainCircle(Path path, Rect circleBounds) { - assert(circleBounds.width == circleBounds.height); - final double radius = circleBounds.width / 2.0; - - for (double theta = 0.0; theta <= 2.0 * math.pi; theta += math.pi / 20.0) { - for (double i = 0.0; i < 1; i += 0.01) { - final double x = i * radius * math.cos(theta); - final double y = i * radius * math.sin(theta); - if (path.contains(new Offset(x,y) + circleBounds.center)) - return false; - } - } - return true; -} diff --git a/packages/flutter/test/material/scaffold_test.dart b/packages/flutter/test/material/scaffold_test.dart index eb300a78f88..9d0d79c89ab 100644 --- a/packages/flutter/test/material/scaffold_test.dart +++ b/packages/flutter/test/material/scaffold_test.dart @@ -969,93 +969,6 @@ void main() { numNotificationsAtLastFrame = listenerState.numNotifications; }); - testWidgets('set floatingActionButtonNotch', (WidgetTester tester) async { - final ComputeNotch computeNotch = (Rect container, Rect notch, Offset start, Offset end) => null; - await tester.pumpWidget(new MaterialApp( - home: new Scaffold( - body: new ConstrainedBox( - constraints: const BoxConstraints.expand(height: 80.0), - child: new _GeometryListener(), - ), - floatingActionButton: new _ComputeNotchSetter(computeNotch), - ) - )); - - final _GeometryListenerState listenerState = tester.state(find.byType(_GeometryListener)); - ScaffoldGeometry geometry = listenerState.cache.value; - - expect( - geometry.floatingActionButtonNotch, - computeNotch, - ); - - await tester.pumpWidget(new MaterialApp( - home: new Scaffold( - body: new ConstrainedBox( - constraints: const BoxConstraints.expand(height: 80.0), - child: new _GeometryListener(), - ), - ) - )); - - await tester.pump(const Duration(seconds: 3)); - - geometry = listenerState.cache.value; - - expect( - geometry.floatingActionButtonNotch, - null, - ); - }); - - testWidgets('closing an inactive floatingActionButtonNotch is a no-op', (WidgetTester tester) async { - final ComputeNotch computeNotch = (Rect container, Rect notch, Offset start, Offset end) => null; - await tester.pumpWidget(new MaterialApp( - home: new Scaffold( - body: new ConstrainedBox( - constraints: const BoxConstraints.expand(height: 80.0), - child: new _GeometryListener(), - ), - floatingActionButton: new _ComputeNotchSetter(computeNotch), - ) - )); - - final _ComputeNotchSetterState computeNotchSetterState = tester.state(find.byType(_ComputeNotchSetter)); - - final VoidCallback clearFirstComputeNotch = computeNotchSetterState.clearComputeNotch; - - final ComputeNotch computeNotch2 = (Rect container, Rect notch, Offset start, Offset end) => null; - await tester.pumpWidget(new MaterialApp( - home: new Scaffold( - body: new ConstrainedBox( - constraints: const BoxConstraints.expand(height: 80.0), - child: new _GeometryListener(), - ), - floatingActionButton: new _ComputeNotchSetter( - computeNotch2, - // We're setting a key to make sure a new ComputeNotchSetterState is - // created. - key: new GlobalKey(), - ), - ) - )); - - await tester.pump(const Duration(seconds: 3)); - - // At this point the first notch maker was replaced by the second one. - // We call the clear callback for the first notch maker and verify that - // the second notch maker is still set. - - clearFirstComputeNotch(); - - final _GeometryListenerState listenerState = tester.state(find.byType(_GeometryListener)); - final ScaffoldGeometry geometry = listenerState.cache.value; - - expect( - geometry.floatingActionButtonNotch, - computeNotch2, - ); - }); }); } @@ -1116,36 +1029,6 @@ class _GeometryCachePainter extends CustomPainter { } } -class _ComputeNotchSetter extends StatefulWidget { - const _ComputeNotchSetter(this.computeNotch, {Key key}): super(key: key); - - final ComputeNotch computeNotch; - - @override - State createState() => new _ComputeNotchSetterState(); -} - -class _ComputeNotchSetterState extends State<_ComputeNotchSetter> { - - VoidCallback clearComputeNotch; - @override - void didChangeDependencies() { - super.didChangeDependencies(); - clearComputeNotch = Scaffold.setFloatingActionButtonNotchFor(context, widget.computeNotch); - } - - @override - void deactivate() { - clearComputeNotch(); - super.deactivate(); - } - - @override - Widget build(BuildContext context) { - return new Container(); - } -} - class _CustomPageRoute extends PageRoute { _CustomPageRoute({ @required this.builder, diff --git a/packages/flutter/test/painting/notched_shapes_test.dart b/packages/flutter/test/painting/notched_shapes_test.dart new file mode 100644 index 00000000000..4c624f0c2ea --- /dev/null +++ b/packages/flutter/test/painting/notched_shapes_test.dart @@ -0,0 +1,66 @@ +// Copyright 2018 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:math' as math; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter/painting.dart'; + +void main() { + group('CircularNotchedRectangle', () { + test('guest and host don\'t overlap', () { + const CircularNotchedRectangle shape = const CircularNotchedRectangle(); + final Rect host = new Rect.fromLTRB(0.0, 100.0, 300.0, 300.0); + final Rect guest = new Rect.fromLTWH(50.0, 50.0, 10.0, 10.0); + + final Path actualPath = shape.getOuterPath(host, guest); + final Path expectedPath = new Path()..addRect(host); + + expect( + actualPath, + coversSameAreaAs( + expectedPath, + areaToCompare: host.inflate(5.0), + sampleSize: 40, + ) + ); + }); + + test('guest center above host', () { + const CircularNotchedRectangle shape = const CircularNotchedRectangle(); + final Rect host = new Rect.fromLTRB(0.0, 100.0, 300.0, 300.0); + final Rect guest = new Rect.fromLTRB(190.0, 85.0, 210.0, 105.0); + + final Path actualPath = shape.getOuterPath(host, guest); + + expect(pathDoesNotContainCircle(actualPath, guest), isTrue); + }); + + test('guest center below host', () { + const CircularNotchedRectangle shape = const CircularNotchedRectangle(); + final Rect host = new Rect.fromLTRB(0.0, 100.0, 300.0, 300.0); + final Rect guest = new Rect.fromLTRB(190.0, 95.0, 210.0, 115.0); + + final Path actualPath = shape.getOuterPath(host, guest); + + expect(pathDoesNotContainCircle(actualPath, guest), isTrue); + }); + + }); +} + +bool pathDoesNotContainCircle(Path path, Rect circleBounds) { + assert(circleBounds.width == circleBounds.height); + final double radius = circleBounds.width / 2.0; + + for (double theta = 0.0; theta <= 2.0 * math.pi; theta += math.pi / 20.0) { + for (double i = 0.0; i < 1; i += 0.01) { + final double x = i * radius * math.cos(theta); + final double y = i * radius * math.sin(theta); + if (path.contains(new Offset(x,y) + circleBounds.center)) + return false; + } + } + return true; +}