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 'data_table.dart'; |
6 | /// @docImport 'elevated_button.dart'; |
7 | /// @docImport 'icon_button.dart'; |
8 | /// @docImport 'ink_decoration.dart'; |
9 | /// @docImport 'ink_ripple.dart'; |
10 | /// @docImport 'ink_splash.dart'; |
11 | /// @docImport 'text_button.dart'; |
12 | library; |
13 | |
14 | import 'dart:async'; |
15 | import 'dart:collection'; |
16 | |
17 | import 'package:flutter/foundation.dart'; |
18 | import 'package:flutter/gestures.dart'; |
19 | import 'package:flutter/rendering.dart'; |
20 | import 'package:flutter/widgets.dart'; |
21 | |
22 | import 'debug.dart'; |
23 | import 'ink_highlight.dart'; |
24 | import 'material.dart'; |
25 | import 'material_state.dart'; |
26 | import 'theme.dart'; |
27 | |
28 | // Examples can assume: |
29 | // late BuildContext context; |
30 | |
31 | /// An ink feature that displays a [color] "splash" in response to a user |
32 | /// gesture that can be confirmed or canceled. |
33 | /// |
34 | /// Subclasses call [confirm] when an input gesture is recognized. For |
35 | /// example a press event might trigger an ink feature that's confirmed |
36 | /// when the corresponding up event is seen. |
37 | /// |
38 | /// Subclasses call [cancel] when an input gesture is aborted before it |
39 | /// is recognized. For example a press event might trigger an ink feature |
40 | /// that's canceled when the pointer is dragged out of the reference |
41 | /// box. |
42 | /// |
43 | /// The [InkWell] and [InkResponse] widgets generate instances of this |
44 | /// class. |
45 | abstract class InteractiveInkFeature extends InkFeature { |
46 | /// Creates an InteractiveInkFeature. |
47 | InteractiveInkFeature({ |
48 | required super.controller, |
49 | required super.referenceBox, |
50 | required Color color, |
51 | ShapeBorder? customBorder, |
52 | super.onRemoved, |
53 | }) : _color = color, |
54 | _customBorder = customBorder; |
55 | |
56 | /// Called when the user input that triggered this feature's appearance was confirmed. |
57 | /// |
58 | /// Typically causes the ink to propagate faster across the material. By default this |
59 | /// method does nothing. |
60 | void confirm() {} |
61 | |
62 | /// Called when the user input that triggered this feature's appearance was canceled. |
63 | /// |
64 | /// Typically causes the ink to gradually disappear. By default this method does |
65 | /// nothing. |
66 | void cancel() {} |
67 | |
68 | /// The ink's color. |
69 | Color get color => _color; |
70 | Color _color; |
71 | set color(Color value) { |
72 | if (value == _color) { |
73 | return; |
74 | } |
75 | _color = value; |
76 | controller.markNeedsPaint(); |
77 | } |
78 | |
79 | /// The ink's optional custom border. |
80 | ShapeBorder? get customBorder => _customBorder; |
81 | ShapeBorder? _customBorder; |
82 | set customBorder(ShapeBorder? value) { |
83 | if (value == _customBorder) { |
84 | return; |
85 | } |
86 | _customBorder = value; |
87 | controller.markNeedsPaint(); |
88 | } |
89 | |
90 | /// Draws an ink splash or ink ripple on the passed in [Canvas]. |
91 | /// |
92 | /// The [transform] argument is the [Matrix4] transform that typically |
93 | /// shifts the coordinate space of the canvas to the space in which |
94 | /// the ink circle is to be painted. |
95 | /// |
96 | /// [center] is the [Offset] from origin of the canvas where the center |
97 | /// of the circle is drawn. |
98 | /// |
99 | /// [paint] takes a [Paint] object that describes the styles used to draw the ink circle. |
100 | /// For example, [paint] can specify properties like color, strokewidth, colorFilter. |
101 | /// |
102 | /// [radius] is the radius of ink circle to be drawn on canvas. |
103 | /// |
104 | /// [clipCallback] is the callback used to obtain the [Rect] used for clipping the ink effect. |
105 | /// If [clipCallback] is null, no clipping is performed on the ink circle. |
106 | /// |
107 | /// Clipping can happen in 3 different ways: |
108 | /// 1. If [customBorder] is provided, it is used to determine the path |
109 | /// for clipping. |
110 | /// 2. If [customBorder] is null, and [borderRadius] is provided, the canvas |
111 | /// is clipped by an [RRect] created from [clipCallback] and [borderRadius]. |
112 | /// 3. If [borderRadius] is the default [BorderRadius.zero], then the [Rect] provided |
113 | /// by [clipCallback] is used for clipping. |
114 | /// |
115 | /// [textDirection] is used by [customBorder] if it is non-null. This allows the [customBorder]'s path |
116 | /// to be properly defined if it was the path was expressed in terms of "start" and "end" instead of |
117 | /// "left" and "right". |
118 | /// |
119 | /// For examples on how the function is used, see [InkSplash] and [InkRipple]. |
120 | @protected |
121 | void paintInkCircle({ |
122 | required Canvas canvas, |
123 | required Matrix4 transform, |
124 | required Paint paint, |
125 | required Offset center, |
126 | required double radius, |
127 | TextDirection? textDirection, |
128 | ShapeBorder? customBorder, |
129 | BorderRadius borderRadius = BorderRadius.zero, |
130 | RectCallback? clipCallback, |
131 | }) { |
132 | final Offset? originOffset = MatrixUtils.getAsTranslation(transform); |
133 | canvas.save(); |
134 | if (originOffset == null) { |
135 | canvas.transform(transform.storage); |
136 | } else { |
137 | canvas.translate(originOffset.dx, originOffset.dy); |
138 | } |
139 | if (clipCallback != null) { |
140 | final Rect rect = clipCallback(); |
141 | if (customBorder != null) { |
142 | canvas.clipPath(customBorder.getOuterPath(rect, textDirection: textDirection)); |
143 | } else if (borderRadius != BorderRadius.zero) { |
144 | canvas.clipRRect( |
145 | RRect.fromRectAndCorners( |
146 | rect, |
147 | topLeft: borderRadius.topLeft, |
148 | topRight: borderRadius.topRight, |
149 | bottomLeft: borderRadius.bottomLeft, |
150 | bottomRight: borderRadius.bottomRight, |
151 | ), |
152 | ); |
153 | } else { |
154 | canvas.clipRect(rect); |
155 | } |
156 | } |
157 | canvas.drawCircle(center, radius, paint); |
158 | canvas.restore(); |
159 | } |
160 | } |
161 | |
162 | /// An encapsulation of an [InteractiveInkFeature] constructor used by |
163 | /// [InkWell], [InkResponse], and [ThemeData]. |
164 | /// |
165 | /// Interactive ink feature implementations should provide a static const |
166 | /// `splashFactory` value that's an instance of this class. The `splashFactory` |
167 | /// can be used to configure an [InkWell], [InkResponse] or [ThemeData]. |
168 | /// |
169 | /// See also: |
170 | /// |
171 | /// * [InkSplash.splashFactory] |
172 | /// * [InkRipple.splashFactory] |
173 | abstract class InteractiveInkFeatureFactory { |
174 | /// Abstract const constructor. This constructor enables subclasses to provide |
175 | /// const constructors so that they can be used in const expressions. |
176 | /// |
177 | /// Subclasses should provide a const constructor. |
178 | const InteractiveInkFeatureFactory(); |
179 | |
180 | /// The factory method. |
181 | /// |
182 | /// Subclasses should override this method to return a new instance of an |
183 | /// [InteractiveInkFeature]. |
184 | @factory |
185 | InteractiveInkFeature create({ |
186 | required MaterialInkController controller, |
187 | required RenderBox referenceBox, |
188 | required Offset position, |
189 | required Color color, |
190 | required TextDirection textDirection, |
191 | bool containedInkWell = false, |
192 | RectCallback? rectCallback, |
193 | BorderRadius? borderRadius, |
194 | ShapeBorder? customBorder, |
195 | double? radius, |
196 | VoidCallback? onRemoved, |
197 | }); |
198 | } |
199 | |
200 | abstract class _ParentInkResponseState { |
201 | void markChildInkResponsePressed(_ParentInkResponseState childState, bool value); |
202 | } |
203 | |
204 | class _ParentInkResponseProvider extends InheritedWidget { |
205 | const _ParentInkResponseProvider({required this.state, required super.child}); |
206 | |
207 | final _ParentInkResponseState state; |
208 | |
209 | @override |
210 | bool updateShouldNotify(_ParentInkResponseProvider oldWidget) => state != oldWidget.state; |
211 | |
212 | static _ParentInkResponseState? maybeOf(BuildContext context) { |
213 | return context.dependOnInheritedWidgetOfExactType<_ParentInkResponseProvider>()?.state; |
214 | } |
215 | } |
216 | |
217 | typedef _GetRectCallback = RectCallback? Function(RenderBox referenceBox); |
218 | typedef _CheckContext = bool Function(BuildContext context); |
219 | |
220 | /// An area of a [Material] that responds to touch. Has a configurable shape and |
221 | /// can be configured to clip splashes that extend outside its bounds or not. |
222 | /// |
223 | /// For a variant of this widget that is specialized for rectangular areas that |
224 | /// always clip splashes, see [InkWell]. |
225 | /// |
226 | /// An [InkResponse] widget does two things when responding to a tap: |
227 | /// |
228 | /// * It starts to animate a _highlight_. The shape of the highlight is |
229 | /// determined by [highlightShape]. If it is a [BoxShape.circle], the |
230 | /// default, then the highlight is a circle of fixed size centered in the |
231 | /// [InkResponse]. If it is [BoxShape.rectangle], then the highlight is a box |
232 | /// the size of the [InkResponse] itself, unless [getRectCallback] is |
233 | /// provided, in which case that callback defines the rectangle. The color of |
234 | /// the highlight is set by [highlightColor]. |
235 | /// |
236 | /// * Simultaneously, it starts to animate a _splash_. This is a growing circle |
237 | /// initially centered on the tap location. If this is a [containedInkWell], |
238 | /// the splash grows to the [radius] while remaining centered at the tap |
239 | /// location. Otherwise, the splash migrates to the center of the box as it |
240 | /// grows. |
241 | /// |
242 | /// The following two diagrams show how [InkResponse] looks when tapped if the |
243 | /// [highlightShape] is [BoxShape.circle] (the default) and [containedInkWell] |
244 | /// is false (also the default). |
245 | /// |
246 | /// The first diagram shows how it looks if the [InkResponse] is relatively |
247 | /// large: |
248 | /// |
249 | ///  |
250 | /// |
251 | /// The second diagram shows how it looks if the [InkResponse] is small: |
252 | /// |
253 | ///  |
254 | /// |
255 | /// The main thing to notice from these diagrams is that the splashes happily |
256 | /// exceed the bounds of the widget (because [containedInkWell] is false). |
257 | /// |
258 | /// The following diagram shows the effect when the [InkResponse] has a |
259 | /// [highlightShape] of [BoxShape.rectangle] with [containedInkWell] set to |
260 | /// true. These are the values used by [InkWell]. |
261 | /// |
262 | ///  |
263 | /// |
264 | /// The [InkResponse] widget must have a [Material] widget as an ancestor. The |
265 | /// [Material] widget is where the ink reactions are actually painted. This |
266 | /// matches the Material Design premise wherein the [Material] is what is |
267 | /// actually reacting to touches by spreading ink. |
268 | /// |
269 | /// If a Widget uses this class directly, it should include the following line |
270 | /// at the top of its build function to call [debugCheckHasMaterial]: |
271 | /// |
272 | /// ```dart |
273 | /// assert(debugCheckHasMaterial(context)); |
274 | /// ``` |
275 | /// |
276 | /// ## Troubleshooting |
277 | /// |
278 | /// ### The ink splashes aren't visible! |
279 | /// |
280 | /// If there is an opaque graphic, e.g. painted using a [Container], [Image], or |
281 | /// [DecoratedBox], between the [Material] widget and the [InkResponse] widget, |
282 | /// then the splash won't be visible because it will be under the opaque graphic. |
283 | /// This is because ink splashes draw on the underlying [Material] itself, as |
284 | /// if the ink was spreading inside the material. |
285 | /// |
286 | /// The [Ink] widget can be used as a replacement for [Image], [Container], or |
287 | /// [DecoratedBox] to ensure that the image or decoration also paints in the |
288 | /// [Material] itself, below the ink. |
289 | /// |
290 | /// If this is not possible for some reason, e.g. because you are using an |
291 | /// opaque [CustomPaint] widget, alternatively consider using a second |
292 | /// [Material] above the opaque widget but below the [InkResponse] (as an |
293 | /// ancestor to the ink response). The [MaterialType.transparency] material |
294 | /// kind can be used for this purpose. |
295 | /// |
296 | /// See also: |
297 | /// |
298 | /// * [GestureDetector], for listening for gestures without ink splashes. |
299 | /// * [ElevatedButton] and [TextButton], two kinds of buttons in Material Design. |
300 | /// * [IconButton], which combines [InkResponse] with an [Icon]. |
301 | class InkResponse extends StatelessWidget { |
302 | /// Creates an area of a [Material] that responds to touch. |
303 | /// |
304 | /// Must have an ancestor [Material] widget in which to cause ink reactions. |
305 | const InkResponse({ |
306 | super.key, |
307 | this.child, |
308 | this.onTap, |
309 | this.onTapDown, |
310 | this.onTapUp, |
311 | this.onTapCancel, |
312 | this.onDoubleTap, |
313 | this.onLongPress, |
314 | this.onSecondaryTap, |
315 | this.onSecondaryTapUp, |
316 | this.onSecondaryTapDown, |
317 | this.onSecondaryTapCancel, |
318 | this.onHighlightChanged, |
319 | this.onHover, |
320 | this.mouseCursor, |
321 | this.containedInkWell = false, |
322 | this.highlightShape = BoxShape.circle, |
323 | this.radius, |
324 | this.borderRadius, |
325 | this.customBorder, |
326 | this.focusColor, |
327 | this.hoverColor, |
328 | this.highlightColor, |
329 | this.overlayColor, |
330 | this.splashColor, |
331 | this.splashFactory, |
332 | this.enableFeedback = true, |
333 | this.excludeFromSemantics = false, |
334 | this.focusNode, |
335 | this.canRequestFocus = true, |
336 | this.onFocusChange, |
337 | this.autofocus = false, |
338 | this.statesController, |
339 | this.hoverDuration, |
340 | }); |
341 | |
342 | /// The widget below this widget in the tree. |
343 | /// |
344 | /// {@macro flutter.widgets.ProxyWidget.child} |
345 | final Widget? child; |
346 | |
347 | /// Called when the user taps this part of the material. |
348 | final GestureTapCallback? onTap; |
349 | |
350 | /// Called when the user taps down this part of the material. |
351 | final GestureTapDownCallback? onTapDown; |
352 | |
353 | /// Called when the user releases a tap that was started on this part of the |
354 | /// material. [onTap] is called immediately after. |
355 | final GestureTapUpCallback? onTapUp; |
356 | |
357 | /// Called when the user cancels a tap that was started on this part of the |
358 | /// material. |
359 | final GestureTapCallback? onTapCancel; |
360 | |
361 | /// Called when the user double taps this part of the material. |
362 | final GestureTapCallback? onDoubleTap; |
363 | |
364 | /// Called when the user long-presses on this part of the material. |
365 | final GestureLongPressCallback? onLongPress; |
366 | |
367 | /// Called when the user taps this part of the material with a secondary button. |
368 | /// |
369 | /// See also: |
370 | /// |
371 | /// * [kSecondaryButton], the button this callback responds to. |
372 | final GestureTapCallback? onSecondaryTap; |
373 | |
374 | /// Called when the user taps down on this part of the material with a |
375 | /// secondary button. |
376 | /// |
377 | /// See also: |
378 | /// |
379 | /// * [kSecondaryButton], the button this callback responds to. |
380 | final GestureTapDownCallback? onSecondaryTapDown; |
381 | |
382 | /// Called when the user releases a secondary button tap that was started on |
383 | /// this part of the material. [onSecondaryTap] is called immediately after. |
384 | /// |
385 | /// See also: |
386 | /// |
387 | /// * [onSecondaryTap], a handler triggered right after this one that doesn't |
388 | /// pass any details about the tap. |
389 | /// * [kSecondaryButton], the button this callback responds to. |
390 | final GestureTapUpCallback? onSecondaryTapUp; |
391 | |
392 | /// Called when the user cancels a secondary button tap that was started on |
393 | /// this part of the material. |
394 | /// |
395 | /// See also: |
396 | /// |
397 | /// * [kSecondaryButton], the button this callback responds to. |
398 | final GestureTapCallback? onSecondaryTapCancel; |
399 | |
400 | /// Called when this part of the material either becomes highlighted or stops |
401 | /// being highlighted. |
402 | /// |
403 | /// The value passed to the callback is true if this part of the material has |
404 | /// become highlighted and false if this part of the material has stopped |
405 | /// being highlighted. |
406 | /// |
407 | /// If all of [onTap], [onDoubleTap], and [onLongPress] become null while a |
408 | /// gesture is ongoing, then [onTapCancel] will be fired and |
409 | /// [onHighlightChanged] will be fired with the value false _during the |
410 | /// build_. This means, for instance, that in that scenario [State.setState] |
411 | /// cannot be called. |
412 | final ValueChanged<bool>? onHighlightChanged; |
413 | |
414 | /// Called when a pointer enters or exits the ink response area. |
415 | /// |
416 | /// The value passed to the callback is true if a pointer has entered this |
417 | /// part of the material and false if a pointer has exited this part of the |
418 | /// material. |
419 | final ValueChanged<bool>? onHover; |
420 | |
421 | /// The cursor for a mouse pointer when it enters or is hovering over the |
422 | /// widget. |
423 | /// |
424 | /// If [mouseCursor] is a [WidgetStateMouseCursor], |
425 | /// [WidgetStateProperty.resolve] is used for the following [WidgetState]s: |
426 | /// |
427 | /// * [WidgetState.hovered]. |
428 | /// * [WidgetState.focused]. |
429 | /// * [WidgetState.disabled]. |
430 | /// |
431 | /// If this property is null, [WidgetStateMouseCursor.clickable] will be used. |
432 | final MouseCursor? mouseCursor; |
433 | |
434 | /// Whether this ink response should be clipped its bounds. |
435 | /// |
436 | /// This flag also controls whether the splash migrates to the center of the |
437 | /// [InkResponse] or not. If [containedInkWell] is true, the splash remains |
438 | /// centered around the tap location. If it is false, the splash migrates to |
439 | /// the center of the [InkResponse] as it grows. |
440 | /// |
441 | /// See also: |
442 | /// |
443 | /// * [highlightShape], the shape of the focus, hover, and pressed |
444 | /// highlights. |
445 | /// * [borderRadius], which controls the corners when the box is a rectangle. |
446 | /// * [getRectCallback], which controls the size and position of the box when |
447 | /// it is a rectangle. |
448 | final bool containedInkWell; |
449 | |
450 | /// The shape (e.g., circle, rectangle) to use for the highlight drawn around |
451 | /// this part of the material when pressed, hovered over, or focused. |
452 | /// |
453 | /// The same shape is used for the pressed highlight (see [highlightColor]), |
454 | /// the focus highlight (see [focusColor]), and the hover highlight (see |
455 | /// [hoverColor]). |
456 | /// |
457 | /// If the shape is [BoxShape.circle], then the highlight is centered on the |
458 | /// [InkResponse]. If the shape is [BoxShape.rectangle], then the highlight |
459 | /// fills the [InkResponse], or the rectangle provided by [getRectCallback] if |
460 | /// the callback is specified. |
461 | /// |
462 | /// See also: |
463 | /// |
464 | /// * [containedInkWell], which controls clipping behavior. |
465 | /// * [borderRadius], which controls the corners when the box is a rectangle. |
466 | /// * [highlightColor], the color of the highlight. |
467 | /// * [getRectCallback], which controls the size and position of the box when |
468 | /// it is a rectangle. |
469 | final BoxShape highlightShape; |
470 | |
471 | /// The radius of the ink splash. |
472 | /// |
473 | /// Splashes grow up to this size. By default, this size is determined from |
474 | /// the size of the rectangle provided by [getRectCallback], or the size of |
475 | /// the [InkResponse] itself. |
476 | /// |
477 | /// See also: |
478 | /// |
479 | /// * [splashColor], the color of the splash. |
480 | /// * [splashFactory], which defines the appearance of the splash. |
481 | final double? radius; |
482 | |
483 | /// The border radius of the containing rectangle. This is effective only if |
484 | /// [highlightShape] is [BoxShape.rectangle]. |
485 | /// |
486 | /// If this is null, it is interpreted as [BorderRadius.zero]. |
487 | final BorderRadius? borderRadius; |
488 | |
489 | /// The custom clip border. |
490 | /// |
491 | /// If this is null, the ink response will not clip its content. |
492 | final ShapeBorder? customBorder; |
493 | |
494 | /// The color of the ink response when the parent widget is focused. If this |
495 | /// property is null then the focus color of the theme, |
496 | /// [ThemeData.focusColor], will be used. |
497 | /// |
498 | /// See also: |
499 | /// |
500 | /// * [highlightShape], the shape of the focus, hover, and pressed |
501 | /// highlights. |
502 | /// * [hoverColor], the color of the hover highlight. |
503 | /// * [splashColor], the color of the splash. |
504 | /// * [splashFactory], which defines the appearance of the splash. |
505 | final Color? focusColor; |
506 | |
507 | /// The color of the ink response when a pointer is hovering over it. If this |
508 | /// property is null then the hover color of the theme, |
509 | /// [ThemeData.hoverColor], will be used. |
510 | /// |
511 | /// See also: |
512 | /// |
513 | /// * [highlightShape], the shape of the focus, hover, and pressed |
514 | /// highlights. |
515 | /// * [highlightColor], the color of the pressed highlight. |
516 | /// * [focusColor], the color of the focus highlight. |
517 | /// * [splashColor], the color of the splash. |
518 | /// * [splashFactory], which defines the appearance of the splash. |
519 | final Color? hoverColor; |
520 | |
521 | /// The highlight color of the ink response when pressed. If this property is |
522 | /// null then the highlight color of the theme, [ThemeData.highlightColor], |
523 | /// will be used. |
524 | /// |
525 | /// See also: |
526 | /// |
527 | /// * [hoverColor], the color of the hover highlight. |
528 | /// * [focusColor], the color of the focus highlight. |
529 | /// * [highlightShape], the shape of the focus, hover, and pressed |
530 | /// highlights. |
531 | /// * [splashColor], the color of the splash. |
532 | /// * [splashFactory], which defines the appearance of the splash. |
533 | final Color? highlightColor; |
534 | |
535 | /// Defines the ink response focus, hover, and splash colors. |
536 | /// |
537 | /// This default null property can be used as an alternative to |
538 | /// [focusColor], [hoverColor], [highlightColor], and |
539 | /// [splashColor]. If non-null, it is resolved against one of |
540 | /// [WidgetState.focused], [WidgetState.hovered], and |
541 | /// [WidgetState.pressed]. It's convenient to use when the parent |
542 | /// widget can pass along its own WidgetStateProperty value for |
543 | /// the overlay color. |
544 | /// |
545 | /// [WidgetState.pressed] triggers a ripple (an ink splash), per |
546 | /// the current Material Design spec. The [overlayColor] doesn't map |
547 | /// a state to [highlightColor] because a separate highlight is not |
548 | /// used by the current design guidelines. See |
549 | /// https://material.io/design/interaction/states.html#pressed |
550 | /// |
551 | /// If the overlay color is null or resolves to null, then [focusColor], |
552 | /// [hoverColor], [splashColor] and their defaults are used instead. |
553 | /// |
554 | /// See also: |
555 | /// |
556 | /// * The Material Design specification for overlay colors and how they |
557 | /// match a component's state: |
558 | /// <https://material.io/design/interaction/states.html#anatomy>. |
559 | final MaterialStateProperty<Color?>? overlayColor; |
560 | |
561 | /// The splash color of the ink response. If this property is null then the |
562 | /// splash color of the theme, [ThemeData.splashColor], will be used. |
563 | /// |
564 | /// See also: |
565 | /// |
566 | /// * [splashFactory], which defines the appearance of the splash. |
567 | /// * [radius], the (maximum) size of the ink splash. |
568 | /// * [highlightColor], the color of the highlight. |
569 | final Color? splashColor; |
570 | |
571 | /// Defines the appearance of the splash. |
572 | /// |
573 | /// Defaults to the value of the theme's splash factory: [ThemeData.splashFactory]. |
574 | /// |
575 | /// See also: |
576 | /// |
577 | /// * [radius], the (maximum) size of the ink splash. |
578 | /// * [splashColor], the color of the splash. |
579 | /// * [highlightColor], the color of the highlight. |
580 | /// * [InkSplash.splashFactory], which defines the default splash. |
581 | /// * [InkRipple.splashFactory], which defines a splash that spreads out |
582 | /// more aggressively than the default. |
583 | final InteractiveInkFeatureFactory? splashFactory; |
584 | |
585 | /// Whether detected gestures should provide acoustic and/or haptic feedback. |
586 | /// |
587 | /// For example, on Android a tap will produce a clicking sound and a |
588 | /// long-press will produce a short vibration, when feedback is enabled. |
589 | /// |
590 | /// See also: |
591 | /// |
592 | /// * [Feedback] for providing platform-specific feedback to certain actions. |
593 | final bool enableFeedback; |
594 | |
595 | /// Whether to exclude the gestures introduced by this widget from the |
596 | /// semantics tree. |
597 | /// |
598 | /// For example, a long-press gesture for showing a tooltip is usually |
599 | /// excluded because the tooltip itself is included in the semantics |
600 | /// tree directly and so having a gesture to show it would result in |
601 | /// duplication of information. |
602 | final bool excludeFromSemantics; |
603 | |
604 | /// {@template flutter.material.inkwell.onFocusChange} |
605 | /// Handler called when the focus changes. |
606 | /// |
607 | /// Called with true if this widget's node gains focus, and false if it loses |
608 | /// focus. |
609 | /// {@endtemplate} |
610 | final ValueChanged<bool>? onFocusChange; |
611 | |
612 | /// {@macro flutter.widgets.Focus.autofocus} |
613 | final bool autofocus; |
614 | |
615 | /// {@macro flutter.widgets.Focus.focusNode} |
616 | final FocusNode? focusNode; |
617 | |
618 | /// {@macro flutter.widgets.Focus.canRequestFocus} |
619 | final bool canRequestFocus; |
620 | |
621 | /// The rectangle to use for the highlight effect and for clipping |
622 | /// the splash effects if [containedInkWell] is true. |
623 | /// |
624 | /// This method is intended to be overridden by descendants that |
625 | /// specialize [InkResponse] for unusual cases. For example, |
626 | /// [TableRowInkWell] implements this method to return the rectangle |
627 | /// corresponding to the row that the widget is in. |
628 | /// |
629 | /// The default behavior returns null, which is equivalent to |
630 | /// returning the referenceBox argument's bounding box (though |
631 | /// slightly more efficient). |
632 | RectCallback? getRectCallback(RenderBox referenceBox) => null; |
633 | |
634 | /// {@template flutter.material.inkwell.statesController} |
635 | /// Represents the interactive "state" of this widget in terms of |
636 | /// a set of [WidgetState]s, like [WidgetState.pressed] and |
637 | /// [WidgetState.focused]. |
638 | /// |
639 | /// Classes based on this one can provide their own |
640 | /// [WidgetStatesController] to which they've added listeners. |
641 | /// They can also update the controller's [WidgetStatesController.value] |
642 | /// however, this may only be done when it's safe to call |
643 | /// [State.setState], like in an event handler. |
644 | /// {@endtemplate} |
645 | final MaterialStatesController? statesController; |
646 | |
647 | /// The duration of the animation that animates the hover effect. |
648 | /// |
649 | /// The default is 50ms. |
650 | final Duration? hoverDuration; |
651 | |
652 | @override |
653 | Widget build(BuildContext context) { |
654 | final _ParentInkResponseState? parentState = _ParentInkResponseProvider.maybeOf(context); |
655 | return _InkResponseStateWidget( |
656 | onTap: onTap, |
657 | onTapDown: onTapDown, |
658 | onTapUp: onTapUp, |
659 | onTapCancel: onTapCancel, |
660 | onDoubleTap: onDoubleTap, |
661 | onLongPress: onLongPress, |
662 | onSecondaryTap: onSecondaryTap, |
663 | onSecondaryTapUp: onSecondaryTapUp, |
664 | onSecondaryTapDown: onSecondaryTapDown, |
665 | onSecondaryTapCancel: onSecondaryTapCancel, |
666 | onHighlightChanged: onHighlightChanged, |
667 | onHover: onHover, |
668 | mouseCursor: mouseCursor, |
669 | containedInkWell: containedInkWell, |
670 | highlightShape: highlightShape, |
671 | radius: radius, |
672 | borderRadius: borderRadius, |
673 | customBorder: customBorder, |
674 | focusColor: focusColor, |
675 | hoverColor: hoverColor, |
676 | highlightColor: highlightColor, |
677 | overlayColor: overlayColor, |
678 | splashColor: splashColor, |
679 | splashFactory: splashFactory, |
680 | enableFeedback: enableFeedback, |
681 | excludeFromSemantics: excludeFromSemantics, |
682 | focusNode: focusNode, |
683 | canRequestFocus: canRequestFocus, |
684 | onFocusChange: onFocusChange, |
685 | autofocus: autofocus, |
686 | parentState: parentState, |
687 | getRectCallback: getRectCallback, |
688 | debugCheckContext: debugCheckContext, |
689 | statesController: statesController, |
690 | hoverDuration: hoverDuration, |
691 | child: child, |
692 | ); |
693 | } |
694 | |
695 | /// Asserts that the given context satisfies the prerequisites for |
696 | /// this class. |
697 | /// |
698 | /// This method is intended to be overridden by descendants that |
699 | /// specialize [InkResponse] for unusual cases. For example, |
700 | /// [TableRowInkWell] implements this method to verify that the widget is |
701 | /// in a table. |
702 | @mustCallSuper |
703 | bool debugCheckContext(BuildContext context) { |
704 | assert(debugCheckHasMaterial(context)); |
705 | assert(debugCheckHasDirectionality(context)); |
706 | return true; |
707 | } |
708 | } |
709 | |
710 | class _InkResponseStateWidget extends StatefulWidget { |
711 | const _InkResponseStateWidget({ |
712 | this.child, |
713 | this.onTap, |
714 | this.onTapDown, |
715 | this.onTapUp, |
716 | this.onTapCancel, |
717 | this.onDoubleTap, |
718 | this.onLongPress, |
719 | this.onSecondaryTap, |
720 | this.onSecondaryTapUp, |
721 | this.onSecondaryTapDown, |
722 | this.onSecondaryTapCancel, |
723 | this.onHighlightChanged, |
724 | this.onHover, |
725 | this.mouseCursor, |
726 | this.containedInkWell = false, |
727 | this.highlightShape = BoxShape.circle, |
728 | this.radius, |
729 | this.borderRadius, |
730 | this.customBorder, |
731 | this.focusColor, |
732 | this.hoverColor, |
733 | this.highlightColor, |
734 | this.overlayColor, |
735 | this.splashColor, |
736 | this.splashFactory, |
737 | this.enableFeedback = true, |
738 | this.excludeFromSemantics = false, |
739 | this.focusNode, |
740 | this.canRequestFocus = true, |
741 | this.onFocusChange, |
742 | this.autofocus = false, |
743 | this.parentState, |
744 | this.getRectCallback, |
745 | required this.debugCheckContext, |
746 | this.statesController, |
747 | this.hoverDuration, |
748 | }); |
749 | |
750 | final Widget? child; |
751 | final GestureTapCallback? onTap; |
752 | final GestureTapDownCallback? onTapDown; |
753 | final GestureTapUpCallback? onTapUp; |
754 | final GestureTapCallback? onTapCancel; |
755 | final GestureTapCallback? onDoubleTap; |
756 | final GestureLongPressCallback? onLongPress; |
757 | final GestureTapCallback? onSecondaryTap; |
758 | final GestureTapUpCallback? onSecondaryTapUp; |
759 | final GestureTapDownCallback? onSecondaryTapDown; |
760 | final GestureTapCallback? onSecondaryTapCancel; |
761 | final ValueChanged<bool>? onHighlightChanged; |
762 | final ValueChanged<bool>? onHover; |
763 | final MouseCursor? mouseCursor; |
764 | final bool containedInkWell; |
765 | final BoxShape highlightShape; |
766 | final double? radius; |
767 | final BorderRadius? borderRadius; |
768 | final ShapeBorder? customBorder; |
769 | final Color? focusColor; |
770 | final Color? hoverColor; |
771 | final Color? highlightColor; |
772 | final MaterialStateProperty<Color?>? overlayColor; |
773 | final Color? splashColor; |
774 | final InteractiveInkFeatureFactory? splashFactory; |
775 | final bool enableFeedback; |
776 | final bool excludeFromSemantics; |
777 | final ValueChanged<bool>? onFocusChange; |
778 | final bool autofocus; |
779 | final FocusNode? focusNode; |
780 | final bool canRequestFocus; |
781 | final _ParentInkResponseState? parentState; |
782 | final _GetRectCallback? getRectCallback; |
783 | final _CheckContext debugCheckContext; |
784 | final MaterialStatesController? statesController; |
785 | final Duration? hoverDuration; |
786 | |
787 | @override |
788 | _InkResponseState createState() => _InkResponseState(); |
789 | |
790 | @override |
791 | void debugFillProperties(DiagnosticPropertiesBuilder properties) { |
792 | super.debugFillProperties(properties); |
793 | final List<String> gestures = <String>[ |
794 | if (onTap != null) 'tap' , |
795 | if (onDoubleTap != null) 'double tap' , |
796 | if (onLongPress != null) 'long press' , |
797 | if (onTapDown != null) 'tap down' , |
798 | if (onTapUp != null) 'tap up' , |
799 | if (onTapCancel != null) 'tap cancel' , |
800 | if (onSecondaryTap != null) 'secondary tap' , |
801 | if (onSecondaryTapUp != null) 'secondary tap up' , |
802 | if (onSecondaryTapDown != null) 'secondary tap down' , |
803 | if (onSecondaryTapCancel != null) 'secondary tap cancel' , |
804 | ]; |
805 | properties.add(IterableProperty<String>('gestures' , gestures, ifEmpty: '<none>' )); |
806 | properties.add(DiagnosticsProperty<MouseCursor>('mouseCursor' , mouseCursor)); |
807 | properties.add( |
808 | DiagnosticsProperty<bool>('containedInkWell' , containedInkWell, level: DiagnosticLevel.fine), |
809 | ); |
810 | properties.add( |
811 | DiagnosticsProperty<BoxShape>( |
812 | 'highlightShape' , |
813 | highlightShape, |
814 | description: ' ${containedInkWell ? "clipped to " : "" }$highlightShape' , |
815 | showName: false, |
816 | ), |
817 | ); |
818 | } |
819 | } |
820 | |
821 | /// Used to index the allocated highlights for the different types of highlights |
822 | /// in [_InkResponseState]. |
823 | enum _HighlightType { pressed, hover, focus } |
824 | |
825 | class _InkResponseState extends State<_InkResponseStateWidget> |
826 | with AutomaticKeepAliveClientMixin<_InkResponseStateWidget> |
827 | implements _ParentInkResponseState { |
828 | Set<InteractiveInkFeature>? _splashes; |
829 | InteractiveInkFeature? _currentSplash; |
830 | bool _hovering = false; |
831 | final Map<_HighlightType, InkHighlight?> _highlights = <_HighlightType, InkHighlight?>{}; |
832 | late final Map<Type, Action<Intent>> _actionMap = <Type, Action<Intent>>{ |
833 | ActivateIntent: CallbackAction<ActivateIntent>(onInvoke: activateOnIntent), |
834 | ButtonActivateIntent: CallbackAction<ButtonActivateIntent>(onInvoke: activateOnIntent), |
835 | }; |
836 | MaterialStatesController? internalStatesController; |
837 | |
838 | bool get highlightsExist => |
839 | _highlights.values.where((InkHighlight? highlight) => highlight != null).isNotEmpty; |
840 | |
841 | final ObserverList<_ParentInkResponseState> _activeChildren = |
842 | ObserverList<_ParentInkResponseState>(); |
843 | |
844 | static const Duration _activationDuration = Duration(milliseconds: 100); |
845 | Timer? _activationTimer; |
846 | |
847 | @override |
848 | void markChildInkResponsePressed(_ParentInkResponseState childState, bool value) { |
849 | final bool lastAnyPressed = _anyChildInkResponsePressed; |
850 | if (value) { |
851 | _activeChildren.add(childState); |
852 | } else { |
853 | _activeChildren.remove(childState); |
854 | } |
855 | final bool nowAnyPressed = _anyChildInkResponsePressed; |
856 | if (nowAnyPressed != lastAnyPressed) { |
857 | widget.parentState?.markChildInkResponsePressed(this, nowAnyPressed); |
858 | } |
859 | } |
860 | |
861 | bool get _anyChildInkResponsePressed => _activeChildren.isNotEmpty; |
862 | |
863 | void activateOnIntent(Intent? intent) { |
864 | _activationTimer?.cancel(); |
865 | _activationTimer = null; |
866 | _startNewSplash(context: context); |
867 | _currentSplash?.confirm(); |
868 | _currentSplash = null; |
869 | if (widget.onTap != null) { |
870 | if (widget.enableFeedback) { |
871 | Feedback.forTap(context); |
872 | } |
873 | widget.onTap?.call(); |
874 | } |
875 | // Delay the call to `updateHighlight` to simulate a pressed delay |
876 | // and give MaterialStatesController listeners a chance to react. |
877 | _activationTimer = Timer(_activationDuration, () { |
878 | updateHighlight(_HighlightType.pressed, value: false); |
879 | }); |
880 | } |
881 | |
882 | void simulateTap([Intent? intent]) { |
883 | _startNewSplash(context: context); |
884 | handleTap(); |
885 | } |
886 | |
887 | void simulateLongPress() { |
888 | _startNewSplash(context: context); |
889 | handleLongPress(); |
890 | } |
891 | |
892 | void handleStatesControllerChange() { |
893 | // Force a rebuild to resolve widget.overlayColor, widget.mouseCursor |
894 | setState(() {}); |
895 | } |
896 | |
897 | MaterialStatesController get statesController => |
898 | widget.statesController ?? internalStatesController!; |
899 | |
900 | void initStatesController() { |
901 | if (widget.statesController == null) { |
902 | internalStatesController = MaterialStatesController(); |
903 | } |
904 | statesController.update(MaterialState.disabled, !enabled); |
905 | statesController.addListener(handleStatesControllerChange); |
906 | } |
907 | |
908 | @override |
909 | void initState() { |
910 | super.initState(); |
911 | initStatesController(); |
912 | FocusManager.instance.addHighlightModeListener(handleFocusHighlightModeChange); |
913 | } |
914 | |
915 | @override |
916 | void didUpdateWidget(_InkResponseStateWidget oldWidget) { |
917 | super.didUpdateWidget(oldWidget); |
918 | if (widget.statesController != oldWidget.statesController) { |
919 | oldWidget.statesController?.removeListener(handleStatesControllerChange); |
920 | if (widget.statesController != null) { |
921 | internalStatesController?.dispose(); |
922 | internalStatesController = null; |
923 | } |
924 | initStatesController(); |
925 | } |
926 | if (widget.radius != oldWidget.radius || |
927 | widget.highlightShape != oldWidget.highlightShape || |
928 | widget.borderRadius != oldWidget.borderRadius) { |
929 | final InkHighlight? hoverHighlight = _highlights[_HighlightType.hover]; |
930 | if (hoverHighlight != null) { |
931 | hoverHighlight.dispose(); |
932 | updateHighlight(_HighlightType.hover, value: _hovering, callOnHover: false); |
933 | } |
934 | final InkHighlight? focusHighlight = _highlights[_HighlightType.focus]; |
935 | if (focusHighlight != null) { |
936 | focusHighlight.dispose(); |
937 | // Do not call updateFocusHighlights() here because it is called below |
938 | } |
939 | } |
940 | if (widget.customBorder != oldWidget.customBorder) { |
941 | _updateHighlightsAndSplashes(); |
942 | } |
943 | if (enabled != isWidgetEnabled(oldWidget)) { |
944 | statesController.update(MaterialState.disabled, !enabled); |
945 | if (!enabled) { |
946 | statesController.update(MaterialState.pressed, false); |
947 | // Remove the existing hover highlight immediately when enabled is false. |
948 | // Do not rely on updateHighlight or InkHighlight.deactivate to not break |
949 | // the expected lifecycle which is updating _hovering when the mouse exit. |
950 | // Manually updating _hovering here or calling InkHighlight.deactivate |
951 | // will lead to onHover not being called or call when it is not allowed. |
952 | final InkHighlight? hoverHighlight = _highlights[_HighlightType.hover]; |
953 | hoverHighlight?.dispose(); |
954 | } |
955 | // Don't call widget.onHover because many widgets, including the button |
956 | // widgets, apply setState to an ancestor context from onHover. |
957 | updateHighlight(_HighlightType.hover, value: _hovering, callOnHover: false); |
958 | } |
959 | updateFocusHighlights(); |
960 | } |
961 | |
962 | @override |
963 | void dispose() { |
964 | FocusManager.instance.removeHighlightModeListener(handleFocusHighlightModeChange); |
965 | statesController.removeListener(handleStatesControllerChange); |
966 | internalStatesController?.dispose(); |
967 | _activationTimer?.cancel(); |
968 | _activationTimer = null; |
969 | super.dispose(); |
970 | } |
971 | |
972 | @override |
973 | bool get wantKeepAlive => highlightsExist || (_splashes != null && _splashes!.isNotEmpty); |
974 | |
975 | Duration getFadeDurationForType(_HighlightType type) { |
976 | switch (type) { |
977 | case _HighlightType.pressed: |
978 | return const Duration(milliseconds: 200); |
979 | case _HighlightType.hover: |
980 | case _HighlightType.focus: |
981 | return widget.hoverDuration ?? const Duration(milliseconds: 50); |
982 | } |
983 | } |
984 | |
985 | void updateHighlight(_HighlightType type, {required bool value, bool callOnHover = true}) { |
986 | final InkHighlight? highlight = _highlights[type]; |
987 | void handleInkRemoval() { |
988 | assert(_highlights[type] != null); |
989 | _highlights[type] = null; |
990 | updateKeepAlive(); |
991 | } |
992 | |
993 | switch (type) { |
994 | case _HighlightType.pressed: |
995 | statesController.update(MaterialState.pressed, value); |
996 | case _HighlightType.hover: |
997 | if (callOnHover) { |
998 | statesController.update(MaterialState.hovered, value); |
999 | } |
1000 | case _HighlightType.focus: |
1001 | // see handleFocusUpdate() |
1002 | break; |
1003 | } |
1004 | |
1005 | if (type == _HighlightType.pressed) { |
1006 | widget.parentState?.markChildInkResponsePressed(this, value); |
1007 | } |
1008 | if (value == (highlight != null && highlight.active)) { |
1009 | return; |
1010 | } |
1011 | |
1012 | if (value) { |
1013 | if (highlight == null) { |
1014 | final Color resolvedOverlayColor = |
1015 | widget.overlayColor?.resolve(statesController.value) ?? |
1016 | switch (type) { |
1017 | // Use the backwards compatible defaults |
1018 | _HighlightType.pressed => widget.highlightColor ?? Theme.of(context).highlightColor, |
1019 | _HighlightType.focus => widget.focusColor ?? Theme.of(context).focusColor, |
1020 | _HighlightType.hover => widget.hoverColor ?? Theme.of(context).hoverColor, |
1021 | }; |
1022 | final RenderBox referenceBox = context.findRenderObject()! as RenderBox; |
1023 | _highlights[type] = InkHighlight( |
1024 | controller: Material.of(context), |
1025 | referenceBox: referenceBox, |
1026 | color: enabled ? resolvedOverlayColor : resolvedOverlayColor.withAlpha(0), |
1027 | shape: widget.highlightShape, |
1028 | radius: widget.radius, |
1029 | borderRadius: widget.borderRadius, |
1030 | customBorder: widget.customBorder, |
1031 | rectCallback: widget.getRectCallback!(referenceBox), |
1032 | onRemoved: handleInkRemoval, |
1033 | textDirection: Directionality.of(context), |
1034 | fadeDuration: getFadeDurationForType(type), |
1035 | ); |
1036 | updateKeepAlive(); |
1037 | } else { |
1038 | highlight.activate(); |
1039 | } |
1040 | } else { |
1041 | highlight!.deactivate(); |
1042 | } |
1043 | assert(value == (_highlights[type] != null && _highlights[type]!.active)); |
1044 | |
1045 | switch (type) { |
1046 | case _HighlightType.pressed: |
1047 | widget.onHighlightChanged?.call(value); |
1048 | case _HighlightType.hover: |
1049 | if (callOnHover) { |
1050 | widget.onHover?.call(value); |
1051 | } |
1052 | case _HighlightType.focus: |
1053 | break; |
1054 | } |
1055 | } |
1056 | |
1057 | void _updateHighlightsAndSplashes() { |
1058 | for (final InkHighlight? highlight in _highlights.values) { |
1059 | highlight?.customBorder = widget.customBorder; |
1060 | } |
1061 | _currentSplash?.customBorder = widget.customBorder; |
1062 | |
1063 | if (_splashes != null && _splashes!.isNotEmpty) { |
1064 | for (final InteractiveInkFeature inkFeature in _splashes!) { |
1065 | inkFeature.customBorder = widget.customBorder; |
1066 | } |
1067 | } |
1068 | } |
1069 | |
1070 | InteractiveInkFeature _createSplash(Offset globalPosition) { |
1071 | final MaterialInkController inkController = Material.of(context); |
1072 | final RenderBox referenceBox = context.findRenderObject()! as RenderBox; |
1073 | final Offset position = referenceBox.globalToLocal(globalPosition); |
1074 | final Color color = |
1075 | widget.overlayColor?.resolve(statesController.value) ?? |
1076 | widget.splashColor ?? |
1077 | Theme.of(context).splashColor; |
1078 | final RectCallback? rectCallback = widget.containedInkWell |
1079 | ? widget.getRectCallback!(referenceBox) |
1080 | : null; |
1081 | final BorderRadius? borderRadius = widget.borderRadius; |
1082 | final ShapeBorder? customBorder = widget.customBorder; |
1083 | |
1084 | InteractiveInkFeature? splash; |
1085 | void onRemoved() { |
1086 | if (_splashes != null) { |
1087 | assert(_splashes!.contains(splash)); |
1088 | _splashes!.remove(splash); |
1089 | if (_currentSplash == splash) { |
1090 | _currentSplash = null; |
1091 | } |
1092 | updateKeepAlive(); |
1093 | } // else we're probably in deactivate() |
1094 | } |
1095 | |
1096 | splash = (widget.splashFactory ?? Theme.of(context).splashFactory).create( |
1097 | controller: inkController, |
1098 | referenceBox: referenceBox, |
1099 | position: position, |
1100 | color: color, |
1101 | containedInkWell: widget.containedInkWell, |
1102 | rectCallback: rectCallback, |
1103 | radius: widget.radius, |
1104 | borderRadius: borderRadius, |
1105 | customBorder: customBorder, |
1106 | onRemoved: onRemoved, |
1107 | textDirection: Directionality.of(context), |
1108 | ); |
1109 | |
1110 | return splash; |
1111 | } |
1112 | |
1113 | void handleFocusHighlightModeChange(FocusHighlightMode mode) { |
1114 | if (!mounted) { |
1115 | return; |
1116 | } |
1117 | setState(() { |
1118 | updateFocusHighlights(); |
1119 | }); |
1120 | } |
1121 | |
1122 | bool get _shouldShowFocus => switch (MediaQuery.maybeNavigationModeOf(context)) { |
1123 | NavigationMode.traditional || null => enabled && _hasFocus, |
1124 | NavigationMode.directional => _hasFocus, |
1125 | }; |
1126 | |
1127 | void updateFocusHighlights() { |
1128 | final bool showFocus = switch (FocusManager.instance.highlightMode) { |
1129 | FocusHighlightMode.touch => false, |
1130 | FocusHighlightMode.traditional => _shouldShowFocus, |
1131 | }; |
1132 | updateHighlight(_HighlightType.focus, value: showFocus); |
1133 | } |
1134 | |
1135 | bool _hasFocus = false; |
1136 | void handleFocusUpdate(bool hasFocus) { |
1137 | _hasFocus = hasFocus; |
1138 | // Set here rather than updateHighlight because this widget's |
1139 | // (MaterialState) states include MaterialState.focused if |
1140 | // the InkWell _has_ the focus, rather than if it's showing |
1141 | // the focus per FocusManager.instance.highlightMode. |
1142 | statesController.update(MaterialState.focused, hasFocus); |
1143 | updateFocusHighlights(); |
1144 | widget.onFocusChange?.call(hasFocus); |
1145 | } |
1146 | |
1147 | void handleAnyTapDown(TapDownDetails details) { |
1148 | if (_anyChildInkResponsePressed) { |
1149 | return; |
1150 | } |
1151 | _startNewSplash(details: details); |
1152 | } |
1153 | |
1154 | void handleTapDown(TapDownDetails details) { |
1155 | handleAnyTapDown(details); |
1156 | widget.onTapDown?.call(details); |
1157 | } |
1158 | |
1159 | void handleTapUp(TapUpDetails details) { |
1160 | widget.onTapUp?.call(details); |
1161 | } |
1162 | |
1163 | void handleSecondaryTapDown(TapDownDetails details) { |
1164 | handleAnyTapDown(details); |
1165 | widget.onSecondaryTapDown?.call(details); |
1166 | } |
1167 | |
1168 | void handleSecondaryTapUp(TapUpDetails details) { |
1169 | widget.onSecondaryTapUp?.call(details); |
1170 | } |
1171 | |
1172 | void _startNewSplash({TapDownDetails? details, BuildContext? context}) { |
1173 | assert(details != null || context != null); |
1174 | |
1175 | final Offset globalPosition; |
1176 | if (context != null) { |
1177 | final RenderBox referenceBox = context.findRenderObject()! as RenderBox; |
1178 | assert( |
1179 | referenceBox.hasSize, |
1180 | 'InkResponse must be done with layout before starting a splash.' , |
1181 | ); |
1182 | globalPosition = referenceBox.localToGlobal(referenceBox.paintBounds.center); |
1183 | } else { |
1184 | globalPosition = details!.globalPosition; |
1185 | } |
1186 | statesController.update(MaterialState.pressed, true); // ... before creating the splash |
1187 | final InteractiveInkFeature splash = _createSplash(globalPosition); |
1188 | _splashes ??= HashSet<InteractiveInkFeature>(); |
1189 | _splashes!.add(splash); |
1190 | _currentSplash?.cancel(); |
1191 | _currentSplash = splash; |
1192 | updateKeepAlive(); |
1193 | updateHighlight(_HighlightType.pressed, value: true); |
1194 | } |
1195 | |
1196 | void handleTap() { |
1197 | _currentSplash?.confirm(); |
1198 | _currentSplash = null; |
1199 | updateHighlight(_HighlightType.pressed, value: false); |
1200 | if (widget.onTap != null) { |
1201 | if (widget.enableFeedback) { |
1202 | Feedback.forTap(context); |
1203 | } |
1204 | widget.onTap?.call(); |
1205 | } |
1206 | } |
1207 | |
1208 | void handleTapCancel() { |
1209 | _currentSplash?.cancel(); |
1210 | _currentSplash = null; |
1211 | widget.onTapCancel?.call(); |
1212 | updateHighlight(_HighlightType.pressed, value: false); |
1213 | } |
1214 | |
1215 | void handleDoubleTap() { |
1216 | _currentSplash?.confirm(); |
1217 | _currentSplash = null; |
1218 | updateHighlight(_HighlightType.pressed, value: false); |
1219 | widget.onDoubleTap?.call(); |
1220 | } |
1221 | |
1222 | void handleLongPress() { |
1223 | _currentSplash?.confirm(); |
1224 | _currentSplash = null; |
1225 | if (widget.onLongPress != null) { |
1226 | if (widget.enableFeedback) { |
1227 | Feedback.forLongPress(context); |
1228 | } |
1229 | widget.onLongPress!(); |
1230 | } |
1231 | } |
1232 | |
1233 | void handleSecondaryTap() { |
1234 | _currentSplash?.confirm(); |
1235 | _currentSplash = null; |
1236 | updateHighlight(_HighlightType.pressed, value: false); |
1237 | widget.onSecondaryTap?.call(); |
1238 | } |
1239 | |
1240 | void handleSecondaryTapCancel() { |
1241 | _currentSplash?.cancel(); |
1242 | _currentSplash = null; |
1243 | widget.onSecondaryTapCancel?.call(); |
1244 | updateHighlight(_HighlightType.pressed, value: false); |
1245 | } |
1246 | |
1247 | @override |
1248 | void deactivate() { |
1249 | if (_splashes != null) { |
1250 | final Set<InteractiveInkFeature> splashes = _splashes!; |
1251 | _splashes = null; |
1252 | for (final InteractiveInkFeature splash in splashes) { |
1253 | splash.dispose(); |
1254 | } |
1255 | _currentSplash = null; |
1256 | } |
1257 | assert(_currentSplash == null); |
1258 | for (final _HighlightType highlight in _highlights.keys) { |
1259 | _highlights[highlight]?.dispose(); |
1260 | _highlights[highlight] = null; |
1261 | } |
1262 | widget.parentState?.markChildInkResponsePressed(this, false); |
1263 | super.deactivate(); |
1264 | } |
1265 | |
1266 | bool isWidgetEnabled(_InkResponseStateWidget widget) { |
1267 | return _primaryButtonEnabled(widget) || _secondaryButtonEnabled(widget); |
1268 | } |
1269 | |
1270 | bool _primaryButtonEnabled(_InkResponseStateWidget widget) { |
1271 | return widget.onTap != null || |
1272 | widget.onDoubleTap != null || |
1273 | widget.onLongPress != null || |
1274 | widget.onTapUp != null || |
1275 | widget.onTapDown != null; |
1276 | } |
1277 | |
1278 | bool _secondaryButtonEnabled(_InkResponseStateWidget widget) { |
1279 | return widget.onSecondaryTap != null || |
1280 | widget.onSecondaryTapUp != null || |
1281 | widget.onSecondaryTapDown != null; |
1282 | } |
1283 | |
1284 | bool get enabled => isWidgetEnabled(widget); |
1285 | bool get _primaryEnabled => _primaryButtonEnabled(widget); |
1286 | bool get _secondaryEnabled => _secondaryButtonEnabled(widget); |
1287 | |
1288 | void handleMouseEnter(PointerEnterEvent event) { |
1289 | _hovering = true; |
1290 | if (enabled) { |
1291 | handleHoverChange(); |
1292 | } |
1293 | } |
1294 | |
1295 | void handleMouseExit(PointerExitEvent event) { |
1296 | _hovering = false; |
1297 | // If the exit occurs after we've been disabled, we still |
1298 | // want to take down the highlights and run widget.onHover. |
1299 | handleHoverChange(); |
1300 | } |
1301 | |
1302 | void handleHoverChange() { |
1303 | updateHighlight(_HighlightType.hover, value: _hovering); |
1304 | } |
1305 | |
1306 | bool get _canRequestFocus => switch (MediaQuery.maybeNavigationModeOf(context)) { |
1307 | NavigationMode.traditional || null => enabled && widget.canRequestFocus, |
1308 | NavigationMode.directional => true, |
1309 | }; |
1310 | |
1311 | @override |
1312 | Widget build(BuildContext context) { |
1313 | assert(widget.debugCheckContext(context)); |
1314 | super.build(context); // See AutomaticKeepAliveClientMixin. |
1315 | |
1316 | final ThemeData theme = Theme.of(context); |
1317 | const Set<MaterialState> highlightableStates = <MaterialState>{ |
1318 | MaterialState.focused, |
1319 | MaterialState.hovered, |
1320 | MaterialState.pressed, |
1321 | }; |
1322 | final Set<MaterialState> nonHighlightableStates = statesController.value.difference( |
1323 | highlightableStates, |
1324 | ); |
1325 | // Each highlightable state will be resolved separately to get the corresponding color. |
1326 | // For this resolution to be correct, the non-highlightable states should be preserved. |
1327 | final Set<MaterialState> pressed = <MaterialState>{ |
1328 | ...nonHighlightableStates, |
1329 | MaterialState.pressed, |
1330 | }; |
1331 | final Set<MaterialState> focused = <MaterialState>{ |
1332 | ...nonHighlightableStates, |
1333 | MaterialState.focused, |
1334 | }; |
1335 | final Set<MaterialState> hovered = <MaterialState>{ |
1336 | ...nonHighlightableStates, |
1337 | MaterialState.hovered, |
1338 | }; |
1339 | |
1340 | Color getHighlightColorForType(_HighlightType type) { |
1341 | return switch (type) { |
1342 | // The pressed state triggers a ripple (ink splash), per the current |
1343 | // Material Design spec. A separate highlight is no longer used. |
1344 | // See https://material.io/design/interaction/states.html#pressed |
1345 | _HighlightType.pressed => |
1346 | widget.overlayColor?.resolve(pressed) ?? widget.highlightColor ?? theme.highlightColor, |
1347 | _HighlightType.focus => |
1348 | widget.overlayColor?.resolve(focused) ?? widget.focusColor ?? theme.focusColor, |
1349 | _HighlightType.hover => |
1350 | widget.overlayColor?.resolve(hovered) ?? widget.hoverColor ?? theme.hoverColor, |
1351 | }; |
1352 | } |
1353 | |
1354 | for (final _HighlightType type in _highlights.keys) { |
1355 | _highlights[type]?.color = getHighlightColorForType(type); |
1356 | } |
1357 | |
1358 | _currentSplash?.color = |
1359 | widget.overlayColor?.resolve(statesController.value) ?? |
1360 | widget.splashColor ?? |
1361 | Theme.of(context).splashColor; |
1362 | |
1363 | final MouseCursor effectiveMouseCursor = MaterialStateProperty.resolveAs<MouseCursor>( |
1364 | widget.mouseCursor ?? MaterialStateMouseCursor.clickable, |
1365 | statesController.value, |
1366 | ); |
1367 | |
1368 | return _ParentInkResponseProvider( |
1369 | state: this, |
1370 | child: Actions( |
1371 | actions: _actionMap, |
1372 | child: Focus( |
1373 | focusNode: widget.focusNode, |
1374 | canRequestFocus: _canRequestFocus, |
1375 | onFocusChange: handleFocusUpdate, |
1376 | autofocus: widget.autofocus, |
1377 | child: MouseRegion( |
1378 | cursor: effectiveMouseCursor, |
1379 | onEnter: handleMouseEnter, |
1380 | onExit: handleMouseExit, |
1381 | child: DefaultSelectionStyle.merge( |
1382 | mouseCursor: effectiveMouseCursor, |
1383 | child: Semantics( |
1384 | onTap: widget.excludeFromSemantics || widget.onTap == null ? null : simulateTap, |
1385 | onLongPress: widget.excludeFromSemantics || widget.onLongPress == null |
1386 | ? null |
1387 | : simulateLongPress, |
1388 | child: GestureDetector( |
1389 | onTapDown: _primaryEnabled ? handleTapDown : null, |
1390 | onTapUp: _primaryEnabled ? handleTapUp : null, |
1391 | onTap: _primaryEnabled ? handleTap : null, |
1392 | onTapCancel: _primaryEnabled ? handleTapCancel : null, |
1393 | onDoubleTap: widget.onDoubleTap != null ? handleDoubleTap : null, |
1394 | onLongPress: widget.onLongPress != null ? handleLongPress : null, |
1395 | onSecondaryTapDown: _secondaryEnabled ? handleSecondaryTapDown : null, |
1396 | onSecondaryTapUp: _secondaryEnabled ? handleSecondaryTapUp : null, |
1397 | onSecondaryTap: _secondaryEnabled ? handleSecondaryTap : null, |
1398 | onSecondaryTapCancel: _secondaryEnabled ? handleSecondaryTapCancel : null, |
1399 | behavior: HitTestBehavior.opaque, |
1400 | excludeFromSemantics: true, |
1401 | child: widget.child, |
1402 | ), |
1403 | ), |
1404 | ), |
1405 | ), |
1406 | ), |
1407 | ), |
1408 | ); |
1409 | } |
1410 | } |
1411 | |
1412 | /// A rectangular area of a [Material] that responds to touch. |
1413 | /// |
1414 | /// For a variant of this widget that does not clip splashes, see [InkResponse]. |
1415 | /// |
1416 | /// The following diagram shows how an [InkWell] looks when tapped, when using |
1417 | /// default values. |
1418 | /// |
1419 | ///  |
1420 | /// |
1421 | /// The [InkWell] widget must have a [Material] widget as an ancestor. The |
1422 | /// [Material] widget is where the ink reactions are actually painted. This |
1423 | /// matches the Material Design premise wherein the [Material] is what is |
1424 | /// actually reacting to touches by spreading ink. |
1425 | /// |
1426 | /// If a Widget uses this class directly, it should include the following line |
1427 | /// at the top of its build function to call [debugCheckHasMaterial]: |
1428 | /// |
1429 | /// ```dart |
1430 | /// assert(debugCheckHasMaterial(context)); |
1431 | /// ``` |
1432 | /// |
1433 | /// ## Troubleshooting |
1434 | /// |
1435 | /// ### The ink splashes aren't visible! |
1436 | /// |
1437 | /// If there is an opaque graphic, e.g. painted using a [Container], [Image], or |
1438 | /// [DecoratedBox], between the [Material] widget and the [InkWell] widget, then |
1439 | /// the splash won't be visible because it will be under the opaque graphic. |
1440 | /// This is because ink splashes draw on the underlying [Material] itself, as |
1441 | /// if the ink was spreading inside the material. |
1442 | /// |
1443 | /// The [Ink] widget can be used as a replacement for [Image], [Container], or |
1444 | /// [DecoratedBox] to ensure that the image or decoration also paints in the |
1445 | /// [Material] itself, below the ink. |
1446 | /// |
1447 | /// If this is not possible for some reason, e.g. because you are using an |
1448 | /// opaque [CustomPaint] widget, alternatively consider using a second |
1449 | /// [Material] above the opaque widget but below the [InkWell] (as an |
1450 | /// ancestor to the ink well). The [MaterialType.transparency] material |
1451 | /// kind can be used for this purpose. |
1452 | /// |
1453 | /// ### InkWell isn't clipping properly |
1454 | /// |
1455 | /// If you want to clip an InkWell or any [Ink] widgets you need to keep in mind |
1456 | /// that the [Material] that the Ink will be printed on is responsible for clipping. |
1457 | /// This means you can't wrap the [Ink] widget in a clipping widget directly, |
1458 | /// since this will leave the [Material] not clipped (and by extension the printed |
1459 | /// [Ink] widgets as well). |
1460 | /// |
1461 | /// An easy solution is to deliberately wrap the [Ink] widgets you want to clip |
1462 | /// in a [Material], and wrap that in a clipping widget instead. See [Ink] for |
1463 | /// an example. |
1464 | /// |
1465 | /// ### The ink splashes don't track the size of an animated container |
1466 | /// If the size of an InkWell's [Material] ancestor changes while the InkWell's |
1467 | /// splashes are expanding, you may notice that the splashes aren't clipped |
1468 | /// correctly. This can't be avoided. |
1469 | /// |
1470 | /// An example of this situation is as follows: |
1471 | /// |
1472 | /// {@tool dartpad} |
1473 | /// Tap the container to cause it to grow. Then, tap it again and hold before |
1474 | /// the widget reaches its maximum size to observe the clipped ink splash. |
1475 | /// |
1476 | /// ** See code in examples/api/lib/material/ink_well/ink_well.0.dart ** |
1477 | /// {@end-tool} |
1478 | /// |
1479 | /// An InkWell's splashes will not properly update to conform to changes if the |
1480 | /// size of its underlying [Material], where the splashes are rendered, changes |
1481 | /// during animation. You should avoid using InkWells within [Material] widgets |
1482 | /// that are changing size. |
1483 | /// |
1484 | /// See also: |
1485 | /// |
1486 | /// * [GestureDetector], for listening for gestures without ink splashes. |
1487 | /// * [ElevatedButton] and [TextButton], two kinds of buttons in Material Design. |
1488 | /// * [InkResponse], a variant of [InkWell] that doesn't force a rectangular |
1489 | /// shape on the ink reaction. |
1490 | class InkWell extends InkResponse { |
1491 | /// Creates an ink well. |
1492 | /// |
1493 | /// Must have an ancestor [Material] widget in which to cause ink reactions. |
1494 | const InkWell({ |
1495 | super.key, |
1496 | super.child, |
1497 | super.onTap, |
1498 | super.onDoubleTap, |
1499 | super.onLongPress, |
1500 | super.onTapDown, |
1501 | super.onTapUp, |
1502 | super.onTapCancel, |
1503 | super.onSecondaryTap, |
1504 | super.onSecondaryTapUp, |
1505 | super.onSecondaryTapDown, |
1506 | super.onSecondaryTapCancel, |
1507 | super.onHighlightChanged, |
1508 | super.onHover, |
1509 | super.mouseCursor, |
1510 | super.focusColor, |
1511 | super.hoverColor, |
1512 | super.highlightColor, |
1513 | super.overlayColor, |
1514 | super.splashColor, |
1515 | super.splashFactory, |
1516 | super.radius, |
1517 | super.borderRadius, |
1518 | super.customBorder, |
1519 | super.enableFeedback, |
1520 | super.excludeFromSemantics, |
1521 | super.focusNode, |
1522 | super.canRequestFocus, |
1523 | super.onFocusChange, |
1524 | super.autofocus, |
1525 | super.statesController, |
1526 | super.hoverDuration, |
1527 | }) : super(containedInkWell: true, highlightShape: BoxShape.rectangle); |
1528 | } |
1529 | |