1 | // Copyright 2014 The Flutter Authors. All rights reserved. |
2 | // Use of this source code is governed by a BSD-style license that can be |
3 | // found in the LICENSE file. |
4 | |
5 | /// @docImport 'button_style.dart'; |
6 | /// @docImport 'elevated_button.dart'; |
7 | /// @docImport 'outlined_button.dart'; |
8 | /// @docImport 'text_button.dart'; |
9 | /// @docImport 'theme.dart'; |
10 | /// @docImport 'theme_data.dart'; |
11 | library; |
12 | |
13 | import 'dart:math' as math; |
14 | import 'dart:ui' as ui; |
15 | |
16 | import 'package:flutter/widgets.dart'; |
17 | import 'package:vector_math/vector_math_64.dart' ; |
18 | |
19 | import 'ink_well.dart'; |
20 | import 'material.dart'; |
21 | |
22 | /// Begin a Material 3 ink sparkle ripple, centered at the tap or click position |
23 | /// relative to the [referenceBox]. |
24 | /// |
25 | /// To use this effect, pass an instance of [splashFactory] to the |
26 | /// `splashFactory` parameter of either the Material [ThemeData] or any |
27 | /// component that has a `splashFactory` parameter, such as buttons: |
28 | /// - [ElevatedButton] |
29 | /// - [TextButton] |
30 | /// - [OutlinedButton] |
31 | /// |
32 | /// The [controller] argument is typically obtained via |
33 | /// `Material.of(context)`. |
34 | /// |
35 | /// If `containedInkWell` is true, then the effect will be sized to fit |
36 | /// the well rectangle, and clipped to it when drawn. The well |
37 | /// rectangle is the box returned by `rectCallback`, if provided, or |
38 | /// otherwise is the bounds of the [referenceBox]. |
39 | /// |
40 | /// If `containedInkWell` is false, then `rectCallback` should be null. |
41 | /// The ink ripple is clipped only to the edges of the [Material]. |
42 | /// This is the default. |
43 | /// |
44 | /// When the ripple is removed, [onRemoved] will be called. |
45 | /// |
46 | /// {@tool snippet} |
47 | /// |
48 | /// For typical use, pass the [InkSparkle.splashFactory] to the `splashFactory` |
49 | /// parameter of a button style or [ThemeData]. |
50 | /// |
51 | /// ```dart |
52 | /// ElevatedButton( |
53 | /// style: ElevatedButton.styleFrom(splashFactory: InkSparkle.splashFactory), |
54 | /// child: const Text('Sparkle!'), |
55 | /// onPressed: () { }, |
56 | /// ) |
57 | /// ``` |
58 | /// {@end-tool} |
59 | class InkSparkle extends InteractiveInkFeature { |
60 | /// Begin a sparkly ripple effect, centered at [position] relative to |
61 | /// [referenceBox]. |
62 | /// |
63 | /// The [color] defines the color of the splash itself. The sparkles are |
64 | /// always white. |
65 | /// |
66 | /// The [controller] argument is typically obtained via |
67 | /// `Material.of(context)`. |
68 | /// |
69 | /// [textDirection] is used by [customBorder] if it is non-null. This allows |
70 | /// the [customBorder]'s path to be properly defined if it was the path was |
71 | /// expressed in terms of "start" and "end" instead of |
72 | /// "left" and "right". |
73 | /// |
74 | /// If [containedInkWell] is true, then the ripple will be sized to fit |
75 | /// the well rectangle, then clipped to it when drawn. The well |
76 | /// rectangle is the box returned by [rectCallback], if provided, or |
77 | /// otherwise is the bounds of the [referenceBox]. |
78 | /// |
79 | /// If [containedInkWell] is false, then [rectCallback] should be null. |
80 | /// The ink ripple is clipped only to the edges of the [Material]. |
81 | /// This is the default. |
82 | /// |
83 | /// Clipping can happen in 3 different ways: |
84 | /// 1. If [customBorder] is provided, it is used to determine the path for |
85 | /// clipping. |
86 | /// 2. If [customBorder] is null, and [borderRadius] is provided, then the |
87 | /// canvas is clipped by an [RRect] created from [borderRadius]. |
88 | /// 3. If [borderRadius] is the default [BorderRadius.zero], then the canvas |
89 | /// is clipped with [rectCallback]. |
90 | /// When the ripple is removed, [onRemoved] will be called. |
91 | /// |
92 | /// [turbulenceSeed] can be passed if a non random seed should be used for |
93 | /// the turbulence and sparkles. By default, the seed is a random number |
94 | /// between 0.0 and 1000.0. |
95 | /// |
96 | /// Turbulence is an input to the shader and helps to provides a more natural, |
97 | /// non-circular, "splash" effect. |
98 | /// |
99 | /// Sparkle randomization is also driven by the [turbulenceSeed]. Sparkles are |
100 | /// identified in the shader as "noise", and the sparkles are derived from |
101 | /// pseudorandom triangular noise. |
102 | InkSparkle({ |
103 | required super.controller, |
104 | required super.referenceBox, |
105 | required super.color, |
106 | required Offset position, |
107 | required TextDirection textDirection, |
108 | bool containedInkWell = true, |
109 | RectCallback? rectCallback, |
110 | BorderRadius? borderRadius, |
111 | super.customBorder, |
112 | double? radius, |
113 | super.onRemoved, |
114 | double? turbulenceSeed, |
115 | }) : assert(containedInkWell || rectCallback == null), |
116 | _color = color, |
117 | _position = position, |
118 | _borderRadius = borderRadius ?? BorderRadius.zero, |
119 | _textDirection = textDirection, |
120 | _targetRadius = |
121 | (radius ?? _getTargetRadius(referenceBox, containedInkWell, rectCallback, position)) * |
122 | _targetRadiusMultiplier, |
123 | _clipCallback = _getClipCallback(referenceBox, containedInkWell, rectCallback) { |
124 | // InkSparkle will not be painted until the async compilation completes. |
125 | _InkSparkleFactory.initializeShader(); |
126 | controller.addInkFeature(this); |
127 | |
128 | // Immediately begin animating the ink. |
129 | _animationController = |
130 | AnimationController(duration: _animationDuration, vsync: controller.vsync) |
131 | ..addListener(controller.markNeedsPaint) |
132 | ..addStatusListener(_handleStatusChanged) |
133 | ..forward(); |
134 | |
135 | _radiusScale = TweenSequence<double>(<TweenSequenceItem<double>>[ |
136 | TweenSequenceItem<double>(tween: CurveTween(curve: Curves.fastOutSlowIn), weight: 75), |
137 | TweenSequenceItem<double>(tween: ConstantTween<double>(1.0), weight: 25), |
138 | ]).animate(_animationController); |
139 | |
140 | // Functionally equivalent to Android 12's SkSL: |
141 | //`return mix(u_touch, u_resolution, saturate(in_radius_scale * 2.0))` |
142 | final Tween<Vector2> centerTween = Tween<Vector2>( |
143 | begin: Vector2.array(<double>[_position.dx, _position.dy]), |
144 | end: Vector2.array(<double>[referenceBox.size.width / 2, referenceBox.size.height / 2]), |
145 | ); |
146 | final Animation<double> centerProgress = TweenSequence<double>(<TweenSequenceItem<double>>[ |
147 | TweenSequenceItem<double>(tween: Tween<double>(begin: 0.0, end: 1.0), weight: 50), |
148 | TweenSequenceItem<double>(tween: ConstantTween<double>(1.0), weight: 50), |
149 | ]).animate(_radiusScale); |
150 | _center = centerTween.animate(centerProgress); |
151 | |
152 | _alpha = TweenSequence<double>(<TweenSequenceItem<double>>[ |
153 | TweenSequenceItem<double>(tween: Tween<double>(begin: 0.0, end: 1.0), weight: 13), |
154 | TweenSequenceItem<double>(tween: ConstantTween<double>(1.0), weight: 27), |
155 | TweenSequenceItem<double>(tween: Tween<double>(begin: 1.0, end: 0.0), weight: 60), |
156 | ]).animate(_animationController); |
157 | |
158 | _sparkleAlpha = TweenSequence<double>(<TweenSequenceItem<double>>[ |
159 | TweenSequenceItem<double>(tween: Tween<double>(begin: 0.0, end: 1.0), weight: 13), |
160 | TweenSequenceItem<double>(tween: ConstantTween<double>(1.0), weight: 27), |
161 | TweenSequenceItem<double>(tween: Tween<double>(begin: 1.0, end: 0.0), weight: 50), |
162 | ]).animate(_animationController); |
163 | |
164 | // Creates an element of randomness so that ink emanating from the same |
165 | // pixel have slightly different rings and sparkles. |
166 | assert(() { |
167 | // In tests, randomness can cause flakes. So if a seed has not |
168 | // already been specified (i.e. for the purpose of the test), set it to |
169 | // the constant turbulence seed. |
170 | turbulenceSeed ??= _InkSparkleFactory.constantSeed; |
171 | return true; |
172 | }()); |
173 | _turbulenceSeed = turbulenceSeed ?? math.Random().nextDouble() * 1000.0; |
174 | } |
175 | |
176 | void _handleStatusChanged(AnimationStatus status) { |
177 | if (status.isCompleted) { |
178 | dispose(); |
179 | } |
180 | } |
181 | |
182 | static const Duration _animationDuration = Duration(milliseconds: 617); |
183 | static const double _targetRadiusMultiplier = 2.3; |
184 | static const double _rotateRight = math.pi * 0.0078125; |
185 | static const double _rotateLeft = -_rotateRight; |
186 | static const double _noiseDensity = 2.1; |
187 | |
188 | late AnimationController _animationController; |
189 | |
190 | // The Android 12 version has these values calculated in the GLSL. They are |
191 | // constant for every pixel in the animation, so the Flutter implementation |
192 | // computes these animation values in software in order to simplify the shader |
193 | // implementation and provide better performance on most devices. |
194 | late Animation<Vector2> _center; |
195 | late Animation<double> _radiusScale; |
196 | late Animation<double> _alpha; |
197 | late Animation<double> _sparkleAlpha; |
198 | |
199 | late double _turbulenceSeed; |
200 | |
201 | final Color _color; |
202 | final Offset _position; |
203 | final BorderRadius _borderRadius; |
204 | final double _targetRadius; |
205 | final RectCallback? _clipCallback; |
206 | final TextDirection _textDirection; |
207 | |
208 | late final ui.FragmentShader _fragmentShader; |
209 | bool _fragmentShaderInitialized = false; |
210 | |
211 | /// Used to specify this type of ink splash for an [InkWell], [InkResponse], |
212 | /// material [Theme], or [ButtonStyle]. |
213 | /// |
214 | /// Since no `turbulenceSeed` is passed, the effect will be random for |
215 | /// subsequent presses in the same position. |
216 | static const InteractiveInkFeatureFactory splashFactory = _InkSparkleFactory(); |
217 | |
218 | /// Used to specify this type of ink splash for an [InkWell], [InkResponse], |
219 | /// material [Theme], or [ButtonStyle]. |
220 | /// |
221 | /// Since a `turbulenceSeed` is passed, the effect will not be random for |
222 | /// subsequent presses in the same position. This can be used for testing. |
223 | static const InteractiveInkFeatureFactory constantTurbulenceSeedSplashFactory = |
224 | _InkSparkleFactory.constantTurbulenceSeed(); |
225 | |
226 | @override |
227 | void dispose() { |
228 | _animationController.stop(); |
229 | _animationController.dispose(); |
230 | if (_fragmentShaderInitialized) { |
231 | _fragmentShader.dispose(); |
232 | } |
233 | super.dispose(); |
234 | } |
235 | |
236 | @override |
237 | void paintFeature(Canvas canvas, Matrix4 transform) { |
238 | assert(_animationController.isAnimating); |
239 | |
240 | // InkSparkle can only paint if its shader has been compiled. |
241 | if (_InkSparkleFactory._program == null) { |
242 | // Skipping paintFeature because the shader it relies on is not ready to |
243 | // be used. InkSparkleFactory.initializeShader must complete |
244 | // before InkSparkle can paint. |
245 | return; |
246 | } |
247 | |
248 | if (!_fragmentShaderInitialized) { |
249 | _fragmentShader = _InkSparkleFactory._program!.fragmentShader(); |
250 | _fragmentShaderInitialized = true; |
251 | } |
252 | |
253 | canvas.save(); |
254 | _transformCanvas(canvas: canvas, transform: transform); |
255 | if (_clipCallback != null) { |
256 | _clipCanvas( |
257 | canvas: canvas, |
258 | clipCallback: _clipCallback, |
259 | textDirection: _textDirection, |
260 | customBorder: customBorder, |
261 | borderRadius: _borderRadius, |
262 | ); |
263 | } |
264 | |
265 | _updateFragmentShader(); |
266 | |
267 | final Paint paint = Paint()..shader = _fragmentShader; |
268 | if (_clipCallback != null) { |
269 | canvas.drawRect(_clipCallback(), paint); |
270 | } else { |
271 | canvas.drawPaint(paint); |
272 | } |
273 | canvas.restore(); |
274 | } |
275 | |
276 | double get _width => referenceBox.size.width; |
277 | double get _height => referenceBox.size.height; |
278 | |
279 | /// All double values for uniforms come from the Android 12 ripple |
280 | /// implementation from the following files: |
281 | /// - https://cs.android.com/android/platform/superproject/+/main:frameworks/base/graphics/java/android/graphics/drawable/RippleShader.java |
282 | /// - https://cs.android.com/android/platform/superproject/+/main:frameworks/base/graphics/java/android/graphics/drawable/RippleDrawable.java |
283 | /// - https://cs.android.com/android/platform/superproject/+/main:frameworks/base/graphics/java/android/graphics/drawable/RippleAnimationSession.java |
284 | void _updateFragmentShader() { |
285 | const double turbulenceScale = 1.5; |
286 | final double turbulencePhase = _turbulenceSeed + _radiusScale.value; |
287 | final double noisePhase = turbulencePhase; |
288 | final double rotation1 = turbulencePhase * _rotateRight + 1.7 * math.pi; |
289 | final double rotation2 = turbulencePhase * _rotateLeft + 2.0 * math.pi; |
290 | final double rotation3 = turbulencePhase * _rotateRight + 2.75 * math.pi; |
291 | |
292 | _fragmentShader |
293 | // uColor |
294 | ..setFloat(0, _color.red / 255.0) |
295 | ..setFloat(1, _color.green / 255.0) |
296 | ..setFloat(2, _color.blue / 255.0) |
297 | ..setFloat(3, _color.alpha / 255.0) |
298 | // Composite 1 (u_alpha, u_sparkle_alpha, u_blur, u_radius_scale) |
299 | ..setFloat(4, _alpha.value) |
300 | ..setFloat(5, _sparkleAlpha.value) |
301 | ..setFloat(6, 1.0) |
302 | ..setFloat(7, _radiusScale.value) |
303 | // uCenter |
304 | ..setFloat(8, _center.value.x) |
305 | ..setFloat(9, _center.value.y) |
306 | // uMaxRadius |
307 | ..setFloat(10, _targetRadius) |
308 | // uResolutionScale |
309 | ..setFloat(11, 1.0 / _width) |
310 | ..setFloat(12, 1.0 / _height) |
311 | // uNoiseScale |
312 | ..setFloat(13, _noiseDensity / _width) |
313 | ..setFloat(14, _noiseDensity / _height) |
314 | // uNoisePhase |
315 | ..setFloat(15, noisePhase / 1000.0) |
316 | // uCircle1 |
317 | ..setFloat( |
318 | 16, |
319 | turbulenceScale * 0.5 + (turbulencePhase * 0.01 * math.cos(turbulenceScale * 0.55)), |
320 | ) |
321 | ..setFloat( |
322 | 17, |
323 | turbulenceScale * 0.5 + (turbulencePhase * 0.01 * math.sin(turbulenceScale * 0.55)), |
324 | ) |
325 | // uCircle2 |
326 | ..setFloat( |
327 | 18, |
328 | turbulenceScale * 0.2 + (turbulencePhase * -0.0066 * math.cos(turbulenceScale * 0.45)), |
329 | ) |
330 | ..setFloat( |
331 | 19, |
332 | turbulenceScale * 0.2 + (turbulencePhase * -0.0066 * math.sin(turbulenceScale * 0.45)), |
333 | ) |
334 | // uCircle3 |
335 | ..setFloat( |
336 | 20, |
337 | turbulenceScale + (turbulencePhase * -0.0066 * math.cos(turbulenceScale * 0.35)), |
338 | ) |
339 | ..setFloat( |
340 | 21, |
341 | turbulenceScale + (turbulencePhase * -0.0066 * math.sin(turbulenceScale * 0.35)), |
342 | ) |
343 | // uRotation1 |
344 | ..setFloat(22, math.cos(rotation1)) |
345 | ..setFloat(23, math.sin(rotation1)) |
346 | // uRotation2 |
347 | ..setFloat(24, math.cos(rotation2)) |
348 | ..setFloat(25, math.sin(rotation2)) |
349 | // uRotation3 |
350 | ..setFloat(26, math.cos(rotation3)) |
351 | ..setFloat(27, math.sin(rotation3)); |
352 | } |
353 | |
354 | /// Transforms the canvas for an ink feature to be painted on the [canvas]. |
355 | /// |
356 | /// This should be called before painting ink features that do not use |
357 | /// [paintInkCircle]. |
358 | /// |
359 | /// The [transform] argument is the [Matrix4] transform that typically |
360 | /// shifts the coordinate space of the canvas to the space in which |
361 | /// the ink feature is to be painted. |
362 | /// |
363 | /// For examples on how the function is used, see [InkSparkle] and [paintInkCircle]. |
364 | void _transformCanvas({required Canvas canvas, required Matrix4 transform}) { |
365 | final Offset? originOffset = MatrixUtils.getAsTranslation(transform); |
366 | if (originOffset == null) { |
367 | canvas.transform(transform.storage); |
368 | } else { |
369 | canvas.translate(originOffset.dx, originOffset.dy); |
370 | } |
371 | } |
372 | |
373 | /// Clips the canvas for an ink feature to be painted on the [canvas]. |
374 | /// |
375 | /// This should be called before painting ink features with [paintFeature] |
376 | /// that do not use [paintInkCircle]. |
377 | /// |
378 | /// The [clipCallback] is the callback used to obtain the [Rect] used for clipping |
379 | /// the ink effect. |
380 | /// |
381 | /// If [clipCallback] is null, no clipping is performed on the ink circle. |
382 | /// |
383 | /// The [textDirection] is used by [customBorder] if it is non-null. This |
384 | /// allows the [customBorder]'s path to be properly defined if the path was |
385 | /// expressed in terms of "start" and "end" instead of "left" and "right". |
386 | /// |
387 | /// For examples on how the function is used, see [InkSparkle]. |
388 | void _clipCanvas({ |
389 | required Canvas canvas, |
390 | required RectCallback clipCallback, |
391 | TextDirection? textDirection, |
392 | ShapeBorder? customBorder, |
393 | BorderRadius borderRadius = BorderRadius.zero, |
394 | }) { |
395 | final Rect rect = clipCallback(); |
396 | if (customBorder != null) { |
397 | canvas.clipPath(customBorder.getOuterPath(rect, textDirection: textDirection)); |
398 | } else if (borderRadius != BorderRadius.zero) { |
399 | canvas.clipRRect( |
400 | RRect.fromRectAndCorners( |
401 | rect, |
402 | topLeft: borderRadius.topLeft, |
403 | topRight: borderRadius.topRight, |
404 | bottomLeft: borderRadius.bottomLeft, |
405 | bottomRight: borderRadius.bottomRight, |
406 | ), |
407 | ); |
408 | } else { |
409 | canvas.clipRect(rect); |
410 | } |
411 | } |
412 | } |
413 | |
414 | class _InkSparkleFactory extends InteractiveInkFeatureFactory { |
415 | const _InkSparkleFactory() : turbulenceSeed = null; |
416 | |
417 | const _InkSparkleFactory.constantTurbulenceSeed() |
418 | : turbulenceSeed = _InkSparkleFactory.constantSeed; |
419 | |
420 | static const double constantSeed = 1337.0; |
421 | |
422 | static void initializeShader() { |
423 | if (!_initCalled) { |
424 | ui.FragmentProgram.fromAsset('shaders/ink_sparkle.frag' ).then((ui.FragmentProgram program) { |
425 | _program = program; |
426 | }); |
427 | _initCalled = true; |
428 | } |
429 | } |
430 | |
431 | static bool _initCalled = false; |
432 | static ui.FragmentProgram? _program; |
433 | |
434 | final double? turbulenceSeed; |
435 | |
436 | @override |
437 | InteractiveInkFeature create({ |
438 | required MaterialInkController controller, |
439 | required RenderBox referenceBox, |
440 | required ui.Offset position, |
441 | required ui.Color color, |
442 | required ui.TextDirection textDirection, |
443 | bool containedInkWell = false, |
444 | RectCallback? rectCallback, |
445 | BorderRadius? borderRadius, |
446 | ShapeBorder? customBorder, |
447 | double? radius, |
448 | ui.VoidCallback? onRemoved, |
449 | }) { |
450 | return InkSparkle( |
451 | controller: controller, |
452 | referenceBox: referenceBox, |
453 | position: position, |
454 | color: color, |
455 | textDirection: textDirection, |
456 | containedInkWell: containedInkWell, |
457 | rectCallback: rectCallback, |
458 | borderRadius: borderRadius, |
459 | customBorder: customBorder, |
460 | radius: radius, |
461 | onRemoved: onRemoved, |
462 | turbulenceSeed: turbulenceSeed, |
463 | ); |
464 | } |
465 | } |
466 | |
467 | RectCallback? _getClipCallback( |
468 | RenderBox referenceBox, |
469 | bool containedInkWell, |
470 | RectCallback? rectCallback, |
471 | ) { |
472 | if (rectCallback != null) { |
473 | assert(containedInkWell); |
474 | return rectCallback; |
475 | } |
476 | if (containedInkWell) { |
477 | return () => Offset.zero & referenceBox.size; |
478 | } |
479 | return null; |
480 | } |
481 | |
482 | double _getTargetRadius( |
483 | RenderBox referenceBox, |
484 | bool containedInkWell, |
485 | RectCallback? rectCallback, |
486 | Offset position, |
487 | ) { |
488 | final Size size = rectCallback != null ? rectCallback().size : referenceBox.size; |
489 | final double d1 = size.bottomRight(Offset.zero).distance; |
490 | final double d2 = (size.topRight(Offset.zero) - size.bottomLeft(Offset.zero)).distance; |
491 | return math.max(d1, d2) / 2.0; |
492 | } |
493 | |