Skip to content

Commit

Permalink
Gradient transform (flutter#42484)
Browse files Browse the repository at this point in the history
  • Loading branch information
dnfield authored and Inconnu08 committed Nov 5, 2019
1 parent 696395a commit e065a0a
Show file tree
Hide file tree
Showing 3 changed files with 192 additions and 8 deletions.
2 changes: 1 addition & 1 deletion bin/internal/goldens.version
@@ -1 +1 @@
7efcec3e8b0bbb6748a992b23a0a89300aa323c7
fa13c1b039e693123888e434e4ee1f9ff79d3b6e
126 changes: 119 additions & 7 deletions packages/flutter/lib/src/painting/gradient.dart
Expand Up @@ -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';
Expand Down Expand Up @@ -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>[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
Expand All @@ -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.
Expand Down Expand Up @@ -107,6 +175,12 @@ abstract class Gradient {
/// with the first stop at 0.0 and the last stop at 1.0.
final List<double> 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<double> _impliedStops() {
if (stops != null)
return stops;
Expand All @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -284,10 +365,11 @@ class LinearGradient extends Gradient {
@required List<Color> colors,
List<double> 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.
///
Expand Down Expand Up @@ -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),
);
}

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
);
Expand Down Expand Up @@ -771,9 +854,36 @@ class RadialGradient extends Gradient {
/// Color(0xFF4285F4), // blue again to seamlessly transition to the start
/// ],
/// stops: const <double>[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>[
/// Color(0xFF4285F4), // blue
/// Color(0xFF34A853), // green
/// Color(0xFFFBBC05), // yellow
/// Color(0xFFEA4335), // red
/// Color(0xFF4285F4), // blue again to seamlessly transition to the start
/// ],
/// stops: const <double>[0.0, 0.25, 0.5, 0.75, 1.0],
/// transform: GradientRotation(math.pi/4),
/// ),
/// ),
/// )
/// )
/// ```
/// {@end-tool}
///
Expand All @@ -797,11 +907,12 @@ class SweepGradient extends Gradient {
@required List<Color> colors,
List<double> 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.
Expand Down Expand Up @@ -846,6 +957,7 @@ class SweepGradient extends Gradient {
colors, _impliedStops(), tileMode,
startAngle,
endAngle,
_resolveTransform(rect, textDirection),
);
}

Expand Down
72 changes: 72 additions & 0 deletions packages/flutter/test/painting/gradient_test.dart
Expand Up @@ -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';

Expand Down Expand Up @@ -765,4 +766,75 @@ void main() {
expect(() { test2a.createShader(rect); }, throwsArgumentError);
expect(() { test2b.createShader(rect); }, throwsArgumentError);
});

group('Transforms', () {
const List<Color> colors = <Color>[Color(0xFFFFFFFF), Color(0xFF000088)];
const Rect rect = Rect.fromLTWH(0.0, 0.0, 300.0, 400.0);
const List<Gradient> gradients45 = <Gradient>[
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<Gradient> gradients90 = <Gradient>[
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<Type, String> gradientSnakeCase = <Type, String> {
LinearGradient: 'linear_gradient',
RadialGradient: 'radial_gradient',
SweepGradient: 'sweep_gradient',
};

Future<void> 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;

}

0 comments on commit e065a0a

Please sign in to comment.