Added the superellipse (a.k.a. squircle) shape to flutter. (#26295)
* Added the superellipse (a.k.a. squircle) shape to flutter, which is needed to recreate some cupertino components, e.g. buttons in pixel-perfect detail (issue #13914).
diff --git a/AUTHORS b/AUTHORS
index 3e7c5fd..f033b44 100644
--- a/AUTHORS
+++ b/AUTHORS
@@ -33,4 +33,5 @@
Jasper van Riet <jaspervanriet@gmail.com>
Mattijs Fuijkschot <mattijs.fuijkschot@gmail.com>
TruongSinh Tran-Nguyen <i@truongsinh.pro>
-Marco Scannadinari <m@scannadinari.co.uk>
+Sander Dalby Larsen <srdlarsen@gmail.com>
+Marco Scannadinari <m@scannadinari.co.uk>
\ No newline at end of file
diff --git a/bin/internal/goldens.version b/bin/internal/goldens.version
index 3c0cd70..a48622d 100644
--- a/bin/internal/goldens.version
+++ b/bin/internal/goldens.version
@@ -1 +1 @@
-c47f1308188dca65b3899228cac37f252ea8b411
+034b2a540bc46375cf0c175a0fd512dcd46971e0
diff --git a/packages/flutter/lib/painting.dart b/packages/flutter/lib/painting.dart
index 55894da..1bd1945 100644
--- a/packages/flutter/lib/painting.dart
+++ b/packages/flutter/lib/painting.dart
@@ -51,6 +51,7 @@
export 'src/painting/rounded_rectangle_border.dart';
export 'src/painting/shape_decoration.dart';
export 'src/painting/stadium_border.dart';
+export 'src/painting/superellipse_shape.dart';
export 'src/painting/text_painter.dart';
export 'src/painting/text_span.dart';
export 'src/painting/text_style.dart';
diff --git a/packages/flutter/lib/src/painting/superellipse_shape.dart b/packages/flutter/lib/src/painting/superellipse_shape.dart
new file mode 100644
index 0000000..58ed507
--- /dev/null
+++ b/packages/flutter/lib/src/painting/superellipse_shape.dart
@@ -0,0 +1,166 @@
+// 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 'basic_types.dart';
+import 'border_radius.dart';
+import 'borders.dart';
+import 'edge_insets.dart';
+
+/// Creates a superellipse - a shape similar to a rounded rectangle, but with
+/// a smoother transition from the sides to the rounded corners and greater
+/// curve continuity.
+///
+/// {@tool sample}
+/// ```dart
+/// Widget build(BuildContext context) {
+/// return Material(
+/// shape: SuperellipseShape(
+/// borderRadius: BorderRadius.circular(28.0),
+/// ),
+/// );
+/// }
+/// ```
+/// {@end-tool}
+///
+/// See also:
+///
+/// * [RoundedRectangleBorder] Which creates a square with rounded corners,
+/// however it doesn't allow the corners to bend the sides of the square
+/// like a superellipse, resulting in a more square shape.
+class SuperellipseShape extends ShapeBorder {
+ /// The arguments must not be null.
+ const SuperellipseShape({
+ this.side = BorderSide.none,
+ this.borderRadius = BorderRadius.zero,
+ }) : assert(side != null),
+ assert(borderRadius != null);
+
+ /// The radius for each corner.
+ ///
+ /// Negative radius values are clamped to 0.0 by [getInnerPath] and
+ /// [getOuterPath].
+ final BorderRadiusGeometry borderRadius;
+
+ /// The style of this border.
+ final BorderSide side;
+
+ @override
+ EdgeInsetsGeometry get dimensions => EdgeInsets.all(side.width);
+
+ @override
+ ShapeBorder scale(double t) {
+ return SuperellipseShape(
+ side: side.scale(t),
+ borderRadius: borderRadius * t,
+ );
+ }
+
+ @override
+ ShapeBorder lerpFrom(ShapeBorder a, double t) {
+ assert(t != null);
+ if (a is SuperellipseShape) {
+ return SuperellipseShape(
+ side: BorderSide.lerp(a.side, side, t),
+ borderRadius: BorderRadiusGeometry.lerp(a.borderRadius, borderRadius, t),
+ );
+ }
+ return super.lerpFrom(a, t);
+ }
+
+ @override
+ ShapeBorder lerpTo(ShapeBorder b, double t) {
+ assert(t != null);
+ if (b is SuperellipseShape) {
+ return SuperellipseShape(
+ side: BorderSide.lerp(side, b.side, t),
+ borderRadius: BorderRadiusGeometry.lerp(borderRadius, b.borderRadius, t),
+ );
+ }
+ return super.lerpTo(b, t);
+ }
+
+ double _clampToShortest(RRect rrect, double value) {
+ return value > rrect.shortestSide ? rrect.shortestSide : value;
+ }
+
+ Path _getPath(RRect rrect) {
+ final double left = rrect.left;
+ final double right = rrect.right;
+ final double top = rrect.top;
+ final double bottom = rrect.bottom;
+ // Radii will be clamped to the value of the shortest side
+ /// of [rrect] to avoid strange tie-fighter shapes.
+ final double tlRadiusX =
+ math.max(0.0, _clampToShortest(rrect, rrect.tlRadiusX));
+ final double tlRadiusY =
+ math.max(0.0, _clampToShortest(rrect, rrect.tlRadiusY));
+ final double trRadiusX =
+ math.max(0.0, _clampToShortest(rrect, rrect.trRadiusX));
+ final double trRadiusY =
+ math.max(0.0, _clampToShortest(rrect, rrect.trRadiusY));
+ final double blRadiusX =
+ math.max(0.0, _clampToShortest(rrect, rrect.blRadiusX));
+ final double blRadiusY =
+ math.max(0.0, _clampToShortest(rrect, rrect.blRadiusY));
+ final double brRadiusX =
+ math.max(0.0, _clampToShortest(rrect, rrect.brRadiusX));
+ final double brRadiusY =
+ math.max(0.0, _clampToShortest(rrect, rrect.brRadiusY));
+
+ return Path()
+ ..moveTo(left, top + tlRadiusX)
+ ..cubicTo(left, top, left, top, left + tlRadiusY, top)
+ ..lineTo(right - trRadiusX, top)
+ ..cubicTo(right, top, right, top, right, top + trRadiusY)
+ ..lineTo(right, bottom - blRadiusX)
+ ..cubicTo(right, bottom, right, bottom, right - blRadiusY, bottom)
+ ..lineTo(left + brRadiusX, bottom)
+ ..cubicTo(left, bottom, left, bottom, left, bottom - brRadiusY)
+ ..close();
+ }
+
+ @override
+ Path getInnerPath(Rect rect, {TextDirection textDirection}) {
+ return _getPath(borderRadius.resolve(textDirection).toRRect(rect).deflate(side.width));
+ }
+
+ @override
+ Path getOuterPath(Rect rect, {TextDirection textDirection}) {
+ return _getPath(borderRadius.resolve(textDirection).toRRect(rect));
+ }
+
+ @override
+ void paint(Canvas canvas, Rect rect, {TextDirection textDirection}) {
+ if (rect.isEmpty)
+ return;
+ switch (side.style) {
+ case BorderStyle.none:
+ break;
+ case BorderStyle.solid:
+ final Path path = getOuterPath(rect, textDirection: textDirection);
+ final Paint paint = side.toPaint();
+ canvas.drawPath(path, paint);
+ break;
+ }
+ }
+
+ @override
+ bool operator ==(dynamic other) {
+ if (runtimeType != other.runtimeType)
+ return false;
+ final SuperellipseShape typedOther = other;
+ return side == typedOther.side
+ && borderRadius == typedOther.borderRadius;
+ }
+
+ @override
+ int get hashCode => hashValues(side, borderRadius);
+
+ @override
+ String toString() {
+ return '$runtimeType($side, $borderRadius)';
+ }
+}
\ No newline at end of file
diff --git a/packages/flutter/test/painting/superellipse_shape_test.dart b/packages/flutter/test/painting/superellipse_shape_test.dart
new file mode 100644
index 0000000..867f8fd
--- /dev/null
+++ b/packages/flutter/test/painting/superellipse_shape_test.dart
@@ -0,0 +1,121 @@
+// 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:io' show Platform;
+import 'package:flutter/material.dart';
+import 'package:flutter/painting.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+import '../rendering/mock_canvas.dart';
+
+void main() {
+ test('SuperellipseShape scale and lerp', () {
+ final SuperellipseShape c10 = SuperellipseShape(side: const BorderSide(width: 10.0), borderRadius: BorderRadius.circular(100.0));
+ final SuperellipseShape c15 = SuperellipseShape(side: const BorderSide(width: 15.0), borderRadius: BorderRadius.circular(150.0));
+ final SuperellipseShape c20 = SuperellipseShape(side: const BorderSide(width: 20.0), borderRadius: BorderRadius.circular(200.0));
+ expect(c10.dimensions, const EdgeInsets.all(10.0));
+ expect(c10.scale(2.0), c20);
+ expect(c20.scale(0.5), c10);
+ expect(ShapeBorder.lerp(c10, c20, 0.0), c10);
+ expect(ShapeBorder.lerp(c10, c20, 0.5), c15);
+ expect(ShapeBorder.lerp(c10, c20, 1.0), c20);
+ });
+
+ test('SuperellipseShape BorderRadius.zero', () {
+ final Rect rect1 = Rect.fromLTRB(10.0, 20.0, 30.0, 40.0);
+ final Matcher looksLikeRect1 = isPathThat(
+ includes: const <Offset>[ Offset(10.0, 20.0), Offset(20.0, 30.0) ],
+ excludes: const <Offset>[ Offset(9.0, 19.0), Offset(31.0, 41.0) ],
+ );
+
+ // Default border radius and border side are zero, i.e. just a rectangle.
+ expect(const SuperellipseShape().getOuterPath(rect1), looksLikeRect1);
+ expect(const SuperellipseShape().getInnerPath(rect1), looksLikeRect1);
+
+ // Represents the inner path when borderSide.width = 4, which is just rect1
+ // inset by 4 on all sides.
+ final Matcher looksLikeInnerPath = isPathThat(
+ includes: const <Offset>[ Offset(14.0, 24.0), Offset(16.0, 26.0) ],
+ excludes: const <Offset>[ Offset(9.0, 23.0), Offset(27.0, 37.0) ],
+ );
+
+ const BorderSide side = BorderSide(width: 4.0);
+ expect(const SuperellipseShape(side: side).getOuterPath(rect1), looksLikeRect1);
+ expect(const SuperellipseShape(side: side).getInnerPath(rect1), looksLikeInnerPath);
+ });
+
+ test('SuperellipseShape non-zero BorderRadius', () {
+ final Rect rect = Rect.fromLTRB(10.0, 20.0, 30.0, 40.0);
+ final Matcher looksLikeRect = isPathThat(
+ includes: const <Offset>[ Offset(15.0, 25.0), Offset(20.0, 30.0) ],
+ excludes: const <Offset>[ Offset(10.0, 20.0), Offset(30.0, 40.0) ],
+ );
+ const SuperellipseShape border = SuperellipseShape(
+ borderRadius: BorderRadius.all(Radius.circular(5.0))
+ );
+ expect(border.getOuterPath(rect), looksLikeRect);
+ expect(border.getInnerPath(rect), looksLikeRect);
+ });
+
+ testWidgets('Golden test even radii', (WidgetTester tester) async {
+ await tester.pumpWidget(RepaintBoundary(
+ child: Material(
+ color: Colors.blueAccent[400],
+ shape: SuperellipseShape(
+ borderRadius: BorderRadius.circular(28.0),
+ ),
+ ),
+ ));
+
+ await tester.pumpAndSettle();
+
+ await expectLater(
+ find.byType(RepaintBoundary),
+ matchesGoldenFile('superellipse_shape.golden_test_even_radii.png'),
+ skip: !Platform.isLinux,
+ );
+ });
+
+ testWidgets('Golden test varying radii', (WidgetTester tester) async {
+ await tester.pumpWidget(RepaintBoundary(
+ child: Material(
+ color: Colors.greenAccent[400],
+ shape: const SuperellipseShape(
+ borderRadius: BorderRadius.only(
+ topLeft: Radius.circular(28.0),
+ bottomRight: Radius.circular(14.0),
+ ),
+ ),
+ ),
+ ));
+
+ await tester.pumpAndSettle();
+
+ await expectLater(
+ find.byType(RepaintBoundary),
+ matchesGoldenFile('superellipse_shape.golden_test_varying_radii.png'),
+ skip: !Platform.isLinux,
+ );
+ });
+
+ testWidgets('Golden test large radii', (WidgetTester tester) async {
+ await tester.pumpWidget(RepaintBoundary(
+ child: Material(
+ color: Colors.redAccent[400],
+ shape: SuperellipseShape(
+ borderRadius: BorderRadius.circular(50.0),
+ ),
+ ),
+ ));
+
+ await tester.pumpAndSettle();
+
+ await expectLater(
+ find.byType(RepaintBoundary),
+ matchesGoldenFile('superellipse_shape.golden_test_large_radii.png'),
+ skip: !Platform.isLinux,
+ );
+ });
+
+}