blob: 043af8a074d1181ce495036fb6c2ea272c50c6b8 [file] [log] [blame]
// Copyright 2014 The Flutter 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 'dart:ui' as ui;
import 'package:flutter/widgets.dart';
import 'package:vector_math/vector_math_64.dart';
import 'ink_well.dart';
import 'material.dart';
/// Begin a Material 3 ink sparkle ripple, centered at the tap or click position
/// relative to the [referenceBox].
///
/// This effect relies on a shader, and therefore hardware acceleration.
/// Currently, this is only supported by certain C++ engine platforms. The
/// platforms that are currently supported are Android, iOS, MacOS, Windows,
/// and Linux. Support for CanvasKit web can be tracked here:
/// - https://github.com/flutter/flutter/issues/85238
///
/// To use this effect, pass an instance of [splashFactory] to the
/// `splashFactory` parameter of either the Material [ThemeData] or any
/// component that has a `splashFactory` parameter, such as buttons:
/// - [ElevatedButton]
/// - [TextButton]
/// - [OutlinedButton]
///
/// The [controller] argument is typically obtained via
/// `Material.of(context)`.
///
/// If [containedInkWell] is true, then the effect will be sized to fit
/// the well rectangle, and clipped to it when drawn. The well
/// rectangle is the box returned by [rectCallback], if provided, or
/// otherwise is the bounds of the [referenceBox].
///
/// If [containedInkWell] is false, then [rectCallback] should be null.
/// The ink ripple is clipped only to the edges of the [Material].
/// This is the default.
///
/// When the ripple is removed, [onRemoved] will be called.
///
/// {@tool snippet}
///
/// For typical use, pass the [InkSparkle.splashFactory] to the `splashFactory`
/// parameter of a button style or [ThemeData].
///
/// ```dart
/// ElevatedButton(
/// style: ElevatedButton.styleFrom(splashFactory: InkSparkle.splashFactory),
/// child: const Text('Sparkle!'),
/// onPressed: () { },
/// )
/// ```
/// {@end-tool}
class InkSparkle extends InteractiveInkFeature {
/// Begin a sparkly ripple effect, centered at [position] relative to
/// [referenceBox].
///
/// The [color] defines the color of the splash itself. The sparkles are
/// always white.
///
/// The [controller] argument is typically obtained via
/// `Material.of(context)`.
///
/// [textDirection] is used by [customBorder] if it is non-null. This allows
/// the [customBorder]'s path to be properly defined if it was the path was
/// expressed in terms of "start" and "end" instead of
/// "left" and "right".
///
/// If [containedInkWell] is true, then the ripple will be sized to fit
/// the well rectangle, then clipped to it when drawn. The well
/// rectangle is the box returned by [rectCallback], if provided, or
/// otherwise is the bounds of the [referenceBox].
///
/// If [containedInkWell] is false, then [rectCallback] should be null.
/// The ink ripple is clipped only to the edges of the [Material].
/// This is the default.
///
/// Clipping can happen in 3 different ways:
/// 1. If [customBorder] is provided, it is used to determine the path for
/// clipping.
/// 2. If [customBorder] is null, and [borderRadius] is provided, then the
/// canvas is clipped by an [RRect] created from [borderRadius].
/// 3. If [borderRadius] is the default [BorderRadius.zero], then the canvas
/// is clipped with [rectCallback].
/// When the ripple is removed, [onRemoved] will be called.
///
/// [turbulenceSeed] can be passed if a non random seed should be used for
/// the turbulence and sparkles. By default, the seed is a random number
/// between 0.0 and 1000.0.
///
/// Turbulence is an input to the shader and helps to provides a more natural,
/// non-circular, "splash" effect.
///
/// Sparkle randomization is also driven by the [turbulenceSeed]. Sparkles are
/// identified in the shader as "noise", and the sparkles are derived from
/// pseudorandom triangular noise.
InkSparkle({
required super.controller,
required super.referenceBox,
required super.color,
required Offset position,
required TextDirection textDirection,
bool containedInkWell = true,
RectCallback? rectCallback,
BorderRadius? borderRadius,
ShapeBorder? customBorder,
double? radius,
super.onRemoved,
double? turbulenceSeed,
}) : assert(containedInkWell || rectCallback == null),
_color = color,
_position = position,
_borderRadius = borderRadius ?? BorderRadius.zero,
_customBorder = customBorder,
_textDirection = textDirection,
_targetRadius = (radius ?? _getTargetRadius(
referenceBox,
containedInkWell,
rectCallback,
position,
)
) * _targetRadiusMultiplier,
_clipCallback = _getClipCallback(referenceBox, containedInkWell, rectCallback) {
// InkSparkle will not be painted until the async compilation completes.
_InkSparkleFactory.initializeShader();
controller.addInkFeature(this);
// Immediately begin animating the ink.
_animationController = AnimationController(
duration: _animationDuration,
vsync: controller.vsync,
)..addListener(controller.markNeedsPaint)
..addStatusListener(_handleStatusChanged)
..forward();
_radiusScale = TweenSequence<double>(
<TweenSequenceItem<double>>[
TweenSequenceItem<double>(
tween: CurveTween(curve: Curves.fastOutSlowIn),
weight: 75,
),
TweenSequenceItem<double>(
tween: ConstantTween<double>(1.0),
weight: 25,
),
],
).animate(_animationController);
// Functionally equivalent to Android 12's SkSL:
//`return mix(u_touch, u_resolution, saturate(in_radius_scale * 2.0))`
final Tween<Vector2> centerTween = Tween<Vector2>(
begin: Vector2.array(<double>[_position.dx, _position.dy]),
end: Vector2.array(<double>[referenceBox.size.width / 2, referenceBox.size.height / 2]),
);
final Animation<double> centerProgress = TweenSequence<double>(
<TweenSequenceItem<double>>[
TweenSequenceItem<double>(
tween: Tween<double>(begin: 0.0, end: 1.0),
weight: 50,
),
TweenSequenceItem<double>(
tween: ConstantTween<double>(1.0),
weight: 50,
),
],
).animate(_radiusScale);
_center = centerTween.animate(centerProgress);
_alpha = TweenSequence<double>(
<TweenSequenceItem<double>>[
TweenSequenceItem<double>(
tween: Tween<double>(begin: 0.0, end: 1.0),
weight: 13,
),
TweenSequenceItem<double>(
tween: ConstantTween<double>(1.0),
weight: 27,
),
TweenSequenceItem<double>(
tween: Tween<double>(begin: 1.0, end: 0.0),
weight: 60,
),
],
).animate(_animationController);
_sparkleAlpha = TweenSequence<double>(
<TweenSequenceItem<double>>[
TweenSequenceItem<double>(
tween: Tween<double>(begin: 0.0, end: 1.0),
weight: 13,
),
TweenSequenceItem<double>(
tween: ConstantTween<double>(1.0),
weight: 27,
),
TweenSequenceItem<double>(
tween: Tween<double>(begin: 1.0, end: 0.0),
weight: 50,
),
],
).animate(_animationController);
// Creates an element of randomness so that ink eminating from the same
// pixel have slightly different rings and sparkles.
_turbulenceSeed = turbulenceSeed ?? math.Random().nextDouble() * 1000.0;
}
void _handleStatusChanged(AnimationStatus status) {
if (status == AnimationStatus.completed) {
dispose();
}
}
static const Duration _animationDuration = Duration(milliseconds: 617);
static const double _targetRadiusMultiplier = 2.3;
static const double _rotateRight = math.pi * 0.0078125;
static const double _rotateLeft = -_rotateRight;
static const double _noiseDensity = 2.1;
late AnimationController _animationController;
// The Android 12 version has these values calculated in the GLSL. They are
// constant for every pixel in the animation, so the Flutter implementation
// computes these animation values in software in order to simplify the shader
// implementation and provide better performance on most devices.
late Animation<Vector2> _center;
late Animation<double> _radiusScale;
late Animation<double> _alpha;
late Animation<double> _sparkleAlpha;
late double _turbulenceSeed;
final Color _color;
final Offset _position;
final BorderRadius _borderRadius;
final ShapeBorder? _customBorder;
final double _targetRadius;
final RectCallback? _clipCallback;
final TextDirection _textDirection;
late final ui.FragmentShader _fragmentShader;
bool _fragmentShaderInitialized = false;
/// Used to specify this type of ink splash for an [InkWell], [InkResponse],
/// material [Theme], or [ButtonStyle].
///
/// Since no [turbulenceSeed] is passed, the effect will be random for
/// subsequent presses in the same position.
static const InteractiveInkFeatureFactory splashFactory = _InkSparkleFactory();
/// Used to specify this type of ink splash for an [InkWell], [InkResponse],
/// material [Theme], or [ButtonStyle].
///
/// Since a [turbulenceSeed] is passed, the effect will not be random for
/// subsequent presses in the same position. This can be used for testing.
static const InteractiveInkFeatureFactory constantTurbulenceSeedSplashFactory = _InkSparkleFactory.constantTurbulenceSeed();
@override
void dispose() {
_animationController.stop();
_animationController.dispose();
if (_fragmentShaderInitialized) {
_fragmentShader.dispose();
}
super.dispose();
}
@override
void paintFeature(Canvas canvas, Matrix4 transform) {
assert(_animationController.isAnimating);
// InkSparkle can only paint if its shader has been compiled.
if (_InkSparkleFactory._program == null) {
// Skipping paintFeature because the shader it relies on is not ready to
// be used. InkSparkleFactory.initializeShader must complete
// before InkSparkle can paint.
return;
}
if (!_fragmentShaderInitialized) {
_fragmentShader = _InkSparkleFactory._program!.fragmentShader();
_fragmentShaderInitialized = true;
}
canvas.save();
_transformCanvas(canvas: canvas, transform: transform);
if (_clipCallback != null) {
_clipCanvas(
canvas: canvas,
clipCallback: _clipCallback!,
textDirection: _textDirection,
customBorder: _customBorder,
borderRadius: _borderRadius,
);
}
_updateFragmentShader();
final Paint paint = Paint()..shader = _fragmentShader;
if (_clipCallback != null) {
canvas.drawRect(_clipCallback!(), paint);
} else {
canvas.drawPaint(paint);
}
canvas.restore();
}
double get _width => referenceBox.size.width;
double get _height => referenceBox.size.height;
/// All double values for uniforms come from the Android 12 ripple
/// implementation from the following files:
/// - https://cs.android.com/android/platform/superproject/+/master:frameworks/base/graphics/java/android/graphics/drawable/RippleShader.java
/// - https://cs.android.com/android/platform/superproject/+/master:frameworks/base/graphics/java/android/graphics/drawable/RippleDrawable.java
/// - https://cs.android.com/android/platform/superproject/+/master:frameworks/base/graphics/java/android/graphics/drawable/RippleAnimationSession.java
void _updateFragmentShader() {
const double turbulenceScale = 1.5;
final double turbulencePhase = _turbulenceSeed + _radiusScale.value;
final double noisePhase = turbulencePhase;
final double rotation1 = turbulencePhase * _rotateRight + 1.7 * math.pi;
final double rotation2 = turbulencePhase * _rotateLeft + 2.0 * math.pi;
final double rotation3 = turbulencePhase * _rotateRight + 2.75 * math.pi;
_fragmentShader
// uColor
..setFloat(0, _color.red / 255.0)
..setFloat(1, _color.green / 255.0)
..setFloat(2, _color.blue / 255.0)
..setFloat(3, _color.alpha / 255.0)
// uAlpha
..setFloat(4, _alpha.value)
// uSparkleColor
..setFloat(5, 1.0)
..setFloat(6, 1.0)
..setFloat(7, 1.0)
..setFloat(8, 1.0)
// uSparkleAlpha
..setFloat(9, _sparkleAlpha.value)
// uBlur
..setFloat(10, 1.0)
// uCenter
..setFloat(11, _center.value.x)
..setFloat(12, _center.value.y)
// uRadiusScale
..setFloat(13, _radiusScale.value)
// uMaxRadius
..setFloat(14, _targetRadius)
// uResolutionScale
..setFloat(15, 1.0 / _width)
..setFloat(16, 1.0 / _height)
// uNoiseScale
..setFloat(17, _noiseDensity / _width)
..setFloat(18, _noiseDensity / _height)
// uNoisePhase
..setFloat(19, noisePhase / 1000.0)
// uCircle1
..setFloat(20, turbulenceScale * 0.5 + (turbulencePhase * 0.01 * math.cos(turbulenceScale * 0.55)))
..setFloat(21, turbulenceScale * 0.5 + (turbulencePhase * 0.01 * math.sin(turbulenceScale * 0.55)))
// uCircle2
..setFloat(22, turbulenceScale * 0.2 + (turbulencePhase * -0.0066 * math.cos(turbulenceScale * 0.45)))
..setFloat(23, turbulenceScale * 0.2 + (turbulencePhase * -0.0066 * math.sin(turbulenceScale * 0.45)))
// uCircle3
..setFloat(24, turbulenceScale + (turbulencePhase * -0.0066 * math.cos(turbulenceScale * 0.35)))
..setFloat(25, turbulenceScale + (turbulencePhase * -0.0066 * math.sin(turbulenceScale * 0.35)))
// uRotation1
..setFloat(26, math.cos(rotation1))
..setFloat(27, math.sin(rotation1))
// uRotation2
..setFloat(28, math.cos(rotation2))
..setFloat(29, math.sin(rotation2))
// uRotation3
..setFloat(30, math.cos(rotation3))
..setFloat(31, math.sin(rotation3));
}
/// Transforms the canvas for an ink feature to be painted on the [canvas].
///
/// This should be called before painting ink features that do not use
/// [paintInkCircle].
///
/// The [transform] argument is the [Matrix4] transform that typically
/// shifts the coordinate space of the canvas to the space in which
/// the ink feature is to be painted.
///
/// For examples on how the function is used, see [InkSparkle] and [paintInkCircle].
void _transformCanvas({
required Canvas canvas,
required Matrix4 transform,
}) {
final Offset? originOffset = MatrixUtils.getAsTranslation(transform);
if (originOffset == null) {
canvas.transform(transform.storage);
} else {
canvas.translate(originOffset.dx, originOffset.dy);
}
}
/// Clips the canvas for an ink feature to be painted on the [canvas].
///
/// This should be called before painting ink features with [paintFeature]
/// that do not use [paintInkCircle].
///
/// The [clipCallback] is the callback used to obtain the [Rect] used for clipping
/// the ink effect.
///
/// If [clipCallback] is null, no clipping is performed on the ink circle.
///
/// The [textDirection] is used by [customBorder] if it is non-null. This
/// allows the [customBorder]'s path to be properly defined if the path was
/// expressed in terms of "start" and "end" instead of "left" and "right".
///
/// For examples on how the function is used, see [InkSparkle].
void _clipCanvas({
required Canvas canvas,
required RectCallback clipCallback,
TextDirection? textDirection,
ShapeBorder? customBorder,
BorderRadius borderRadius = BorderRadius.zero,
}) {
final Rect rect = clipCallback();
if (customBorder != null) {
canvas.clipPath(
customBorder.getOuterPath(rect, textDirection: textDirection));
} else if (borderRadius != BorderRadius.zero) {
canvas.clipRRect(RRect.fromRectAndCorners(
rect,
topLeft: borderRadius.topLeft,
topRight: borderRadius.topRight,
bottomLeft: borderRadius.bottomLeft,
bottomRight: borderRadius.bottomRight,
));
} else {
canvas.clipRect(rect);
}
}
}
class _InkSparkleFactory extends InteractiveInkFeatureFactory {
const _InkSparkleFactory() : turbulenceSeed = null;
const _InkSparkleFactory.constantTurbulenceSeed() : turbulenceSeed = 1337.0;
static void initializeShader() {
if (!_initCalled) {
ui.FragmentProgram.fromAsset('shaders/ink_sparkle.frag').then(
(ui.FragmentProgram program) {
_program = program;
},
);
_initCalled = true;
}
}
static bool _initCalled = false;
static ui.FragmentProgram? _program;
final double? turbulenceSeed;
@override
InteractiveInkFeature create({
required MaterialInkController controller,
required RenderBox referenceBox,
required ui.Offset position,
required ui.Color color,
required ui.TextDirection textDirection,
bool containedInkWell = false,
RectCallback? rectCallback,
BorderRadius? borderRadius,
ShapeBorder? customBorder,
double? radius,
ui.VoidCallback? onRemoved,
}) {
return InkSparkle(
controller: controller,
referenceBox: referenceBox,
position: position,
color: color,
textDirection: textDirection,
containedInkWell: containedInkWell,
rectCallback: rectCallback,
borderRadius: borderRadius,
customBorder: customBorder,
radius: radius,
onRemoved: onRemoved,
turbulenceSeed: turbulenceSeed,
);
}
}
RectCallback? _getClipCallback(
RenderBox referenceBox,
bool containedInkWell,
RectCallback? rectCallback,
) {
if (rectCallback != null) {
assert(containedInkWell);
return rectCallback;
}
if (containedInkWell) {
return () => Offset.zero & referenceBox.size;
}
return null;
}
double _getTargetRadius(
RenderBox referenceBox,
bool containedInkWell,
RectCallback? rectCallback,
Offset position,
) {
final Size size = rectCallback != null ? rectCallback().size : referenceBox.size;
final double d1 = size.bottomRight(Offset.zero).distance;
final double d2 = (size.topRight(Offset.zero) - size.bottomLeft(Offset.zero)).distance;
return math.max(d1, d2) / 2.0;
}