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).
This commit is contained in:
committed by
Dan Field
parent
d126474cd1
commit
6c6fdaff81
3
AUTHORS
3
AUTHORS
@@ -33,4 +33,5 @@ Stefan Mitev <mr.mitew@gmail.com>
|
||||
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>
|
||||
@@ -1 +1 @@
|
||||
c47f1308188dca65b3899228cac37f252ea8b411
|
||||
034b2a540bc46375cf0c175a0fd512dcd46971e0
|
||||
|
||||
@@ -51,6 +51,7 @@ export 'src/painting/paint_utilities.dart';
|
||||
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';
|
||||
|
||||
166
packages/flutter/lib/src/painting/superellipse_shape.dart
Normal file
166
packages/flutter/lib/src/painting/superellipse_shape.dart
Normal file
@@ -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)';
|
||||
}
|
||||
}
|
||||
121
packages/flutter/test/painting/superellipse_shape_test.dart
Normal file
121
packages/flutter/test/painting/superellipse_shape_test.dart
Normal file
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user