ink_well.dart 53.1 KB
Newer Older
Ian Hickson's avatar
Ian Hickson committed
1
// Copyright 2014 The Flutter Authors. All rights reserved.
2 3 4
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

5
import 'dart:async';
6 7
import 'dart:collection';

xster's avatar
xster committed
8
import 'package:flutter/foundation.dart';
9 10 11
import 'package:flutter/gestures.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
12

13
import 'debug.dart';
14
import 'feedback.dart';
15
import 'ink_highlight.dart';
16
import 'material.dart';
17
import 'material_state.dart';
18
import 'theme.dart';
19

20 21 22
// Examples can assume:
// late BuildContext context;

23 24 25 26 27 28 29 30 31
/// An ink feature that displays a [color] "splash" in response to a user
/// gesture that can be confirmed or canceled.
///
/// Subclasses call [confirm] when an input gesture is recognized. For
/// example a press event might trigger an ink feature that's confirmed
/// when the corresponding up event is seen.
///
/// Subclasses call [cancel] when an input gesture is aborted before it
/// is recognized. For example a press event might trigger an ink feature
32
/// that's canceled when the pointer is dragged out of the reference
33 34 35 36 37 38 39
/// box.
///
/// The [InkWell] and [InkResponse] widgets generate instances of this
/// class.
abstract class InteractiveInkFeature extends InkFeature {
  /// Creates an InteractiveInkFeature.
  InteractiveInkFeature({
40 41
    required super.controller,
    required super.referenceBox,
42
    required Color color,
43
    ShapeBorder? customBorder,
44
    super.onRemoved,
45 46
  }) : _color = color,
       _customBorder = customBorder;
47 48 49 50 51

  /// Called when the user input that triggered this feature's appearance was confirmed.
  ///
  /// Typically causes the ink to propagate faster across the material. By default this
  /// method does nothing.
52
  void confirm() { }
53 54 55 56 57

  /// Called when the user input that triggered this feature's appearance was canceled.
  ///
  /// Typically causes the ink to gradually disappear. By default this method does
  /// nothing.
58
  void cancel() { }
59 60 61 62 63

  /// The ink's color.
  Color get color => _color;
  Color _color;
  set color(Color value) {
64
    if (value == _color) {
65
      return;
66
    }
67 68 69
    _color = value;
    controller.markNeedsPaint();
  }
70

71 72 73 74 75 76 77 78 79 80 81
  /// The ink's optional custom border.
  ShapeBorder? get customBorder => _customBorder;
  ShapeBorder? _customBorder;
  set customBorder(ShapeBorder? value) {
    if (value == _customBorder) {
      return;
    }
    _customBorder = value;
    controller.markNeedsPaint();
  }

82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98
  /// Draws an ink splash or ink ripple on the passed in [Canvas].
  ///
  /// The [transform] argument is the [Matrix4] transform that typically
  /// shifts the coordinate space of the canvas to the space in which
  /// the ink circle is to be painted.
  ///
  /// [center] is the [Offset] from origin of the canvas where the center
  /// of the circle is drawn.
  ///
  /// [paint] takes a [Paint] object that describes the styles used to draw the ink circle.
  /// For example, [paint] can specify properties like color, strokewidth, colorFilter.
  ///
  /// [radius] is the radius of ink circle to be drawn on canvas.
  ///
  /// [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.
  ///
99
  /// Clipping can happen in 3 different ways:
100 101 102 103 104 105 106 107 108 109 110 111 112 113
  ///  1. If [customBorder] is provided, it is used to determine the path
  ///     for clipping.
  ///  2. If [customBorder] is null, and [borderRadius] is provided, the canvas
  ///     is clipped by an [RRect] created from [clipCallback] and [borderRadius].
  ///  3. If [borderRadius] is the default [BorderRadius.zero], then the [Rect] provided
  ///      by [clipCallback] is used for clipping.
  ///
  /// [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".
  ///
  /// For examples on how the function is used, see [InkSplash] and [InkRipple].
  @protected
  void paintInkCircle({
114 115 116 117 118 119 120
    required Canvas canvas,
    required Matrix4 transform,
    required Paint paint,
    required Offset center,
    required double radius,
    TextDirection? textDirection,
    ShapeBorder? customBorder,
121
    BorderRadius borderRadius = BorderRadius.zero,
122
    RectCallback? clipCallback,
123
  }) {
124

125
    final Offset? originOffset = MatrixUtils.getAsTranslation(transform);
126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148
    canvas.save();
    if (originOffset == null) {
      canvas.transform(transform.storage);
    } else {
      canvas.translate(originOffset.dx, originOffset.dy);
    }
    if (clipCallback != null) {
      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);
      }
    }
    canvas.drawCircle(center, radius, paint);
    canvas.restore();
  }
149 150
}

151 152
/// An encapsulation of an [InteractiveInkFeature] constructor used by
/// [InkWell], [InkResponse], and [ThemeData].
153 154 155 156 157 158 159 160 161 162
///
/// Interactive ink feature implementations should provide a static const
/// `splashFactory` value that's an instance of this class. The `splashFactory`
/// can be used to configure an [InkWell], [InkResponse] or [ThemeData].
///
/// See also:
///
///  * [InkSplash.splashFactory]
///  * [InkRipple.splashFactory]
abstract class InteractiveInkFeatureFactory {
163 164 165
  /// Abstract const constructor. This constructor enables subclasses to provide
  /// const constructors so that they can be used in const expressions.
  ///
166 167 168 169 170 171 172
  /// Subclasses should provide a const constructor.
  const InteractiveInkFeatureFactory();

  /// The factory method.
  ///
  /// Subclasses should override this method to return a new instance of an
  /// [InteractiveInkFeature].
173
  @factory
174
  InteractiveInkFeature create({
175 176 177 178 179
    required MaterialInkController controller,
    required RenderBox referenceBox,
    required Offset position,
    required Color color,
    required TextDirection textDirection,
180
    bool containedInkWell = false,
181 182 183 184
    RectCallback? rectCallback,
    BorderRadius? borderRadius,
    ShapeBorder? customBorder,
    double? radius,
185
    VoidCallback? onRemoved,
186 187 188
  });
}

189 190 191 192 193 194
abstract class _ParentInkResponseState {
  void markChildInkResponsePressed(_ParentInkResponseState childState, bool value);
}

class _ParentInkResponseProvider extends InheritedWidget {
  const _ParentInkResponseProvider({
195
    required this.state,
196 197
    required super.child,
  });
198 199 200 201 202 203

  final _ParentInkResponseState state;

  @override
  bool updateShouldNotify(_ParentInkResponseProvider oldWidget) => state != oldWidget.state;

204
  static _ParentInkResponseState? maybeOf(BuildContext context) {
205 206 207 208
    return context.dependOnInheritedWidgetOfExactType<_ParentInkResponseProvider>()?.state;
  }
}

209
typedef _GetRectCallback = RectCallback? Function(RenderBox referenceBox);
210 211
typedef _CheckContext = bool Function(BuildContext context);

212
/// An area of a [Material] that responds to touch. Has a configurable shape and
213 214
/// can be configured to clip splashes that extend outside its bounds or not.
///
215
/// For a variant of this widget that is specialized for rectangular areas that
216 217
/// always clip splashes, see [InkWell].
///
218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233
/// An [InkResponse] widget does two things when responding to a tap:
///
///  * It starts to animate a _highlight_. The shape of the highlight is
///    determined by [highlightShape]. If it is a [BoxShape.circle], the
///    default, then the highlight is a circle of fixed size centered in the
///    [InkResponse]. If it is [BoxShape.rectangle], then the highlight is a box
///    the size of the [InkResponse] itself, unless [getRectCallback] is
///    provided, in which case that callback defines the rectangle. The color of
///    the highlight is set by [highlightColor].
///
///  * Simultaneously, it starts to animate a _splash_. This is a growing circle
///    initially centered on the tap location. If this is a [containedInkWell],
///    the splash grows to the [radius] while remaining centered at the tap
///    location. Otherwise, the splash migrates to the center of the box as it
///    grows.
///
234 235
/// The following two diagrams show how [InkResponse] looks when tapped if the
/// [highlightShape] is [BoxShape.circle] (the default) and [containedInkWell]
236 237 238 239
/// is false (also the default).
///
/// The first diagram shows how it looks if the [InkResponse] is relatively
/// large:
240
///
241
/// ![The highlight is a disc centered in the box, smaller than the child widget.](https://flutter.github.io/assets-for-api-docs/assets/material/ink_response_large.png)
242 243 244
///
/// The second diagram shows how it looks if the [InkResponse] is small:
///
245
/// ![The highlight is a disc overflowing the box, centered on the child.](https://flutter.github.io/assets-for-api-docs/assets/material/ink_response_small.png)
246
///
247 248 249
/// The main thing to notice from these diagrams is that the splashes happily
/// exceed the bounds of the widget (because [containedInkWell] is false).
///
250 251 252 253
/// The following diagram shows the effect when the [InkResponse] has a
/// [highlightShape] of [BoxShape.rectangle] with [containedInkWell] set to
/// true. These are the values used by [InkWell].
///
254
/// ![The highlight is a rectangle the size of the box.](https://flutter.github.io/assets-for-api-docs/assets/material/ink_well.png)
255
///
256
/// The [InkResponse] widget must have a [Material] widget as an ancestor. The
257
/// [Material] widget is where the ink reactions are actually painted. This
258
/// matches the Material Design premise wherein the [Material] is what is
259
/// actually reacting to touches by spreading ink.
260 261
///
/// If a Widget uses this class directly, it should include the following line
262
/// at the top of its build function to call [debugCheckHasMaterial]:
263
///
264 265 266
/// ```dart
/// assert(debugCheckHasMaterial(context));
/// ```
Ian Hickson's avatar
Ian Hickson committed
267 268 269 270 271 272 273
///
/// ## Troubleshooting
///
/// ### The ink splashes aren't visible!
///
/// If there is an opaque graphic, e.g. painted using a [Container], [Image], or
/// [DecoratedBox], between the [Material] widget and the [InkResponse] widget,
274 275 276 277 278 279 280 281 282 283 284 285 286
/// then the splash won't be visible because it will be under the opaque graphic.
/// This is because ink splashes draw on the underlying [Material] itself, as
/// if the ink was spreading inside the material.
///
/// The [Ink] widget can be used as a replacement for [Image], [Container], or
/// [DecoratedBox] to ensure that the image or decoration also paints in the
/// [Material] itself, below the ink.
///
/// If this is not possible for some reason, e.g. because you are using an
/// opaque [CustomPaint] widget, alternatively consider using a second
/// [Material] above the opaque widget but below the [InkResponse] (as an
/// ancestor to the ink response). The [MaterialType.transparency] material
/// kind can be used for this purpose.
287 288 289 290
///
/// See also:
///
///  * [GestureDetector], for listening for gestures without ink splashes.
291
///  * [ElevatedButton] and [TextButton], two kinds of buttons in Material Design.
292
///  * [IconButton], which combines [InkResponse] with an [Icon].
293
class InkResponse extends StatelessWidget {
294 295 296
  /// Creates an area of a [Material] that responds to touch.
  ///
  /// Must have an ancestor [Material] widget in which to cause ink reactions.
297
  const InkResponse({
298
    super.key,
299 300
    this.child,
    this.onTap,
301
    this.onTapDown,
302
    this.onTapUp,
303
    this.onTapCancel,
304
    this.onDoubleTap,
305
    this.onLongPress,
306 307 308 309
    this.onSecondaryTap,
    this.onSecondaryTapUp,
    this.onSecondaryTapDown,
    this.onSecondaryTapCancel,
310
    this.onHighlightChanged,
311
    this.onHover,
312
    this.mouseCursor,
313 314
    this.containedInkWell = false,
    this.highlightShape = BoxShape.circle,
315
    this.radius,
Ian Hickson's avatar
Ian Hickson committed
316
    this.borderRadius,
317
    this.customBorder,
318 319
    this.focusColor,
    this.hoverColor,
320
    this.highlightColor,
321
    this.overlayColor,
322
    this.splashColor,
323
    this.splashFactory,
324 325
    this.enableFeedback = true,
    this.excludeFromSemantics = false,
326 327 328 329
    this.focusNode,
    this.canRequestFocus = true,
    this.onFocusChange,
    this.autofocus = false,
330
    this.statesController,
331
    this.hoverDuration,
332
  });
333

334
  /// The widget below this widget in the tree.
335
  ///
336
  /// {@macro flutter.widgets.ProxyWidget.child}
337
  final Widget? child;
338

339
  /// Called when the user taps this part of the material.
340
  final GestureTapCallback? onTap;
341

342
  /// Called when the user taps down this part of the material.
343
  final GestureTapDownCallback? onTapDown;
344

345 346 347 348
  /// Called when the user releases a tap that was started on this part of the
  /// material. [onTap] is called immediately after.
  final GestureTapUpCallback? onTapUp;

349 350
  /// Called when the user cancels a tap that was started on this part of the
  /// material.
351
  final GestureTapCallback? onTapCancel;
352

353
  /// Called when the user double taps this part of the material.
354
  final GestureTapCallback? onDoubleTap;
355

356
  /// Called when the user long-presses on this part of the material.
357
  final GestureLongPressCallback? onLongPress;
358

359 360 361 362 363 364 365 366 367 368 369 370 371 372 373
  /// Called when the user taps this part of the material with a secondary button.
  final GestureTapCallback? onSecondaryTap;

  /// Called when the user taps down on this part of the material with a
  /// secondary button.
  final GestureTapDownCallback? onSecondaryTapDown;

  /// Called when the user releases a secondary button tap that was started on
  /// this part of the material. [onSecondaryTap] is called immediately after.
  final GestureTapUpCallback? onSecondaryTapUp;

  /// Called when the user cancels a secondary button tap that was started on
  /// this part of the material.
  final GestureTapCallback? onSecondaryTapCancel;

374 375
  /// Called when this part of the material either becomes highlighted or stops
  /// being highlighted.
376 377 378 379
  ///
  /// The value passed to the callback is true if this part of the material has
  /// become highlighted and false if this part of the material has stopped
  /// being highlighted.
380 381 382 383 384 385
  ///
  /// If all of [onTap], [onDoubleTap], and [onLongPress] become null while a
  /// gesture is ongoing, then [onTapCancel] will be fired and
  /// [onHighlightChanged] will be fired with the value false _during the
  /// build_. This means, for instance, that in that scenario [State.setState]
  /// cannot be called.
386
  final ValueChanged<bool>? onHighlightChanged;
387

388 389 390 391 392
  /// Called when a pointer enters or exits the ink response area.
  ///
  /// The value passed to the callback is true if a pointer has entered this
  /// part of the material and false if a pointer has exited this part of the
  /// material.
393
  final ValueChanged<bool>? onHover;
394

395
  /// The cursor for a mouse pointer when it enters or is hovering over the
396
  /// widget.
397
  ///
398 399 400 401 402 403 404 405
  /// If [mouseCursor] is a [MaterialStateProperty<MouseCursor>],
  /// [MaterialStateProperty.resolve] is used for the following [MaterialState]s:
  ///
  ///  * [MaterialState.hovered].
  ///  * [MaterialState.focused].
  ///  * [MaterialState.disabled].
  ///
  /// If this property is null, [MaterialStateMouseCursor.clickable] will be used.
406
  final MouseCursor? mouseCursor;
407

408
  /// Whether this ink response should be clipped its bounds.
409 410 411 412 413 414 415 416
  ///
  /// This flag also controls whether the splash migrates to the center of the
  /// [InkResponse] or not. If [containedInkWell] is true, the splash remains
  /// centered around the tap location. If it is false, the splash migrates to
  /// the center of the [InkResponse] as it grows.
  ///
  /// See also:
  ///
417 418
  ///  * [highlightShape], the shape of the focus, hover, and pressed
  ///    highlights.
419 420 421
  ///  * [borderRadius], which controls the corners when the box is a rectangle.
  ///  * [getRectCallback], which controls the size and position of the box when
  ///    it is a rectangle.
422
  final bool containedInkWell;
423

424
  /// The shape (e.g., circle, rectangle) to use for the highlight drawn around
425 426 427 428 429
  /// this part of the material when pressed, hovered over, or focused.
  ///
  /// The same shape is used for the pressed highlight (see [highlightColor]),
  /// the focus highlight (see [focusColor]), and the hover highlight (see
  /// [hoverColor]).
430 431 432 433 434 435 436 437 438 439 440 441 442
  ///
  /// If the shape is [BoxShape.circle], then the highlight is centered on the
  /// [InkResponse]. If the shape is [BoxShape.rectangle], then the highlight
  /// fills the [InkResponse], or the rectangle provided by [getRectCallback] if
  /// the callback is specified.
  ///
  /// See also:
  ///
  ///  * [containedInkWell], which controls clipping behavior.
  ///  * [borderRadius], which controls the corners when the box is a rectangle.
  ///  * [highlightColor], the color of the highlight.
  ///  * [getRectCallback], which controls the size and position of the box when
  ///    it is a rectangle.
443
  final BoxShape highlightShape;
444

445
  /// The radius of the ink splash.
446 447 448 449 450 451 452 453
  ///
  /// Splashes grow up to this size. By default, this size is determined from
  /// the size of the rectangle provided by [getRectCallback], or the size of
  /// the [InkResponse] itself.
  ///
  /// See also:
  ///
  ///  * [splashColor], the color of the splash.
454
  ///  * [splashFactory], which defines the appearance of the splash.
455
  final double? radius;
456

457 458
  /// The border radius of the containing rectangle. This is effective only if
  /// [highlightShape] is [BoxShape.rectangle].
Ian Hickson's avatar
Ian Hickson committed
459 460
  ///
  /// If this is null, it is interpreted as [BorderRadius.zero].
461
  final BorderRadius? borderRadius;
462

463 464 465
  /// The custom clip border.
  ///
  /// If this is null, the ink response will not clip its content.
466
  final ShapeBorder? customBorder;
467

468 469 470 471 472 473 474 475 476 477 478
  /// The color of the ink response when the parent widget is focused. If this
  /// property is null then the focus color of the theme,
  /// [ThemeData.focusColor], will be used.
  ///
  /// See also:
  ///
  ///  * [highlightShape], the shape of the focus, hover, and pressed
  ///    highlights.
  ///  * [hoverColor], the color of the hover highlight.
  ///  * [splashColor], the color of the splash.
  ///  * [splashFactory], which defines the appearance of the splash.
479
  final Color? focusColor;
480 481 482 483 484 485 486 487 488 489 490 491 492

  /// The color of the ink response when a pointer is hovering over it. If this
  /// property is null then the hover color of the theme,
  /// [ThemeData.hoverColor], will be used.
  ///
  /// See also:
  ///
  ///  * [highlightShape], the shape of the focus, hover, and pressed
  ///    highlights.
  ///  * [highlightColor], the color of the pressed highlight.
  ///  * [focusColor], the color of the focus highlight.
  ///  * [splashColor], the color of the splash.
  ///  * [splashFactory], which defines the appearance of the splash.
493
  final Color? hoverColor;
494 495 496 497

  /// The highlight color of the ink response when pressed. If this property is
  /// null then the highlight color of the theme, [ThemeData.highlightColor],
  /// will be used.
498 499 500
  ///
  /// See also:
  ///
501 502 503 504
  ///  * [hoverColor], the color of the hover highlight.
  ///  * [focusColor], the color of the focus highlight.
  ///  * [highlightShape], the shape of the focus, hover, and pressed
  ///    highlights.
505
  ///  * [splashColor], the color of the splash.
506
  ///  * [splashFactory], which defines the appearance of the splash.
507
  final Color? highlightColor;
508

509 510 511
  /// Defines the ink response focus, hover, and splash colors.
  ///
  /// This default null property can be used as an alternative to
512 513 514 515 516 517
  /// [focusColor], [hoverColor], [highlightColor], and
  /// [splashColor]. If non-null, it is resolved against one of
  /// [MaterialState.focused], [MaterialState.hovered], and
  /// [MaterialState.pressed]. It's convenient to use when the parent
  /// widget can pass along its own MaterialStateProperty value for
  /// the overlay color.
518 519 520 521
  ///
  /// [MaterialState.pressed] triggers a ripple (an ink splash), per
  /// the current Material Design spec. The [overlayColor] doesn't map
  /// a state to [highlightColor] because a separate highlight is not
522
  /// used by the current design guidelines. See
523 524 525 526 527 528 529 530
  /// https://material.io/design/interaction/states.html#pressed
  ///
  /// If the overlay color is null or resolves to null, then [focusColor],
  /// [hoverColor], [splashColor] and their defaults are used instead.
  ///
  /// See also:
  ///
  ///  * The Material Design specification for overlay colors and how they
531
  ///    match a component's state:
532
  ///    <https://material.io/design/interaction/states.html#anatomy>.
533
  final MaterialStateProperty<Color?>? overlayColor;
534

535
  /// The splash color of the ink response. If this property is null then the
536
  /// splash color of the theme, [ThemeData.splashColor], will be used.
537 538 539
  ///
  /// See also:
  ///
540
  ///  * [splashFactory], which defines the appearance of the splash.
541 542
  ///  * [radius], the (maximum) size of the ink splash.
  ///  * [highlightColor], the color of the highlight.
543
  final Color? splashColor;
544

545 546 547 548 549 550 551 552 553 554 555
  /// Defines the appearance of the splash.
  ///
  /// Defaults to the value of the theme's splash factory: [ThemeData.splashFactory].
  ///
  /// See also:
  ///
  ///  * [radius], the (maximum) size of the ink splash.
  ///  * [splashColor], the color of the splash.
  ///  * [highlightColor], the color of the highlight.
  ///  * [InkSplash.splashFactory], which defines the default splash.
  ///  * [InkRipple.splashFactory], which defines a splash that spreads out
556
  ///    more aggressively than the default.
557
  final InteractiveInkFeatureFactory? splashFactory;
558

559 560 561 562 563 564 565 566 567 568
  /// Whether detected gestures should provide acoustic and/or haptic feedback.
  ///
  /// For example, on Android a tap will produce a clicking sound and a
  /// long-press will produce a short vibration, when feedback is enabled.
  ///
  /// See also:
  ///
  ///  * [Feedback] for providing platform-specific feedback to certain actions.
  final bool enableFeedback;

569 570 571 572 573 574 575 576 577
  /// Whether to exclude the gestures introduced by this widget from the
  /// semantics tree.
  ///
  /// For example, a long-press gesture for showing a tooltip is usually
  /// excluded because the tooltip itself is included in the semantics
  /// tree directly and so having a gesture to show it would result in
  /// duplication of information.
  final bool excludeFromSemantics;

578
  /// {@template flutter.material.inkwell.onFocusChange}
579 580 581 582
  /// Handler called when the focus changes.
  ///
  /// Called with true if this widget's node gains focus, and false if it loses
  /// focus.
583
  /// {@endtemplate}
584
  final ValueChanged<bool>? onFocusChange;
585 586 587 588 589

  /// {@macro flutter.widgets.Focus.autofocus}
  final bool autofocus;

  /// {@macro flutter.widgets.Focus.focusNode}
590
  final FocusNode? focusNode;
591

592
  /// {@macro flutter.widgets.Focus.canRequestFocus}
593 594
  final bool canRequestFocus;

595 596 597 598 599
  /// The rectangle to use for the highlight effect and for clipping
  /// the splash effects if [containedInkWell] is true.
  ///
  /// This method is intended to be overridden by descendants that
  /// specialize [InkResponse] for unusual cases. For example,
600
  /// [TableRowInkWell] implements this method to return the rectangle
601 602 603 604 605
  /// corresponding to the row that the widget is in.
  ///
  /// The default behavior returns null, which is equivalent to
  /// returning the referenceBox argument's bounding box (though
  /// slightly more efficient).
606
  RectCallback? getRectCallback(RenderBox referenceBox) => null;
607

608 609 610 611 612 613 614 615 616 617 618 619 620
  /// {@template flutter.material.inkwell.statesController}
  /// Represents the interactive "state" of this widget in terms of
  /// a set of [MaterialState]s, like [MaterialState.pressed] and
  /// [MaterialState.focused].
  ///
  /// Classes based on this one can provide their own
  /// [MaterialStatesController] to which they've added listeners.
  /// They can also update the controller's [MaterialStatesController.value]
  /// however, this may only be done when it's safe to call
  /// [State.setState], like in an event handler.
  /// {@endtemplate}
  final MaterialStatesController? statesController;

621 622 623 624 625
  /// The duration of the animation that animates the hover effect.
  ///
  /// The default is 50ms.
  final Duration? hoverDuration;

626 627
  @override
  Widget build(BuildContext context) {
628
    final _ParentInkResponseState? parentState = _ParentInkResponseProvider.maybeOf(context);
629
    return _InkResponseStateWidget(
630 631
      onTap: onTap,
      onTapDown: onTapDown,
632
      onTapUp: onTapUp,
633 634 635
      onTapCancel: onTapCancel,
      onDoubleTap: onDoubleTap,
      onLongPress: onLongPress,
636 637 638 639
      onSecondaryTap: onSecondaryTap,
      onSecondaryTapUp: onSecondaryTapUp,
      onSecondaryTapDown: onSecondaryTapDown,
      onSecondaryTapCancel: onSecondaryTapCancel,
640 641
      onHighlightChanged: onHighlightChanged,
      onHover: onHover,
642
      mouseCursor: mouseCursor,
643 644 645 646 647 648 649 650
      containedInkWell: containedInkWell,
      highlightShape: highlightShape,
      radius: radius,
      borderRadius: borderRadius,
      customBorder: customBorder,
      focusColor: focusColor,
      hoverColor: hoverColor,
      highlightColor: highlightColor,
651
      overlayColor: overlayColor,
652 653 654 655 656 657 658 659 660 661 662
      splashColor: splashColor,
      splashFactory: splashFactory,
      enableFeedback: enableFeedback,
      excludeFromSemantics: excludeFromSemantics,
      focusNode: focusNode,
      canRequestFocus: canRequestFocus,
      onFocusChange: onFocusChange,
      autofocus: autofocus,
      parentState: parentState,
      getRectCallback: getRectCallback,
      debugCheckContext: debugCheckContext,
663
      statesController: statesController,
664
      hoverDuration: hoverDuration,
665
      child: child,
666 667 668
    );
  }

669 670 671 672 673
  /// Asserts that the given context satisfies the prerequisites for
  /// this class.
  ///
  /// This method is intended to be overridden by descendants that
  /// specialize [InkResponse] for unusual cases. For example,
674
  /// [TableRowInkWell] implements this method to verify that the widget is
675
  /// in a table.
676
  @mustCallSuper
677 678
  bool debugCheckContext(BuildContext context) {
    assert(debugCheckHasMaterial(context));
679
    assert(debugCheckHasDirectionality(context));
680 681
    return true;
  }
682 683
}

684 685
class _InkResponseStateWidget extends StatefulWidget {
  const _InkResponseStateWidget({
686 687 688
    this.child,
    this.onTap,
    this.onTapDown,
689
    this.onTapUp,
690 691 692
    this.onTapCancel,
    this.onDoubleTap,
    this.onLongPress,
693 694 695 696
    this.onSecondaryTap,
    this.onSecondaryTapUp,
    this.onSecondaryTapDown,
    this.onSecondaryTapCancel,
697 698
    this.onHighlightChanged,
    this.onHover,
699
    this.mouseCursor,
700 701 702 703 704 705 706 707
    this.containedInkWell = false,
    this.highlightShape = BoxShape.circle,
    this.radius,
    this.borderRadius,
    this.customBorder,
    this.focusColor,
    this.hoverColor,
    this.highlightColor,
708
    this.overlayColor,
709 710 711 712 713 714 715 716 717 718
    this.splashColor,
    this.splashFactory,
    this.enableFeedback = true,
    this.excludeFromSemantics = false,
    this.focusNode,
    this.canRequestFocus = true,
    this.onFocusChange,
    this.autofocus = false,
    this.parentState,
    this.getRectCallback,
719
    required this.debugCheckContext,
720
    this.statesController,
721
    this.hoverDuration,
722
  });
723

724 725 726
  final Widget? child;
  final GestureTapCallback? onTap;
  final GestureTapDownCallback? onTapDown;
727
  final GestureTapUpCallback? onTapUp;
728 729 730
  final GestureTapCallback? onTapCancel;
  final GestureTapCallback? onDoubleTap;
  final GestureLongPressCallback? onLongPress;
731 732 733 734
  final GestureTapCallback? onSecondaryTap;
  final GestureTapUpCallback? onSecondaryTapUp;
  final GestureTapDownCallback? onSecondaryTapDown;
  final GestureTapCallback? onSecondaryTapCancel;
735 736 737
  final ValueChanged<bool>? onHighlightChanged;
  final ValueChanged<bool>? onHover;
  final MouseCursor? mouseCursor;
738 739
  final bool containedInkWell;
  final BoxShape highlightShape;
740 741 742 743 744 745 746 747 748
  final double? radius;
  final BorderRadius? borderRadius;
  final ShapeBorder? customBorder;
  final Color? focusColor;
  final Color? hoverColor;
  final Color? highlightColor;
  final MaterialStateProperty<Color?>? overlayColor;
  final Color? splashColor;
  final InteractiveInkFeatureFactory? splashFactory;
749 750
  final bool enableFeedback;
  final bool excludeFromSemantics;
751
  final ValueChanged<bool>? onFocusChange;
752
  final bool autofocus;
753
  final FocusNode? focusNode;
754
  final bool canRequestFocus;
755 756
  final _ParentInkResponseState? parentState;
  final _GetRectCallback? getRectCallback;
757
  final _CheckContext debugCheckContext;
758
  final MaterialStatesController? statesController;
759
  final Duration? hoverDuration;
760

761
  @override
762
  _InkResponseState createState() => _InkResponseState();
Ian Hickson's avatar
Ian Hickson committed
763 764

  @override
765 766
  void debugFillProperties(DiagnosticPropertiesBuilder properties) {
    super.debugFillProperties(properties);
767 768 769 770 771
    final List<String> gestures = <String>[
      if (onTap != null) 'tap',
      if (onDoubleTap != null) 'double tap',
      if (onLongPress != null) 'long press',
      if (onTapDown != null) 'tap down',
772
      if (onTapUp != null) 'tap up',
773
      if (onTapCancel != null) 'tap cancel',
774 775 776 777
      if (onSecondaryTap != null) 'secondary tap',
      if (onSecondaryTapUp != null) 'secondary tap up',
      if (onSecondaryTapDown != null) 'secondary tap down',
      if (onSecondaryTapCancel != null) 'secondary tap cancel'
778
    ];
779
    properties.add(IterableProperty<String>('gestures', gestures, ifEmpty: '<none>'));
780
    properties.add(DiagnosticsProperty<MouseCursor>('mouseCursor', mouseCursor));
781 782
    properties.add(DiagnosticsProperty<bool>('containedInkWell', containedInkWell, level: DiagnosticLevel.fine));
    properties.add(DiagnosticsProperty<BoxShape>(
783 784 785 786 787
      'highlightShape',
      highlightShape,
      description: '${containedInkWell ? "clipped to " : ""}$highlightShape',
      showName: false,
    ));
Ian Hickson's avatar
Ian Hickson committed
788
  }
789 790
}

791 792 793 794 795 796 797 798
/// Used to index the allocated highlights for the different types of highlights
/// in [_InkResponseState].
enum _HighlightType {
  pressed,
  hover,
  focus,
}

799
class _InkResponseState extends State<_InkResponseStateWidget>
800 801 802
  with AutomaticKeepAliveClientMixin<_InkResponseStateWidget>
  implements _ParentInkResponseState
{
803 804
  Set<InteractiveInkFeature>? _splashes;
  InteractiveInkFeature? _currentSplash;
805
  bool _hovering = false;
806
  final Map<_HighlightType, InkHighlight?> _highlights = <_HighlightType, InkHighlight?>{};
807
  late final Map<Type, Action<Intent>> _actionMap = <Type, Action<Intent>>{
808 809
    ActivateIntent: CallbackAction<ActivateIntent>(onInvoke: activateOnIntent),
    ButtonActivateIntent: CallbackAction<ButtonActivateIntent>(onInvoke: activateOnIntent),
810
  };
811
  MaterialStatesController? internalStatesController;
812

813
  bool get highlightsExist => _highlights.values.where((InkHighlight? highlight) => highlight != null).isNotEmpty;
814

815
  final ObserverList<_ParentInkResponseState> _activeChildren = ObserverList<_ParentInkResponseState>();
816

817 818 819
  static const Duration _activationDuration = Duration(milliseconds: 100);
  Timer? _activationTimer;

820 821 822 823 824 825 826 827 828 829 830 831 832 833 834
  @override
  void markChildInkResponsePressed(_ParentInkResponseState childState, bool value) {
    final bool lastAnyPressed = _anyChildInkResponsePressed;
    if (value) {
      _activeChildren.add(childState);
    } else {
      _activeChildren.remove(childState);
    }
    final bool nowAnyPressed = _anyChildInkResponsePressed;
    if (nowAnyPressed != lastAnyPressed) {
      widget.parentState?.markChildInkResponsePressed(this, nowAnyPressed);
    }
  }
  bool get _anyChildInkResponsePressed => _activeChildren.isNotEmpty;

835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853
  void activateOnIntent(Intent? intent) {
    _activationTimer?.cancel();
    _activationTimer = null;
    _startNewSplash(context: context);
    _currentSplash?.confirm();
    _currentSplash = null;
    if (widget.onTap != null) {
      if (widget.enableFeedback) {
        Feedback.forTap(context);
      }
      widget.onTap?.call();
    }
    // Delay the call to `updateHighlight` to simulate a pressed delay
    // and give MaterialStatesController listeners a chance to react.
    _activationTimer = Timer(_activationDuration, () {
      updateHighlight(_HighlightType.pressed, value: false);
    });
  }

854
  void simulateTap([Intent? intent]) {
855
    _startNewSplash(context: context);
856
    handleTap();
857 858
  }

859
  void simulateLongPress() {
860
    _startNewSplash(context: context);
861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876
    handleLongPress();
  }

  void handleStatesControllerChange() {
    // Force a rebuild to resolve widget.overlayColor, widget.mouseCursor
    setState(() { });
  }

  MaterialStatesController get statesController => widget.statesController ?? internalStatesController!;

  void initStatesController() {
    if (widget.statesController == null) {
      internalStatesController = MaterialStatesController();
    }
    statesController.update(MaterialState.disabled, !enabled);
    statesController.addListener(handleStatesControllerChange);
877 878
  }

879 880 881
  @override
  void initState() {
    super.initState();
882 883
    initStatesController();
    FocusManager.instance.addHighlightModeListener(handleFocusHighlightModeChange);
884 885
  }

886
  @override
887
  void didUpdateWidget(_InkResponseStateWidget oldWidget) {
888
    super.didUpdateWidget(oldWidget);
889 890 891 892 893
    if (widget.statesController != oldWidget.statesController) {
      oldWidget.statesController?.removeListener(handleStatesControllerChange);
      if (widget.statesController != null) {
        internalStatesController?.dispose();
        internalStatesController = null;
894
      }
895
      initStatesController();
896
    }
897 898 899
    if (widget.radius != oldWidget.radius ||
        widget.highlightShape != oldWidget.highlightShape ||
        widget.borderRadius != oldWidget.borderRadius) {
900 901 902
      final InkHighlight? hoverHighlight = _highlights[_HighlightType.hover];
      if (hoverHighlight != null) {
        hoverHighlight.dispose();
903 904
        updateHighlight(_HighlightType.hover, value: _hovering, callOnHover: false);
      }
905 906 907
      final InkHighlight? focusHighlight = _highlights[_HighlightType.focus];
      if (focusHighlight != null) {
        focusHighlight.dispose();
908 909 910
        // Do not call updateFocusHighlights() here because it is called below
      }
    }
911 912 913
    if (widget.customBorder != oldWidget.customBorder) {
      _updateHighlightsAndSplashes();
    }
914 915 916 917
    if (enabled != isWidgetEnabled(oldWidget)) {
      statesController.update(MaterialState.disabled, !enabled);
      if (!enabled) {
        statesController.update(MaterialState.pressed, false);
918 919 920 921 922 923 924
        // Remove the existing hover highlight immediately when enabled is false.
        // Do not rely on updateHighlight or InkHighlight.deactivate to not break
        // the expected lifecycle which is updating _hovering when the mouse exit.
        // Manually updating _hovering here or calling InkHighlight.deactivate
        // will lead to onHover not being called or call when it is not allowed.
        final InkHighlight? hoverHighlight = _highlights[_HighlightType.hover];
        hoverHighlight?.dispose();
925 926 927 928 929 930
      }
      // Don't call widget.onHover because many widgets, including the button
      // widgets, apply setState to an ancestor context from onHover.
      updateHighlight(_HighlightType.hover, value: _hovering, callOnHover: false);
    }
    updateFocusHighlights();
931 932
  }

933 934
  @override
  void dispose() {
935 936
    FocusManager.instance.removeHighlightModeListener(handleFocusHighlightModeChange);
    statesController.removeListener(handleStatesControllerChange);
937
    internalStatesController?.dispose();
938 939
    _activationTimer?.cancel();
    _activationTimer = null;
940 941 942 943
    super.dispose();
  }

  @override
944
  bool get wantKeepAlive => highlightsExist || (_splashes != null && _splashes!.isNotEmpty);
945 946 947 948 949 950 951

  Duration getFadeDurationForType(_HighlightType type) {
    switch (type) {
      case _HighlightType.pressed:
        return const Duration(milliseconds: 200);
      case _HighlightType.hover:
      case _HighlightType.focus:
952
        return widget.hoverDuration ?? const Duration(milliseconds: 50);
953 954 955
    }
  }

956 957
  void updateHighlight(_HighlightType type, { required bool value, bool callOnHover = true }) {
    final InkHighlight? highlight = _highlights[type];
958 959 960
    void handleInkRemoval() {
      assert(_highlights[type] != null);
      _highlights[type] = null;
961
      updateKeepAlive();
962 963
    }

964 965 966 967 968 969 970 971 972 973 974 975
    switch (type) {
      case _HighlightType.pressed:
        statesController.update(MaterialState.pressed, value);
      case _HighlightType.hover:
        if (callOnHover) {
          statesController.update(MaterialState.hovered, value);
        }
      case _HighlightType.focus:
        // see handleFocusUpdate()
        break;
    }

976 977 978
    if (type == _HighlightType.pressed) {
      widget.parentState?.markChildInkResponsePressed(this, value);
    }
979
    if (value == (highlight != null && highlight.active)) {
980
      return;
981
    }
982

983
    if (value) {
984
      if (highlight == null) {
985 986 987 988 989 990 991
        final Color resolvedOverlayColor = widget.overlayColor?.resolve(statesController.value)
          ?? switch (type) {
            // Use the backwards compatible defaults
            _HighlightType.pressed => widget.highlightColor ?? Theme.of(context).highlightColor,
            _HighlightType.focus => widget.focusColor ?? Theme.of(context).focusColor,
            _HighlightType.hover => widget.hoverColor ?? Theme.of(context).hoverColor,
          };
992
        final RenderBox referenceBox = context.findRenderObject()! as RenderBox;
993
        _highlights[type] = InkHighlight(
994
          controller: Material.of(context),
995
          referenceBox: referenceBox,
996
          color: enabled ? resolvedOverlayColor : resolvedOverlayColor.withAlpha(0),
997
          shape: widget.highlightShape,
998
          radius: widget.radius,
999
          borderRadius: widget.borderRadius,
1000
          customBorder: widget.customBorder,
1001
          rectCallback: widget.getRectCallback!(referenceBox),
1002
          onRemoved: handleInkRemoval,
1003
          textDirection: Directionality.of(context),
1004
          fadeDuration: getFadeDurationForType(type),
1005
        );
1006
        updateKeepAlive();
1007
      } else {
1008
        highlight.activate();
1009 1010
      }
    } else {
1011
      highlight!.deactivate();
1012
    }
1013
    assert(value == (_highlights[type] != null && _highlights[type]!.active));
1014

1015
    switch (type) {
1016
      case _HighlightType.pressed:
1017
        widget.onHighlightChanged?.call(value);
1018
      case _HighlightType.hover:
1019
        if (callOnHover) {
1020
          widget.onHover?.call(value);
1021
        }
1022 1023
      case _HighlightType.focus:
        break;
1024
    }
1025 1026
  }

1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043
  void _updateHighlightsAndSplashes() {
    for (final InkHighlight? highlight in _highlights.values) {
      if (highlight != null) {
        highlight.customBorder = widget.customBorder;
      }
    }
    if (_currentSplash != null) {
      _currentSplash!.customBorder = widget.customBorder;
    }
    if (_splashes != null && _splashes!.isNotEmpty) {
      for (final InteractiveInkFeature inkFeature in _splashes!) {
        inkFeature.customBorder = widget.customBorder;
      }
    }
  }

  InteractiveInkFeature _createSplash(Offset globalPosition) {
1044
    final MaterialInkController inkController = Material.of(context);
1045
    final RenderBox referenceBox = context.findRenderObject()! as RenderBox;
1046
    final Offset position = referenceBox.globalToLocal(globalPosition);
1047
    final Color color =  widget.overlayColor?.resolve(statesController.value) ?? widget.splashColor ?? Theme.of(context).splashColor;
1048 1049 1050
    final RectCallback? rectCallback = widget.containedInkWell ? widget.getRectCallback!(referenceBox) : null;
    final BorderRadius? borderRadius = widget.borderRadius;
    final ShapeBorder? customBorder = widget.customBorder;
1051

1052
    InteractiveInkFeature? splash;
1053 1054
    void onRemoved() {
      if (_splashes != null) {
1055 1056
        assert(_splashes!.contains(splash));
        _splashes!.remove(splash);
1057
        if (_currentSplash == splash) {
1058
          _currentSplash = null;
1059
        }
1060 1061 1062 1063
        updateKeepAlive();
      } // else we're probably in deactivate()
    }

1064
    splash = (widget.splashFactory ?? Theme.of(context).splashFactory).create(
1065
      controller: inkController,
1066
      referenceBox: referenceBox,
1067 1068
      position: position,
      color: color,
1069
      containedInkWell: widget.containedInkWell,
1070
      rectCallback: rectCallback,
1071
      radius: widget.radius,
1072
      borderRadius: borderRadius,
1073
      customBorder: customBorder,
1074
      onRemoved: onRemoved,
1075
      textDirection: Directionality.of(context),
1076
    );
1077 1078 1079 1080

    return splash;
  }

1081
  void handleFocusHighlightModeChange(FocusHighlightMode mode) {
1082 1083 1084 1085
    if (!mounted) {
      return;
    }
    setState(() {
1086
      updateFocusHighlights();
1087 1088 1089
    });
  }

1090
  bool get _shouldShowFocus {
1091 1092 1093 1094
    return switch (MediaQuery.maybeNavigationModeOf(context)) {
      NavigationMode.traditional || null => enabled && _hasFocus,
      NavigationMode.directional => _hasFocus,
    };
1095 1096
  }

1097
  void updateFocusHighlights() {
1098 1099 1100 1101
    final bool showFocus = switch (FocusManager.instance.highlightMode) {
      FocusHighlightMode.touch => false,
      FocusHighlightMode.traditional => _shouldShowFocus,
    };
1102
    updateHighlight(_HighlightType.focus, value: showFocus);
1103 1104
  }

1105
  bool _hasFocus = false;
1106
  void handleFocusUpdate(bool hasFocus) {
1107
    _hasFocus = hasFocus;
1108 1109 1110 1111 1112 1113
    // Set here rather than updateHighlight because this widget's
    // (MaterialState) states include MaterialState.focused if
    // the InkWell _has_ the focus, rather than if it's showing
    // the focus per FocusManager.instance.highlightMode.
    statesController.update(MaterialState.focused, hasFocus);
    updateFocusHighlights();
1114
    widget.onFocusChange?.call(hasFocus);
1115 1116
  }

1117
  void handleAnyTapDown(TapDownDetails details) {
1118
    if (_anyChildInkResponsePressed) {
1119
      return;
1120
    }
1121
    _startNewSplash(details: details);
1122 1123 1124 1125
  }

  void handleTapDown(TapDownDetails details) {
    handleAnyTapDown(details);
1126
    widget.onTapDown?.call(details);
1127 1128
  }

1129
  void handleTapUp(TapUpDetails details) {
1130 1131 1132
    widget.onTapUp?.call(details);
  }

1133 1134 1135 1136 1137 1138 1139 1140 1141
  void handleSecondaryTapDown(TapDownDetails details) {
    handleAnyTapDown(details);
    widget.onSecondaryTapDown?.call(details);
  }

  void handleSecondaryTapUp(TapUpDetails details) {
    widget.onSecondaryTapUp?.call(details);
  }

1142
  void _startNewSplash({TapDownDetails? details, BuildContext? context}) {
1143 1144
    assert(details != null || context != null);

1145
    final Offset globalPosition;
1146
    if (context != null) {
1147
      final RenderBox referenceBox = context.findRenderObject()! as RenderBox;
1148 1149 1150
      assert(referenceBox.hasSize, 'InkResponse must be done with layout before starting a splash.');
      globalPosition = referenceBox.localToGlobal(referenceBox.paintBounds.center);
    } else {
1151
      globalPosition = details!.globalPosition;
1152
    }
1153
    statesController.update(MaterialState.pressed, true); // ... before creating the splash
1154
    final InteractiveInkFeature splash = _createSplash(globalPosition);
1155
    _splashes ??= HashSet<InteractiveInkFeature>();
1156
    _splashes!.add(splash);
1157
    _currentSplash?.cancel();
1158
    _currentSplash = splash;
1159
    updateKeepAlive();
1160
    updateHighlight(_HighlightType.pressed, value: true);
1161 1162
  }

1163
  void handleTap() {
1164 1165
    _currentSplash?.confirm();
    _currentSplash = null;
1166
    updateHighlight(_HighlightType.pressed, value: false);
1167
    if (widget.onTap != null) {
1168
      if (widget.enableFeedback) {
1169
        Feedback.forTap(context);
1170
      }
1171
      widget.onTap?.call();
1172
    }
1173 1174
  }

1175
  void handleTapCancel() {
1176 1177
    _currentSplash?.cancel();
    _currentSplash = null;
1178
    widget.onTapCancel?.call();
1179
    updateHighlight(_HighlightType.pressed, value: false);
1180 1181
  }

1182
  void handleDoubleTap() {
1183 1184
    _currentSplash?.confirm();
    _currentSplash = null;
1185
    updateHighlight(_HighlightType.pressed, value: false);
1186
    widget.onDoubleTap?.call();
1187 1188
  }

1189
  void handleLongPress() {
1190 1191
    _currentSplash?.confirm();
    _currentSplash = null;
1192
    if (widget.onLongPress != null) {
1193
      if (widget.enableFeedback) {
1194
        Feedback.forLongPress(context);
1195
      }
1196
      widget.onLongPress!();
1197
    }
1198 1199
  }

1200 1201 1202 1203 1204 1205 1206 1207 1208 1209 1210 1211 1212 1213
  void handleSecondaryTap() {
    _currentSplash?.confirm();
    _currentSplash = null;
    updateHighlight(_HighlightType.pressed, value: false);
    widget.onSecondaryTap?.call();
  }

  void handleSecondaryTapCancel() {
    _currentSplash?.cancel();
    _currentSplash = null;
    widget.onSecondaryTapCancel?.call();
    updateHighlight(_HighlightType.pressed, value: false);
  }

1214
  @override
1215
  void deactivate() {
1216 1217 1218
    if (_splashes != null) {
      final Set<InteractiveInkFeature> splashes = _splashes!;
      _splashes = null;
1219
      for (final InteractiveInkFeature splash in splashes) {
1220
        splash.dispose();
1221
      }
1222 1223 1224 1225 1226 1227 1228 1229
      _currentSplash = null;
    }
    assert(_currentSplash == null);
    for (final _HighlightType highlight in _highlights.keys) {
      _highlights[highlight]?.dispose();
      _highlights[highlight] = null;
    }
    widget.parentState?.markChildInkResponsePressed(this, false);
1230
    super.deactivate();
1231 1232
  }

1233
  bool isWidgetEnabled(_InkResponseStateWidget widget) {
1234 1235 1236 1237
    return _primaryButtonEnabled(widget) || _secondaryButtonEnabled(widget);
  }

  bool _primaryButtonEnabled(_InkResponseStateWidget widget) {
1238 1239 1240 1241
    return widget.onTap != null
      || widget.onDoubleTap != null
      || widget.onLongPress != null
      || widget.onTapUp != null
1242 1243 1244 1245 1246
      || widget.onTapDown != null;
  }

  bool _secondaryButtonEnabled(_InkResponseStateWidget widget) {
    return widget.onSecondaryTap != null
1247 1248
      || widget.onSecondaryTapUp != null
      || widget.onSecondaryTapDown != null;
1249 1250
  }

1251
  bool get enabled => isWidgetEnabled(widget);
1252 1253
  bool get _primaryEnabled => _primaryButtonEnabled(widget);
  bool get _secondaryEnabled => _secondaryButtonEnabled(widget);
1254

1255
  void handleMouseEnter(PointerEnterEvent event) {
1256 1257
    _hovering = true;
    if (enabled) {
1258
      handleHoverChange();
1259 1260
    }
  }
1261

1262
  void handleMouseExit(PointerExitEvent event) {
1263 1264 1265
    _hovering = false;
    // If the exit occurs after we've been disabled, we still
    // want to take down the highlights and run widget.onHover.
1266
    handleHoverChange();
1267 1268
  }

1269
  void handleHoverChange() {
1270 1271 1272
    updateHighlight(_HighlightType.hover, value: _hovering);
  }

1273
  bool get _canRequestFocus {
1274 1275 1276 1277
    return switch (MediaQuery.maybeNavigationModeOf(context)) {
      NavigationMode.traditional || null => enabled && widget.canRequestFocus,
      NavigationMode.directional => true,
    };
1278 1279
  }

1280
  @override
1281
  Widget build(BuildContext context) {
1282
    assert(widget.debugCheckContext(context));
1283
    super.build(context); // See AutomaticKeepAliveClientMixin.
1284 1285 1286 1287 1288 1289 1290

    Color getHighlightColorForType(_HighlightType type) {
      const Set<MaterialState> pressed = <MaterialState>{MaterialState.pressed};
      const Set<MaterialState> focused = <MaterialState>{MaterialState.focused};
      const Set<MaterialState> hovered = <MaterialState>{MaterialState.hovered};

      final ThemeData theme = Theme.of(context);
1291
      return switch (type) {
1292 1293 1294
        // The pressed state triggers a ripple (ink splash), per the current
        // Material Design spec. A separate highlight is no longer used.
        // See https://material.io/design/interaction/states.html#pressed
1295 1296 1297 1298
        _HighlightType.pressed => widget.overlayColor?.resolve(pressed) ?? widget.highlightColor ?? theme.highlightColor,
        _HighlightType.focus   => widget.overlayColor?.resolve(focused) ?? widget.focusColor ?? theme.focusColor,
        _HighlightType.hover   => widget.overlayColor?.resolve(hovered) ?? widget.hoverColor ?? theme.hoverColor,
      };
1299
    }
1300
    for (final _HighlightType type in _highlights.keys) {
1301 1302
      _highlights[type]?.color = getHighlightColorForType(type);
    }
1303

1304
    _currentSplash?.color = widget.overlayColor?.resolve(statesController.value) ?? widget.splashColor ?? Theme.of(context).splashColor;
1305

1306 1307
    final MouseCursor effectiveMouseCursor = MaterialStateProperty.resolveAs<MouseCursor>(
      widget.mouseCursor ?? MaterialStateMouseCursor.clickable,
1308
      statesController.value,
1309
    );
1310

1311 1312 1313 1314 1315 1316
    return _ParentInkResponseProvider(
      state: this,
      child: Actions(
        actions: _actionMap,
        child: Focus(
          focusNode: widget.focusNode,
1317
          canRequestFocus: _canRequestFocus,
1318
          onFocusChange: handleFocusUpdate,
1319 1320
          autofocus: widget.autofocus,
          child: MouseRegion(
1321
            cursor: effectiveMouseCursor,
1322 1323
            onEnter: handleMouseEnter,
            onExit: handleMouseExit,
1324 1325 1326 1327 1328 1329 1330 1331 1332 1333 1334 1335 1336 1337 1338 1339 1340 1341 1342 1343
            child: DefaultSelectionStyle.merge(
              mouseCursor: effectiveMouseCursor,
              child: Semantics(
                onTap: widget.excludeFromSemantics || widget.onTap == null ? null : simulateTap,
                onLongPress: widget.excludeFromSemantics || widget.onLongPress == null ? null : simulateLongPress,
                child: GestureDetector(
                  onTapDown: _primaryEnabled ? handleTapDown : null,
                  onTapUp: _primaryEnabled ? handleTapUp : null,
                  onTap: _primaryEnabled ? handleTap : null,
                  onTapCancel: _primaryEnabled ? handleTapCancel : null,
                  onDoubleTap: widget.onDoubleTap != null ? handleDoubleTap : null,
                  onLongPress: widget.onLongPress != null ? handleLongPress : null,
                  onSecondaryTapDown: _secondaryEnabled ? handleSecondaryTapDown : null,
                  onSecondaryTapUp: _secondaryEnabled ? handleSecondaryTapUp: null,
                  onSecondaryTap: _secondaryEnabled ? handleSecondaryTap : null,
                  onSecondaryTapCancel: _secondaryEnabled ? handleSecondaryTapCancel : null,
                  behavior: HitTestBehavior.opaque,
                  excludeFromSemantics: true,
                  child: widget.child,
                ),
1344
              ),
1345
            ),
1346 1347
          ),
        ),
1348
      ),
1349
    );
1350
  }
1351
}
1352

1353
/// A rectangular area of a [Material] that responds to touch.
1354
///
1355 1356 1357 1358 1359
/// For a variant of this widget that does not clip splashes, see [InkResponse].
///
/// The following diagram shows how an [InkWell] looks when tapped, when using
/// default values.
///
1360
/// ![The highlight is a rectangle the size of the box.](https://flutter.github.io/assets-for-api-docs/assets/material/ink_well.png)
1361
///
1362
/// The [InkWell] widget must have a [Material] widget as an ancestor. The
1363
/// [Material] widget is where the ink reactions are actually painted. This
1364
/// matches the Material Design premise wherein the [Material] is what is
1365 1366
/// actually reacting to touches by spreading ink.
///
1367
/// If a Widget uses this class directly, it should include the following line
1368
/// at the top of its build function to call [debugCheckHasMaterial]:
1369
///
1370 1371 1372 1373
/// ```dart
/// assert(debugCheckHasMaterial(context));
/// ```
///
Ian Hickson's avatar
Ian Hickson committed
1374 1375 1376 1377 1378 1379
/// ## Troubleshooting
///
/// ### The ink splashes aren't visible!
///
/// If there is an opaque graphic, e.g. painted using a [Container], [Image], or
/// [DecoratedBox], between the [Material] widget and the [InkWell] widget, then
1380 1381 1382 1383 1384 1385 1386 1387 1388 1389 1390 1391 1392
/// the splash won't be visible because it will be under the opaque graphic.
/// This is because ink splashes draw on the underlying [Material] itself, as
/// if the ink was spreading inside the material.
///
/// The [Ink] widget can be used as a replacement for [Image], [Container], or
/// [DecoratedBox] to ensure that the image or decoration also paints in the
/// [Material] itself, below the ink.
///
/// If this is not possible for some reason, e.g. because you are using an
/// opaque [CustomPaint] widget, alternatively consider using a second
/// [Material] above the opaque widget but below the [InkWell] (as an
/// ancestor to the ink well). The [MaterialType.transparency] material
/// kind can be used for this purpose.
Ian Hickson's avatar
Ian Hickson committed
1393
///
1394 1395 1396 1397 1398 1399 1400 1401 1402 1403 1404 1405
/// ### InkWell isn't clipping properly
///
/// If you want to clip an InkWell or any [Ink] widgets you need to keep in mind
/// that the [Material] that the Ink will be printed on is responsible for clipping.
/// This means you can't wrap the [Ink] widget in a clipping widget directly,
/// since this will leave the [Material] not clipped (and by extension the printed
/// [Ink] widgets as well).
///
/// An easy solution is to deliberately wrap the [Ink] widgets you want to clip
/// in a [Material], and wrap that in a clipping widget instead. See [Ink] for
/// an example.
///
1406 1407 1408 1409 1410 1411 1412
/// ### The ink splashes don't track the size of an animated container
/// If the size of an InkWell's [Material] ancestor changes while the InkWell's
/// splashes are expanding, you may notice that the splashes aren't clipped
/// correctly. This can't be avoided.
///
/// An example of this situation is as follows:
///
1413
/// {@tool dartpad}
1414 1415 1416
/// Tap the container to cause it to grow. Then, tap it again and hold before
/// the widget reaches its maximum size to observe the clipped ink splash.
///
1417
/// ** See code in examples/api/lib/material/ink_well/ink_well.0.dart **
1418 1419 1420 1421 1422 1423 1424
/// {@end-tool}
///
/// An InkWell's splashes will not properly update to conform to changes if the
/// size of its underlying [Material], where the splashes are rendered, changes
/// during animation. You should avoid using InkWells within [Material] widgets
/// that are changing size.
///
1425 1426 1427
/// See also:
///
///  * [GestureDetector], for listening for gestures without ink splashes.
1428
///  * [ElevatedButton] and [TextButton], two kinds of buttons in Material Design.
1429 1430
///  * [InkResponse], a variant of [InkWell] that doesn't force a rectangular
///    shape on the ink reaction.
1431
class InkWell extends InkResponse {
1432 1433 1434
  /// Creates an ink well.
  ///
  /// Must have an ancestor [Material] widget in which to cause ink reactions.
1435
  const InkWell({
1436 1437 1438 1439 1440 1441 1442 1443
    super.key,
    super.child,
    super.onTap,
    super.onDoubleTap,
    super.onLongPress,
    super.onTapDown,
    super.onTapUp,
    super.onTapCancel,
1444 1445 1446 1447
    super.onSecondaryTap,
    super.onSecondaryTapUp,
    super.onSecondaryTapDown,
    super.onSecondaryTapCancel,
1448 1449 1450 1451 1452 1453 1454 1455 1456 1457 1458 1459
    super.onHighlightChanged,
    super.onHover,
    super.mouseCursor,
    super.focusColor,
    super.hoverColor,
    super.highlightColor,
    super.overlayColor,
    super.splashColor,
    super.splashFactory,
    super.radius,
    super.borderRadius,
    super.customBorder,
1460
    bool? enableFeedback = true,
1461 1462 1463 1464 1465
    super.excludeFromSemantics,
    super.focusNode,
    super.canRequestFocus,
    super.onFocusChange,
    super.autofocus,
1466
    super.statesController,
1467
    super.hoverDuration,
1468
  }) : super(
1469
    containedInkWell: true,
1470
    highlightShape: BoxShape.rectangle,
1471
    enableFeedback: enableFeedback ?? true,
1472
  );
1473
}