Smooth Floating Action Button notch (#14851)
This commit is contained in:
parent
7f03b9e41b
commit
5d39f15c69
@ -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)
|
|
||||||
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
|
assert(_notchAssertions(host, guest, start, end, fabRadius, notchRadius));
|
||||||
// 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
|
// If there's no overlap between the guest's margin boundary and the host,
|
||||||
// top edge.
|
// 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 hypotenuse of this triangle equals the notch's radius, and one side
|
// A detailed explanation and the derivation of the formulas below is
|
||||||
// (a) is the distance from the notch's center to the top edge.
|
// available at: https://goo.gl/Ufzrqn
|
||||||
//
|
|
||||||
// The other side (b) would be the distance on the horizontal axis between the
|
const double s1 = 15.0;
|
||||||
// notch's center and the intersection points with it's top edge.
|
const double s2 = 1.0;
|
||||||
final double a = host.top - guest.center.dy;
|
|
||||||
final double b = math.sqrt(notchRadius * notchRadius - a * a);
|
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 @@
|
|||||||
// 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() {
|
|||||||
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(
|
expect(pathDoesNotContainCircle(notchedRectangle, guest), isTrue);
|
||||||
createNotchedRectangle(host, start.dx, end.dx, actualNotch),
|
|
||||||
coversSameAreaAs(
|
|
||||||
createNotchedRectangle(host, start.dx, end.dx, expectedNotch),
|
|
||||||
areaToCompare: host.inflate(10.0)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
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() {
|
|||||||
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
|
|
||||||
)
|
|
||||||
..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(
|
|
||||||
createNotchedRectangle(host, start.dx, end.dx, actualNotch),
|
|
||||||
coversSameAreaAs(
|
|
||||||
createNotchedRectangle(host, start.dx, end.dx, expectedNotch),
|
|
||||||
areaToCompare: guest.inflate(10.0),
|
|
||||||
sampleSize: 50,
|
|
||||||
)
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
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) {
|
Path createNotchedRectangle(Rect container, double startX, double endX, Path notch) {
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user