Unverified Commit 5d39f15c authored by amirh's avatar amirh Committed by GitHub

Smooth Floating Action Button notch (#14851)

parent 7f03b9e4
...@@ -227,65 +227,95 @@ class _FloatingActionButtonState extends State<FloatingActionButton> { ...@@ -227,65 +227,95 @@ class _FloatingActionButtonState extends State<FloatingActionButton> {
} }
Path _computeNotch(Rect host, Rect guest, Offset start, Offset end) { Path _computeNotch(Rect host, Rect guest, Offset start, Offset end) {
assert(() {
if (end.dy != host.top)
throw new FlutterError(
'The floating action button\'s notch maker must only be used for a notch in 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 floating action button\'s notch maker must only be used for a notch in the top edge the host.\n'
'The notch\'s path start point: $start is not in the top edge of $host'
);
return true;
}());
assert(() {
if (!host.overlaps(guest))
throw new FlutterError('Notch host must intersect with its guest');
return true;
}());
// The FAB's shape is a circle bounded by the guest rectangle. // The FAB's shape is a circle bounded by the guest rectangle.
// So the FAB's radius is half the guest width. // So the FAB's radius is half the guest width.
final double fabRadius = guest.width / 2.0; final double fabRadius = guest.width / 2.0;
final double notchRadius = fabRadius + widget.notchMargin; final double notchRadius = fabRadius + widget.notchMargin;
assert(() {
if (guest.center.dx - notchRadius < start.dx) assert(_notchAssertions(host, guest, start, end, fabRadius, notchRadius));
throw new FlutterError(
'The notch\'s path start point must be to the left of the notch.\n' // If there's no overlap between the guest's margin boundary and the host,
'Start point was $start, guest was $guest, notchMargin was ${widget.notchMargin}.' // don't make a notch, just return a straight line from start to end.
); if (!host.overlaps(guest.inflate(widget.notchMargin)))
if (guest.center.dx + notchRadius > end.dx) return new Path()..lineTo(end.dx, end.dy);
throw new FlutterError(
'The notch\'s end point must be to the right of the guest.\n' // We build a path for the notch from 3 segments:
'End point was $start, notch was $guest, notchMargin was ${widget.notchMargin}.' // Segment A - a Bezier curve from the host's top edge to segment B.
); // Segment B - an arc with radius notchRadius.
return true; // Segment C - a Bezier curver from segment B back to the host's top edge.
}());
// We find the intersection of the notch's circle with the top edge of the host
// using the Pythagorean theorem for the right triangle that connects the
// center of the notch and the intersection of the notch's circle and the host's
// top edge.
//
// The hypotenuse of this triangle equals the notch's radius, and one side
// (a) is the distance from the notch's center to the top edge.
// //
// The other side (b) would be the distance on the horizontal axis between the // A detailed explanation and the derivation of the formulas below is
// notch's center and the intersection points with it's top edge. // available at: https://goo.gl/Ufzrqn
final double a = host.top - guest.center.dy;
final double b = math.sqrt(notchRadius * notchRadius - a * a); 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() return new Path()
..lineTo(guest.center.dx - b, host.top) ..lineTo(p[0].dx, p[0].dy)
..quadraticBezierTo(p[1].dx, p[1].dy, p[2].dx, p[2].dy)
..arcToPoint( ..arcToPoint(
new Offset(guest.center.dx + b, host.top), p[3],
radius: new Radius.circular(notchRadius), radius: new Radius.circular(notchRadius),
clockwise: false, clockwise: false,
) )
..quadraticBezierTo(p[4].dx, p[4].dy, p[5].dx, p[5].dy)
..lineTo(end.dx, end.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;
}
} }
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
// Use of this source code is governed by a BSD-style license that can be // Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file. // found in the LICENSE file.
import 'dart:math' as math;
import 'dart:ui'; import 'dart:ui';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
...@@ -255,36 +256,15 @@ void main() { ...@@ -255,36 +256,15 @@ void main() {
final Offset end = const Offset(220.0, 100.0); final Offset end = const Offset(220.0, 100.0);
final Path actualNotch = computeNotch(host, guest, start, end); final Path actualNotch = computeNotch(host, guest, start, end);
final Path expectedNotch = new Path() final Path notchedRectangle =
..lineTo(190.0, 100.0) createNotchedRectangle(host, start.dx, end.dx, actualNotch);
..arcToPoint(
const Offset(210.0, 100.0),
radius: const Radius.circular(10.0),
clockwise: false
)
..lineTo(220.0, 100.0);
expect(
createNotchedRectangle(host, start.dx, end.dx, actualNotch),
coversSameAreaAs(
createNotchedRectangle(host, start.dx, end.dx, expectedNotch),
areaToCompare: host.inflate(10.0)
)
);
expect( expect(pathDoesNotContainCircle(notchedRectangle, guest), isTrue);
createNotchedRectangle(host, start.dx, end.dx, actualNotch),
coversSameAreaAs(
createNotchedRectangle(host, start.dx, end.dx, expectedNotch),
areaToCompare: guest.inflate(10.0),
sampleSize: 50,
)
);
}); });
testWidgets('notch with margin', (WidgetTester tester) async { testWidgets('notch with margin', (WidgetTester tester) async {
final ComputeNotch computeNotch = await fetchComputeNotch(tester, final ComputeNotch computeNotch = await fetchComputeNotch(tester,
const FloatingActionButton(onPressed: null, notchMargin: 4.0) const FloatingActionButton(onPressed: null, notchMargin: 4.0)
); );
final Rect host = new Rect.fromLTRB(0.0, 100.0, 300.0, 300.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); final Rect guest = new Rect.fromLTRB(190.0, 90.0, 210.0, 110.0);
...@@ -292,33 +272,58 @@ void main() { ...@@ -292,33 +272,58 @@ void main() {
final Offset end = const Offset(220.0, 100.0); final Offset end = const Offset(220.0, 100.0);
final Path actualNotch = computeNotch(host, guest, start, end); final Path actualNotch = computeNotch(host, guest, start, end);
final Path expectedNotch = new Path() final Path notchedRectangle =
..lineTo(186.0, 100.0) createNotchedRectangle(host, start.dx, end.dx, actualNotch);
..arcToPoint( expect(pathDoesNotContainCircle(notchedRectangle, guest.inflate(4.0)), isTrue);
const Offset(214.0, 100.0), });
radius: const Radius.circular(14.0),
clockwise: false testWidgets('notch circle center above BAB', (WidgetTester tester) async {
) final ComputeNotch computeNotch = await fetchComputeNotch(tester,
..lineTo(220.0, 100.0); const FloatingActionButton(onPressed: null, notchMargin: 4.0)
expect(
createNotchedRectangle(host, start.dx, end.dx, actualNotch),
coversSameAreaAs(
createNotchedRectangle(host, start.dx, end.dx, expectedNotch),
areaToCompare: host.inflate(10.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);
final Offset start = const Offset(180.0, 100.0);
final Offset end = const Offset(220.0, 100.0);
expect( final Path actualNotch = computeNotch(host, guest, start, end);
createNotchedRectangle(host, start.dx, end.dx, actualNotch), final Path notchedRectangle =
coversSameAreaAs( createNotchedRectangle(host, start.dx, end.dx, actualNotch);
createNotchedRectangle(host, start.dx, end.dx, expectedNotch), expect(pathDoesNotContainCircle(notchedRectangle, guest.inflate(4.0)), isTrue);
areaToCompare: guest.inflate(10.0), });
sampleSize: 50,
) 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);
final Offset start = const Offset(180.0, 100.0);
final 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);
final Offset start = const Offset(180.0, 100.0);
final 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) { Path createNotchedRectangle(Rect container, double startX, double endX, Path notch) {
...@@ -393,3 +398,18 @@ class GeometryCachePainter extends CustomPainter { ...@@ -393,3 +398,18 @@ class GeometryCachePainter extends CustomPainter {
return true; 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;
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment