From de499c2a93d6470c1db5de63ddae55c4bd4c898a Mon Sep 17 00:00:00 2001 From: Dan Field Date: Fri, 11 Oct 2019 13:40:16 -0700 Subject: [PATCH] Gradient transform (#42484) --- bin/internal/goldens.version | 2 +- .../flutter/lib/src/painting/gradient.dart | 126 +++++++++++++++++- .../flutter/test/painting/gradient_test.dart | 72 ++++++++++ 3 files changed, 192 insertions(+), 8 deletions(-) diff --git a/bin/internal/goldens.version b/bin/internal/goldens.version index 9a7f8f1d588..db957bdeb5a 100644 --- a/bin/internal/goldens.version +++ b/bin/internal/goldens.version @@ -1 +1 @@ -7efcec3e8b0bbb6748a992b23a0a89300aa323c7 +fa13c1b039e693123888e434e4ee1f9ff79d3b6e diff --git a/packages/flutter/lib/src/painting/gradient.dart b/packages/flutter/lib/src/painting/gradient.dart index 374cc98330d..083aedf0112 100644 --- a/packages/flutter/lib/src/painting/gradient.dart +++ b/packages/flutter/lib/src/painting/gradient.dart @@ -4,9 +4,11 @@ import 'dart:collection'; import 'dart:math' as math; +import 'dart:typed_data'; import 'dart:ui' as ui show Gradient, lerpDouble; import 'package:flutter/foundation.dart'; +import 'package:vector_math/vector_math_64.dart'; import 'alignment.dart'; import 'basic_types.dart'; @@ -57,6 +59,64 @@ _ColorsAndStops _interpolateColorsAndStops( return _ColorsAndStops(interpolatedColors, interpolatedStops); } +/// Base class for transforming gradient shaders without applying the same +/// transform to the entire canvas. +/// +/// For example, a [SweepGradient] normally starts its gradation at 3 o'clock +/// and draws clockwise. To have the sweep appear to start at 6 o'clock, supply +/// a [GradientRotation] of `pi/4` radians (i.e. 45 degrees). +@immutable +abstract class GradientTransform { + /// A const constructor so that subclasses may be const. + const GradientTransform(); + + /// When a [Gradient] creates its [Shader], it will call this method to + /// determine what transform to apply to the shader for the given [Rect] and + /// [TextDirection]. + /// + /// Implementers may return null from this method, which achieves the same + /// final effect as returning [Matrix4.identity]. + Matrix4 transform(Rect bounds, {TextDirection textDirection}); +} + +/// A [GradientTransform] that rotates the gradient around the center-point of +/// its bounding box. +/// +/// {@tool sample} +/// +/// This sample would rotate a sweep gradient by a quarter turn clockwise: +/// +/// ```dart +/// const SweepGradient gradient = SweepGradient( +/// colors: [Color(0xFFFFFFFF), Color(0xFF009900)], +/// transform: GradientRotation(math.pi/4), +/// ); +/// ``` +@immutable +class GradientRotation extends GradientTransform { + /// Constructs a [GradientRotation] for the specified angle. + /// + /// The angle is in radians in the clockwise direction. + const GradientRotation(this.radians); + + /// The angle of rotation in radians in the clockwise direction. + final double radians; + + @override + Matrix4 transform(Rect bounds, {TextDirection textDirection}) { + assert(bounds != null); + final double sinRadians = math.sin(radians); + final double oneMinusCosRadians = 1 - math.cos(radians); + final Offset center = bounds.center; + final double originX = sinRadians * center.dy + oneMinusCosRadians * center.dx; + final double originY = -sinRadians * center.dx + oneMinusCosRadians * center.dy; + + return Matrix4.identity() + ..translate(originX, originY) + ..rotateZ(radians); + } +} + /// A 2D gradient. /// /// This is an interface that allows [LinearGradient], [RadialGradient], and @@ -76,9 +136,17 @@ abstract class Gradient { /// If specified, the [stops] argument must have the same number of entries as /// [colors] (this is also not verified until the [createShader] method is /// called). + /// + /// The [transform] argument can be applied to transform _only_ the gradient, + /// without rotating the canvas itself or other geometry on the canvas. For + /// example, a `GradientRotation(math.pi/4)` will result in a [SweepGradient] + /// that starts from a position of 6 o'clock instead of 3 o'clock, assuming + /// no other rotation or perspective transformations have been applied to the + /// [Canvas]. If null, no transformation is applied. const Gradient({ @required this.colors, this.stops, + this.transform, }) : assert(colors != null); /// The colors the gradient should obtain at each of the stops. @@ -107,6 +175,12 @@ abstract class Gradient { /// with the first stop at 0.0 and the last stop at 1.0. final List stops; + /// The transform, if any, to apply to the gradient. + /// + /// This transform is in addition to any other transformations applied to the + /// canvas, but does not add any transformations to the canvas. + final GradientTransform transform; + List _impliedStops() { if (stops != null) return stops; @@ -124,6 +198,9 @@ abstract class Gradient { /// If the gradient's configuration is text-direction-dependent, for example /// it uses [AlignmentDirectional] objects instead of [Alignment] /// objects, then the `textDirection` argument must not be null. + /// + /// The shader's transform will be resolved from the [transform] of this + /// gradient. Shader createShader(Rect rect, { TextDirection textDirection }); /// Returns a new gradient with its properties scaled by the given factor. @@ -220,6 +297,10 @@ abstract class Gradient { assert(a != null && b != null); return t < 0.5 ? a.scale(1.0 - (t * 2.0)) : b.scale((t - 0.5) * 2.0); } + + Float64List _resolveTransform(Rect bounds, TextDirection textDirection) { + return transform?.transform(bounds, textDirection: textDirection)?.storage; + } } /// A 2D linear gradient. @@ -284,10 +365,11 @@ class LinearGradient extends Gradient { @required List colors, List stops, this.tileMode = TileMode.clamp, + GradientTransform transform, }) : assert(begin != null), assert(end != null), assert(tileMode != null), - super(colors: colors, stops: stops); + super(colors: colors, stops: stops, transform: transform); /// The offset at which stop 0.0 of the gradient is placed. /// @@ -334,7 +416,7 @@ class LinearGradient extends Gradient { return ui.Gradient.linear( begin.resolve(textDirection).withinRect(rect), end.resolve(textDirection).withinRect(rect), - colors, _impliedStops(), tileMode, + colors, _impliedStops(), tileMode, _resolveTransform(rect, textDirection), ); } @@ -533,11 +615,12 @@ class RadialGradient extends Gradient { this.tileMode = TileMode.clamp, this.focal, this.focalRadius = 0.0, + GradientTransform transform, }) : assert(center != null), assert(radius != null), assert(tileMode != null), assert(focalRadius != null), - super(colors: colors, stops: stops); + super(colors: colors, stops: stops, transform: transform); /// The center of the gradient, as an offset into the (-1.0, -1.0) x (1.0, 1.0) /// square describing the gradient which will be mapped onto the paint box. @@ -605,7 +688,7 @@ class RadialGradient extends Gradient { center.resolve(textDirection).withinRect(rect), radius * rect.shortestSide, colors, _impliedStops(), tileMode, - null, // transform + _resolveTransform(rect, textDirection), focal == null ? null : focal.resolve(textDirection).withinRect(rect), focalRadius * rect.shortestSide, ); @@ -771,9 +854,36 @@ class RadialGradient extends Gradient { /// Color(0xFF4285F4), // blue again to seamlessly transition to the start /// ], /// stops: const [0.0, 0.25, 0.5, 0.75, 1.0], -/// ), +/// ), +/// ) +/// ) +/// ``` +/// {@end-tool} +/// +/// {@tool sample} +/// +/// This sample takes the above gradient and rotates it by `math.pi/4` radians, +/// i.e. 45 degrees. +/// +/// ```dart +/// Container( +/// decoration: BoxDecoration( +/// gradient: SweepGradient( +/// center: FractionalOffset.center, +/// startAngle: 0.0, +/// endAngle: math.pi * 2, +/// colors: const [ +/// Color(0xFF4285F4), // blue +/// Color(0xFF34A853), // green +/// Color(0xFFFBBC05), // yellow +/// Color(0xFFEA4335), // red +/// Color(0xFF4285F4), // blue again to seamlessly transition to the start +/// ], +/// stops: const [0.0, 0.25, 0.5, 0.75, 1.0], +/// transform: GradientRotation(math.pi/4), +/// ), /// ), -/// ) +/// ) /// ``` /// {@end-tool} /// @@ -797,11 +907,12 @@ class SweepGradient extends Gradient { @required List colors, List stops, this.tileMode = TileMode.clamp, + GradientTransform transform, }) : assert(center != null), assert(startAngle != null), assert(endAngle != null), assert(tileMode != null), - super(colors: colors, stops: stops); + super(colors: colors, stops: stops, transform: transform); /// The center of the gradient, as an offset into the (-1.0, -1.0) x (1.0, 1.0) /// square describing the gradient which will be mapped onto the paint box. @@ -846,6 +957,7 @@ class SweepGradient extends Gradient { colors, _impliedStops(), tileMode, startAngle, endAngle, + _resolveTransform(rect, textDirection), ); } diff --git a/packages/flutter/test/painting/gradient_test.dart b/packages/flutter/test/painting/gradient_test.dart index e11381da4be..6e567ae6884 100644 --- a/packages/flutter/test/painting/gradient_test.dart +++ b/packages/flutter/test/painting/gradient_test.dart @@ -3,6 +3,7 @@ // found in the LICENSE file. import 'dart:math' as math; +import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter/painting.dart'; @@ -765,4 +766,75 @@ void main() { expect(() { test2a.createShader(rect); }, throwsArgumentError); expect(() { test2b.createShader(rect); }, throwsArgumentError); }); + + group('Transforms', () { + const List colors = [Color(0xFFFFFFFF), Color(0xFF000088)]; + const Rect rect = Rect.fromLTWH(0.0, 0.0, 300.0, 400.0); + const List gradients45 = [ + LinearGradient(colors: colors, transform: GradientRotation(math.pi/4)), + // A radial gradient won't be interesting to rotate unless the center is changed. + RadialGradient(colors: colors, center: Alignment.topCenter, transform: GradientRotation(math.pi/4)), + SweepGradient(colors: colors, transform: GradientRotation(math.pi/4)), + ]; + const List gradients90 = [ + LinearGradient(colors: colors, transform: GradientRotation(math.pi/2)), + // A radial gradient won't be interesting to rotate unless the center is changed. + RadialGradient(colors: colors, center: Alignment.topCenter, transform: GradientRotation(math.pi/2)), + SweepGradient(colors: colors, transform: GradientRotation(math.pi/2)), + ]; + + const Map gradientSnakeCase = { + LinearGradient: 'linear_gradient', + RadialGradient: 'radial_gradient', + SweepGradient: 'sweep_gradient', + }; + + Future runTest(WidgetTester tester, Gradient gradient, double degrees) async { + final String goldenName = '${gradientSnakeCase[gradient.runtimeType]}_$degrees.png'; + final Shader shader = gradient.createShader( + rect, + ); + final Key painterKey = UniqueKey(); + await tester.pumpWidget(Center( + child: SizedBox.fromSize( + size: rect.size, + child: RepaintBoundary( + key: painterKey, + child: CustomPaint( + painter: GradientPainter(shader, rect) + ), + ), + ), + )); + await expectLater(find.byKey(painterKey), matchesGoldenFile(goldenName)); + } + + testWidgets('Gradients - 45 degrees', (WidgetTester tester) async { + for (Gradient gradient in gradients45) { + await runTest(tester, gradient, 45); + } + }); + + testWidgets('Gradients - 90 degrees', (WidgetTester tester) async { + for (Gradient gradient in gradients90) { + await runTest(tester, gradient, 90); + } + }); + }); +} + +class GradientPainter extends CustomPainter { + const GradientPainter(this.shader, this.rect); + + final Shader shader; + final Rect rect; + + @override + void paint(Canvas canvas, Size size) { + canvas.drawRect(rect, Paint()..shader = shader); + } + + @override + bool shouldRepaint(CustomPainter oldDelegate) => true; + }