Move the notch computation from the FAB to the BAB. (#18372)

Move the notch computation from the FAB to the BAB.

The notch in the BAB (bottom action bar) for the FAB (floating action button) was previously kept as part of the FAB's implementation. This was done to keep the shape of the FAB and the shape of the notch coupled.
That approach resulted in a somewhat complex and 'non Fluttery' mechanism for propagating the notch computation from the FAB to the BAB.

This CL uncouples the FAB and the notch computation.
With the new API the BAB computes its overall shape including the notch using a NotchedShape delegate.

This includes multiple breaking changes:
  * Scaffold.setFloatingActionButtonNotchFor is deleted.
  * The ComputeNotch type is deleted.
  * The hasNotch property of BottomAppBar is deleted.
  * The notchMargin property of FloatingActionButton is deleted.

Quick migration guide from the previous API:

| Previous API | New API |
| ------------------|-------------|
| BottomAppBar(hasNotch: false) | BottomAppBar() |
| Using a FloatingActionButton with: BottomAppBar() / BottomAppBar(hasNotch: true) | BottomAppBar(shape: CircularNotchedRectangle()) |
| Scaffold.setFloatingActionButtonNotchFor(..) | No longer supported |
This commit is contained in:
amirh 2018-06-21 15:51:21 -07:00 committed by GitHub
parent 65069ed4de
commit c39f2f26f7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 409 additions and 630 deletions

View file

@ -187,10 +187,20 @@ class _BottomAppBarDemoState extends State<BottomAppBarDemo> {
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<T> {
@ -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<FloatingActionButtonLocation> kCenterLocations = <FloatingActionButtonLocation>[
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();
}
}

View file

@ -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';

View file

@ -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<BottomAppBar> {
@override
Widget build(BuildContext context) {
final CustomClipper<Path> clipper = widget.hasNotch
? new _BottomAppBarClipper(geometry: geometryListenable)
final CustomClipper<Path> 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<BottomAppBar> {
class _BottomAppBarClipper extends CustomClipper<Path> {
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<ScaffoldGeometry> 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<Path> {
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

View file

@ -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() => '<default FloatingActionButton tag>';
}
// 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<FloatingActionButton> {
bool _highlight = false;
VoidCallback _clearComputeNotch;
void _handleHighlightChanged(bool value) {
setState(() {
@ -287,110 +260,4 @@ class _FloatingActionButtonState extends State<FloatingActionButton> {
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<Offset> p = new List<Offset>(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;
}
}

View file

@ -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<ScaffoldGeometry> {
_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.
///

View file

@ -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<Offset> p = new List<Offset>(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();
}
}

View file

@ -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<ShapeListener> {
}
}
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();
}
}

View file

@ -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<ComputeNotch> 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<GeometryListener> {
@override
Widget build(BuildContext context) {
return new CustomPaint(
painter: cache
);
}
ValueListenable<ScaffoldGeometry> geometryListenable;
GeometryCachePainter cache;
@override
void didChangeDependencies() {
super.didChangeDependencies();
final ValueListenable<ScaffoldGeometry> 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<ScaffoldGeometry> 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;
}

View file

@ -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<T> extends PageRoute<T> {
_CustomPageRoute({
@required this.builder,

View file

@ -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;
}