diff --git a/AUTHORS b/AUTHORS index 3e7c5fd4f0..f033b44868 100644 --- a/AUTHORS +++ b/AUTHORS @@ -33,4 +33,5 @@ Stefan Mitev Jasper van Riet Mattijs Fuijkschot TruongSinh Tran-Nguyen -Marco Scannadinari +Sander Dalby Larsen +Marco Scannadinari \ No newline at end of file diff --git a/bin/internal/goldens.version b/bin/internal/goldens.version index 3c0cd7062c..a48622d24c 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 55894da6ca..1bd1945633 100644 --- a/packages/flutter/lib/painting.dart +++ b/packages/flutter/lib/painting.dart @@ -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'; 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 0000000000..58ed5072cc --- /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 0000000000..867f8fda3b --- /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(10.0, 20.0), Offset(20.0, 30.0) ], + excludes: const [ 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(14.0, 24.0), Offset(16.0, 26.0) ], + excludes: const [ 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(15.0, 25.0), Offset(20.0, 30.0) ], + excludes: const [ 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, + ); + }); + +}