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> {
}
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.
// So the FAB's radius is half the guest width.
final double fabRadius = guest.width / 2.0;
final double notchRadius = fabRadius + widget.notchMargin;
assert(() {
if (guest.center.dx - notchRadius < start.dx)
throw new FlutterError(
'The notch\'s path start point must be to the left of the notch.\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 guest.\n'
'End point was $start, notch was $guest, notchMargin was ${widget.notchMargin}.'
);
return true;
}());
// 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.
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.
//
// The other side (b) would be the distance on the horizontal axis between the
// notch's center and the intersection points with it's top edge.
final double a = host.top - guest.center.dy;
final double b = math.sqrt(notchRadius * notchRadius - a * a);
// 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(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(
new Offset(guest.center.dx + b, host.top),
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;
}
}
......@@ -2,6 +2,7 @@
// 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';
......@@ -255,36 +256,15 @@ void main() {
final Offset end = const Offset(220.0, 100.0);
final Path actualNotch = computeNotch(host, guest, start, end);
final Path expectedNotch = new Path()
..lineTo(190.0, 100.0)
..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)
)
);
final Path notchedRectangle =
createNotchedRectangle(host, start.dx, end.dx, actualNotch);
expect(
createNotchedRectangle(host, start.dx, end.dx, actualNotch),
coversSameAreaAs(
createNotchedRectangle(host, start.dx, end.dx, expectedNotch),
areaToCompare: guest.inflate(10.0),
sampleSize: 50,
)
);
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)
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);
......@@ -292,33 +272,58 @@ void main() {
final Offset end = const Offset(220.0, 100.0);
final Path actualNotch = computeNotch(host, guest, start, end);
final Path expectedNotch = new Path()
..lineTo(186.0, 100.0)
..arcToPoint(
const Offset(214.0, 100.0),
radius: const Radius.circular(14.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)
)
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);
final Offset start = const Offset(180.0, 100.0);
final Offset end = const Offset(220.0, 100.0);
expect(
createNotchedRectangle(host, start.dx, end.dx, actualNotch),
coversSameAreaAs(
createNotchedRectangle(host, start.dx, end.dx, expectedNotch),
areaToCompare: guest.inflate(10.0),
sampleSize: 50,
)
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);
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) {
......@@ -393,3 +398,18 @@ class GeometryCachePainter extends CustomPainter {
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