Commit 6c6fdaff authored by Sander Dalby Larsen's avatar Sander Dalby Larsen Committed by Dan Field

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).
parent d126474c
...@@ -33,4 +33,5 @@ Stefan Mitev <mr.mitew@gmail.com> ...@@ -33,4 +33,5 @@ Stefan Mitev <mr.mitew@gmail.com>
Jasper van Riet <jaspervanriet@gmail.com> Jasper van Riet <jaspervanriet@gmail.com>
Mattijs Fuijkschot <mattijs.fuijkschot@gmail.com> Mattijs Fuijkschot <mattijs.fuijkschot@gmail.com>
TruongSinh Tran-Nguyen <i@truongsinh.pro> 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
c47f1308188dca65b3899228cac37f252ea8b411 034b2a540bc46375cf0c175a0fd512dcd46971e0
...@@ -51,6 +51,7 @@ export 'src/painting/paint_utilities.dart'; ...@@ -51,6 +51,7 @@ export 'src/painting/paint_utilities.dart';
export 'src/painting/rounded_rectangle_border.dart'; export 'src/painting/rounded_rectangle_border.dart';
export 'src/painting/shape_decoration.dart'; export 'src/painting/shape_decoration.dart';
export 'src/painting/stadium_border.dart'; export 'src/painting/stadium_border.dart';
export 'src/painting/superellipse_shape.dart';
export 'src/painting/text_painter.dart'; export 'src/painting/text_painter.dart';
export 'src/painting/text_span.dart'; export 'src/painting/text_span.dart';
export 'src/painting/text_style.dart'; export 'src/painting/text_style.dart';
// 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
// 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,
);
});
}
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