| // Copyright 2014 The Flutter Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| import 'dart:collection'; |
| |
| import 'package:flutter/foundation.dart'; |
| import 'package:flutter/gestures.dart'; |
| import 'package:flutter/rendering.dart'; |
| import 'package:flutter/widgets.dart'; |
| |
| import 'debug.dart'; |
| import 'feedback.dart'; |
| import 'ink_highlight.dart'; |
| import 'material.dart'; |
| import 'material_state.dart'; |
| import 'theme.dart'; |
| |
| /// 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 |
| /// that's canceled when the pointer is dragged out of the reference |
| /// box. |
| /// |
| /// The [InkWell] and [InkResponse] widgets generate instances of this |
| /// class. |
| abstract class InteractiveInkFeature extends InkFeature { |
| /// Creates an InteractiveInkFeature. |
| /// |
| /// The [controller] and [referenceBox] arguments must not be null. |
| InteractiveInkFeature({ |
| required MaterialInkController controller, |
| required RenderBox referenceBox, |
| required Color color, |
| VoidCallback? onRemoved, |
| }) : assert(controller != null), |
| assert(referenceBox != null), |
| _color = color, |
| super(controller: controller, referenceBox: referenceBox, onRemoved: onRemoved); |
| |
| /// 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. |
| void confirm() { } |
| |
| /// 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. |
| void cancel() { } |
| |
| /// The ink's color. |
| Color get color => _color; |
| Color _color; |
| set color(Color value) { |
| if (value == _color) |
| return; |
| _color = value; |
| controller.markNeedsPaint(); |
| } |
| |
| /// 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. |
| /// |
| /// Clipping can happen in 3 different ways: |
| /// 1. If [customBorder] is provided, it is used to determine the path |
| /// for clipping. |
| /// 2. If [customBorder] is null, and [borderRadius] is provided, 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({ |
| required Canvas canvas, |
| required Matrix4 transform, |
| required Paint paint, |
| required Offset center, |
| required double radius, |
| TextDirection? textDirection, |
| ShapeBorder? customBorder, |
| BorderRadius borderRadius = BorderRadius.zero, |
| RectCallback? clipCallback, |
| }) { |
| assert(canvas != null); |
| assert(transform != null); |
| assert(paint != null); |
| assert(center != null); |
| assert(radius != null); |
| assert(borderRadius != null); |
| |
| final Offset? originOffset = MatrixUtils.getAsTranslation(transform); |
| 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(); |
| } |
| } |
| |
| /// An encapsulation of an [InteractiveInkFeature] constructor used by |
| /// [InkWell], [InkResponse], and [ThemeData]. |
| /// |
| /// 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 { |
| /// Abstract const constructor. This constructor enables subclasses to provide |
| /// const constructors so that they can be used in const expressions. |
| /// |
| /// Subclasses should provide a const constructor. |
| const InteractiveInkFeatureFactory(); |
| |
| /// The factory method. |
| /// |
| /// Subclasses should override this method to return a new instance of an |
| /// [InteractiveInkFeature]. |
| @factory |
| InteractiveInkFeature create({ |
| required MaterialInkController controller, |
| required RenderBox referenceBox, |
| required Offset position, |
| required Color color, |
| required TextDirection textDirection, |
| bool containedInkWell = false, |
| RectCallback? rectCallback, |
| BorderRadius? borderRadius, |
| ShapeBorder? customBorder, |
| double? radius, |
| VoidCallback? onRemoved, |
| }); |
| } |
| |
| abstract class _ParentInkResponseState { |
| void markChildInkResponsePressed(_ParentInkResponseState childState, bool value); |
| } |
| |
| class _ParentInkResponseProvider extends InheritedWidget { |
| const _ParentInkResponseProvider({ |
| required this.state, |
| required Widget child, |
| }) : super(child: child); |
| |
| final _ParentInkResponseState state; |
| |
| @override |
| bool updateShouldNotify(_ParentInkResponseProvider oldWidget) => state != oldWidget.state; |
| |
| static _ParentInkResponseState? of(BuildContext context) { |
| return context.dependOnInheritedWidgetOfExactType<_ParentInkResponseProvider>()?.state; |
| } |
| } |
| |
| typedef _GetRectCallback = RectCallback? Function(RenderBox referenceBox); |
| typedef _CheckContext = bool Function(BuildContext context); |
| |
| /// An area of a [Material] that responds to touch. Has a configurable shape and |
| /// can be configured to clip splashes that extend outside its bounds or not. |
| /// |
| /// For a variant of this widget that is specialized for rectangular areas that |
| /// always clip splashes, see [InkWell]. |
| /// |
| /// 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. |
| /// |
| /// The following two diagrams show how [InkResponse] looks when tapped if the |
| /// [highlightShape] is [BoxShape.circle] (the default) and [containedInkWell] |
| /// is false (also the default). |
| /// |
| /// The first diagram shows how it looks if the [InkResponse] is relatively |
| /// large: |
| /// |
| /// ![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) |
| /// |
| /// The second diagram shows how it looks if the [InkResponse] is small: |
| /// |
| /// ![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) |
| /// |
| /// The main thing to notice from these diagrams is that the splashes happily |
| /// exceed the bounds of the widget (because [containedInkWell] is false). |
| /// |
| /// 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]. |
| /// |
| /// ![The highlight is a rectangle the size of the box.](https://flutter.github.io/assets-for-api-docs/assets/material/ink_well.png) |
| /// |
| /// The [InkResponse] widget must have a [Material] widget as an ancestor. The |
| /// [Material] widget is where the ink reactions are actually painted. This |
| /// matches the material design premise wherein the [Material] is what is |
| /// actually reacting to touches by spreading ink. |
| /// |
| /// If a Widget uses this class directly, it should include the following line |
| /// at the top of its build function to call [debugCheckHasMaterial]: |
| /// |
| /// ```dart |
| /// assert(debugCheckHasMaterial(context)); |
| /// ``` |
| /// |
| /// ## 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, |
| /// 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. |
| /// |
| /// See also: |
| /// |
| /// * [GestureDetector], for listening for gestures without ink splashes. |
| /// * [ElevatedButton] and [TextButton], two kinds of buttons in material design. |
| /// * [IconButton], which combines [InkResponse] with an [Icon]. |
| class InkResponse extends StatelessWidget { |
| /// Creates an area of a [Material] that responds to touch. |
| /// |
| /// Must have an ancestor [Material] widget in which to cause ink reactions. |
| /// |
| /// The [containedInkWell], [highlightShape], [enableFeedback], |
| /// and [excludeFromSemantics] arguments must not be null. |
| const InkResponse({ |
| Key? key, |
| this.child, |
| this.onTap, |
| this.onTapDown, |
| this.onTapCancel, |
| this.onDoubleTap, |
| this.onLongPress, |
| this.onHighlightChanged, |
| this.onHover, |
| this.mouseCursor, |
| this.containedInkWell = false, |
| this.highlightShape = BoxShape.circle, |
| this.radius, |
| this.borderRadius, |
| this.customBorder, |
| this.focusColor, |
| this.hoverColor, |
| this.highlightColor, |
| this.overlayColor, |
| this.splashColor, |
| this.splashFactory, |
| this.enableFeedback = true, |
| this.excludeFromSemantics = false, |
| this.focusNode, |
| this.canRequestFocus = true, |
| this.onFocusChange, |
| this.autofocus = false, |
| }) : assert(containedInkWell != null), |
| assert(highlightShape != null), |
| assert(enableFeedback != null), |
| assert(excludeFromSemantics != null), |
| assert(autofocus != null), |
| assert(canRequestFocus != null), |
| super(key: key); |
| |
| /// The widget below this widget in the tree. |
| /// |
| /// {@macro flutter.widgets.ProxyWidget.child} |
| final Widget? child; |
| |
| /// Called when the user taps this part of the material. |
| final GestureTapCallback? onTap; |
| |
| /// Called when the user taps down this part of the material. |
| final GestureTapDownCallback? onTapDown; |
| |
| /// Called when the user cancels a tap that was started on this part of the |
| /// material. |
| final GestureTapCallback? onTapCancel; |
| |
| /// Called when the user double taps this part of the material. |
| final GestureTapCallback? onDoubleTap; |
| |
| /// Called when the user long-presses on this part of the material. |
| final GestureLongPressCallback? onLongPress; |
| |
| /// Called when this part of the material either becomes highlighted or stops |
| /// being highlighted. |
| /// |
| /// 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. |
| /// |
| /// 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. |
| final ValueChanged<bool>? onHighlightChanged; |
| |
| /// 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. |
| final ValueChanged<bool>? onHover; |
| |
| /// The cursor for a mouse pointer when it enters or is hovering over the |
| /// widget. |
| /// |
| /// 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. |
| final MouseCursor? mouseCursor; |
| |
| /// Whether this ink response should be clipped its bounds. |
| /// |
| /// 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: |
| /// |
| /// * [highlightShape], the shape of the focus, hover, and pressed |
| /// highlights. |
| /// * [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. |
| final bool containedInkWell; |
| |
| /// The shape (e.g., circle, rectangle) to use for the highlight drawn around |
| /// 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]). |
| /// |
| /// 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. |
| final BoxShape highlightShape; |
| |
| /// The radius of the ink splash. |
| /// |
| /// 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. |
| /// * [splashFactory], which defines the appearance of the splash. |
| final double? radius; |
| |
| /// The clipping radius of the containing rect. This is effective only if |
| /// [customBorder] is null. |
| /// |
| /// If this is null, it is interpreted as [BorderRadius.zero]. |
| final BorderRadius? borderRadius; |
| |
| /// The custom clip border which overrides [borderRadius]. |
| final ShapeBorder? customBorder; |
| |
| /// 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. |
| final Color? focusColor; |
| |
| /// 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. |
| final Color? hoverColor; |
| |
| /// 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. |
| /// |
| /// See also: |
| /// |
| /// * [hoverColor], the color of the hover highlight. |
| /// * [focusColor], the color of the focus highlight. |
| /// * [highlightShape], the shape of the focus, hover, and pressed |
| /// highlights. |
| /// * [splashColor], the color of the splash. |
| /// * [splashFactory], which defines the appearance of the splash. |
| final Color? highlightColor; |
| |
| /// Defines the ink response focus, hover, and splash colors. |
| /// |
| /// This default null property can be used as an alternative to |
| /// [focusColor], [hoverColor], 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. |
| /// |
| /// [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 |
| /// used by the current design guidelines. See |
| /// 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 |
| /// match a component's state: |
| /// <https://material.io/design/interaction/states.html#anatomy>. |
| final MaterialStateProperty<Color?>? overlayColor; |
| |
| /// The splash color of the ink response. If this property is null then the |
| /// splash color of the theme, [ThemeData.splashColor], will be used. |
| /// |
| /// See also: |
| /// |
| /// * [splashFactory], which defines the appearance of the splash. |
| /// * [radius], the (maximum) size of the ink splash. |
| /// * [highlightColor], the color of the highlight. |
| final Color? splashColor; |
| |
| /// 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 |
| /// more aggressively than the default. |
| final InteractiveInkFeatureFactory? splashFactory; |
| |
| /// 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; |
| |
| /// 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; |
| |
| /// Handler called when the focus changes. |
| /// |
| /// Called with true if this widget's node gains focus, and false if it loses |
| /// focus. |
| final ValueChanged<bool>? onFocusChange; |
| |
| /// {@macro flutter.widgets.Focus.autofocus} |
| final bool autofocus; |
| |
| /// {@macro flutter.widgets.Focus.focusNode} |
| final FocusNode? focusNode; |
| |
| /// {@macro flutter.widgets.Focus.canRequestFocus} |
| final bool canRequestFocus; |
| |
| /// 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, |
| /// [TableRowInkWell] implements this method to return the rectangle |
| /// 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). |
| RectCallback? getRectCallback(RenderBox referenceBox) => null; |
| |
| @override |
| Widget build(BuildContext context) { |
| final _ParentInkResponseState? parentState = _ParentInkResponseProvider.of(context); |
| return _InkResponseStateWidget( |
| onTap: onTap, |
| onTapDown: onTapDown, |
| onTapCancel: onTapCancel, |
| onDoubleTap: onDoubleTap, |
| onLongPress: onLongPress, |
| onHighlightChanged: onHighlightChanged, |
| onHover: onHover, |
| mouseCursor: mouseCursor, |
| containedInkWell: containedInkWell, |
| highlightShape: highlightShape, |
| radius: radius, |
| borderRadius: borderRadius, |
| customBorder: customBorder, |
| focusColor: focusColor, |
| hoverColor: hoverColor, |
| highlightColor: highlightColor, |
| overlayColor: overlayColor, |
| splashColor: splashColor, |
| splashFactory: splashFactory, |
| enableFeedback: enableFeedback, |
| excludeFromSemantics: excludeFromSemantics, |
| focusNode: focusNode, |
| canRequestFocus: canRequestFocus, |
| onFocusChange: onFocusChange, |
| autofocus: autofocus, |
| parentState: parentState, |
| getRectCallback: getRectCallback, |
| debugCheckContext: debugCheckContext, |
| child: child, |
| ); |
| } |
| |
| /// 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, |
| /// [TableRowInkWell] implements this method to verify that the widget is |
| /// in a table. |
| @mustCallSuper |
| bool debugCheckContext(BuildContext context) { |
| assert(debugCheckHasMaterial(context)); |
| assert(debugCheckHasDirectionality(context)); |
| return true; |
| } |
| } |
| |
| class _InkResponseStateWidget extends StatefulWidget { |
| const _InkResponseStateWidget({ |
| this.child, |
| this.onTap, |
| this.onTapDown, |
| this.onTapCancel, |
| this.onDoubleTap, |
| this.onLongPress, |
| this.onHighlightChanged, |
| this.onHover, |
| this.mouseCursor, |
| this.containedInkWell = false, |
| this.highlightShape = BoxShape.circle, |
| this.radius, |
| this.borderRadius, |
| this.customBorder, |
| this.focusColor, |
| this.hoverColor, |
| this.highlightColor, |
| this.overlayColor, |
| this.splashColor, |
| this.splashFactory, |
| this.enableFeedback = true, |
| this.excludeFromSemantics = false, |
| this.focusNode, |
| this.canRequestFocus = true, |
| this.onFocusChange, |
| this.autofocus = false, |
| this.parentState, |
| this.getRectCallback, |
| required this.debugCheckContext, |
| }) : assert(containedInkWell != null), |
| assert(highlightShape != null), |
| assert(enableFeedback != null), |
| assert(excludeFromSemantics != null), |
| assert(autofocus != null), |
| assert(canRequestFocus != null); |
| |
| final Widget? child; |
| final GestureTapCallback? onTap; |
| final GestureTapDownCallback? onTapDown; |
| final GestureTapCallback? onTapCancel; |
| final GestureTapCallback? onDoubleTap; |
| final GestureLongPressCallback? onLongPress; |
| final ValueChanged<bool>? onHighlightChanged; |
| final ValueChanged<bool>? onHover; |
| final MouseCursor? mouseCursor; |
| final bool containedInkWell; |
| final BoxShape highlightShape; |
| 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; |
| final bool enableFeedback; |
| final bool excludeFromSemantics; |
| final ValueChanged<bool>? onFocusChange; |
| final bool autofocus; |
| final FocusNode? focusNode; |
| final bool canRequestFocus; |
| final _ParentInkResponseState? parentState; |
| final _GetRectCallback? getRectCallback; |
| final _CheckContext debugCheckContext; |
| |
| @override |
| _InkResponseState createState() => _InkResponseState(); |
| |
| @override |
| void debugFillProperties(DiagnosticPropertiesBuilder properties) { |
| super.debugFillProperties(properties); |
| final List<String> gestures = <String>[ |
| if (onTap != null) 'tap', |
| if (onDoubleTap != null) 'double tap', |
| if (onLongPress != null) 'long press', |
| if (onTapDown != null) 'tap down', |
| if (onTapCancel != null) 'tap cancel', |
| ]; |
| properties.add(IterableProperty<String>('gestures', gestures, ifEmpty: '<none>')); |
| properties.add(DiagnosticsProperty<MouseCursor>('mouseCursor', mouseCursor)); |
| properties.add(DiagnosticsProperty<bool>('containedInkWell', containedInkWell, level: DiagnosticLevel.fine)); |
| properties.add(DiagnosticsProperty<BoxShape>( |
| 'highlightShape', |
| highlightShape, |
| description: '${containedInkWell ? "clipped to " : ""}$highlightShape', |
| showName: false, |
| )); |
| } |
| } |
| |
| /// Used to index the allocated highlights for the different types of highlights |
| /// in [_InkResponseState]. |
| enum _HighlightType { |
| pressed, |
| hover, |
| focus, |
| } |
| |
| class _InkResponseState extends State<_InkResponseStateWidget> |
| with AutomaticKeepAliveClientMixin<_InkResponseStateWidget> |
| implements _ParentInkResponseState { |
| Set<InteractiveInkFeature>? _splashes; |
| InteractiveInkFeature? _currentSplash; |
| bool _hovering = false; |
| final Map<_HighlightType, InkHighlight?> _highlights = <_HighlightType, InkHighlight?>{}; |
| late final Map<Type, Action<Intent>> _actionMap = <Type, Action<Intent>>{ |
| ActivateIntent: CallbackAction<ActivateIntent>(onInvoke: _simulateTap), |
| ButtonActivateIntent: CallbackAction<ButtonActivateIntent>(onInvoke: _simulateTap), |
| }; |
| |
| bool get highlightsExist => _highlights.values.where((InkHighlight? highlight) => highlight != null).isNotEmpty; |
| |
| final ObserverList<_ParentInkResponseState> _activeChildren = ObserverList<_ParentInkResponseState>(); |
| |
| @override |
| void markChildInkResponsePressed(_ParentInkResponseState childState, bool value) { |
| assert(childState != null); |
| 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; |
| |
| void _simulateTap([Intent? intent]) { |
| _startSplash(context: context); |
| _handleTap(); |
| } |
| |
| void _simulateLongPress() { |
| _startSplash(context: context); |
| _handleLongPress(); |
| } |
| |
| @override |
| void initState() { |
| super.initState(); |
| FocusManager.instance.addHighlightModeListener(_handleFocusHighlightModeChange); |
| } |
| |
| @override |
| void didUpdateWidget(_InkResponseStateWidget oldWidget) { |
| super.didUpdateWidget(oldWidget); |
| if (_isWidgetEnabled(widget) != _isWidgetEnabled(oldWidget)) { |
| if (enabled) { |
| // 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(); |
| } |
| } |
| |
| @override |
| void dispose() { |
| FocusManager.instance.removeHighlightModeListener(_handleFocusHighlightModeChange); |
| super.dispose(); |
| } |
| |
| @override |
| bool get wantKeepAlive => highlightsExist || (_splashes != null && _splashes!.isNotEmpty); |
| |
| Color getHighlightColorForType(_HighlightType type) { |
| const Set<MaterialState> focused = <MaterialState>{MaterialState.focused}; |
| const Set<MaterialState> hovered = <MaterialState>{MaterialState.hovered}; |
| |
| switch (type) { |
| // 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 |
| case _HighlightType.pressed: |
| return widget.highlightColor ?? Theme.of(context).highlightColor; |
| case _HighlightType.focus: |
| return widget.overlayColor?.resolve(focused) ?? widget.focusColor ?? Theme.of(context).focusColor; |
| case _HighlightType.hover: |
| return widget.overlayColor?.resolve(hovered) ?? widget.hoverColor ?? Theme.of(context).hoverColor; |
| } |
| } |
| |
| Duration getFadeDurationForType(_HighlightType type) { |
| switch (type) { |
| case _HighlightType.pressed: |
| return const Duration(milliseconds: 200); |
| case _HighlightType.hover: |
| case _HighlightType.focus: |
| return const Duration(milliseconds: 50); |
| } |
| } |
| |
| void updateHighlight(_HighlightType type, { required bool value, bool callOnHover = true }) { |
| final InkHighlight? highlight = _highlights[type]; |
| void handleInkRemoval() { |
| assert(_highlights[type] != null); |
| _highlights[type] = null; |
| updateKeepAlive(); |
| } |
| |
| if (type == _HighlightType.pressed) { |
| widget.parentState?.markChildInkResponsePressed(this, value); |
| } |
| if (value == (highlight != null && highlight.active)) |
| return; |
| if (value) { |
| if (highlight == null) { |
| final RenderBox referenceBox = context.findRenderObject()! as RenderBox; |
| _highlights[type] = InkHighlight( |
| controller: Material.of(context)!, |
| referenceBox: referenceBox, |
| color: getHighlightColorForType(type), |
| shape: widget.highlightShape, |
| radius: widget.radius, |
| borderRadius: widget.borderRadius, |
| customBorder: widget.customBorder, |
| rectCallback: widget.getRectCallback!(referenceBox), |
| onRemoved: handleInkRemoval, |
| textDirection: Directionality.of(context), |
| fadeDuration: getFadeDurationForType(type), |
| ); |
| updateKeepAlive(); |
| } else { |
| highlight.activate(); |
| } |
| } else { |
| highlight!.deactivate(); |
| } |
| assert(value == (_highlights[type] != null && _highlights[type]!.active)); |
| |
| switch (type) { |
| case _HighlightType.pressed: |
| widget.onHighlightChanged?.call(value); |
| break; |
| case _HighlightType.hover: |
| if (callOnHover) |
| widget.onHover?.call(value); |
| break; |
| case _HighlightType.focus: |
| break; |
| } |
| } |
| |
| InteractiveInkFeature _createInkFeature(Offset globalPosition) { |
| final MaterialInkController inkController = Material.of(context)!; |
| final RenderBox referenceBox = context.findRenderObject()! as RenderBox; |
| final Offset position = referenceBox.globalToLocal(globalPosition); |
| const Set<MaterialState> pressed = <MaterialState>{MaterialState.pressed}; |
| final Color color = widget.overlayColor?.resolve(pressed) ?? widget.splashColor ?? Theme.of(context).splashColor; |
| final RectCallback? rectCallback = widget.containedInkWell ? widget.getRectCallback!(referenceBox) : null; |
| final BorderRadius? borderRadius = widget.borderRadius; |
| final ShapeBorder? customBorder = widget.customBorder; |
| |
| InteractiveInkFeature? splash; |
| void onRemoved() { |
| if (_splashes != null) { |
| assert(_splashes!.contains(splash)); |
| _splashes!.remove(splash); |
| if (_currentSplash == splash) |
| _currentSplash = null; |
| updateKeepAlive(); |
| } // else we're probably in deactivate() |
| } |
| |
| splash = (widget.splashFactory ?? Theme.of(context).splashFactory).create( |
| controller: inkController, |
| referenceBox: referenceBox, |
| position: position, |
| color: color, |
| containedInkWell: widget.containedInkWell, |
| rectCallback: rectCallback, |
| radius: widget.radius, |
| borderRadius: borderRadius, |
| customBorder: customBorder, |
| onRemoved: onRemoved, |
| textDirection: Directionality.of(context), |
| ); |
| |
| return splash; |
| } |
| |
| void _handleFocusHighlightModeChange(FocusHighlightMode mode) { |
| if (!mounted) { |
| return; |
| } |
| setState(() { |
| _updateFocusHighlights(); |
| }); |
| } |
| |
| bool get _shouldShowFocus { |
| final NavigationMode mode = MediaQuery.maybeOf(context)?.navigationMode ?? NavigationMode.traditional; |
| switch (mode) { |
| case NavigationMode.traditional: |
| return enabled && _hasFocus; |
| case NavigationMode.directional: |
| return _hasFocus; |
| } |
| } |
| |
| void _updateFocusHighlights() { |
| final bool showFocus; |
| switch (FocusManager.instance.highlightMode) { |
| case FocusHighlightMode.touch: |
| showFocus = false; |
| break; |
| case FocusHighlightMode.traditional: |
| showFocus = _shouldShowFocus; |
| break; |
| } |
| updateHighlight(_HighlightType.focus, value: showFocus); |
| } |
| |
| bool _hasFocus = false; |
| void _handleFocusUpdate(bool hasFocus) { |
| _hasFocus = hasFocus; |
| _updateFocusHighlights(); |
| widget.onFocusChange?.call(hasFocus); |
| } |
| |
| void _handleTapDown(TapDownDetails details) { |
| if (_anyChildInkResponsePressed) |
| return; |
| _startSplash(details: details); |
| widget.onTapDown?.call(details); |
| } |
| |
| void _startSplash({TapDownDetails? details, BuildContext? context}) { |
| assert(details != null || context != null); |
| |
| final Offset globalPosition; |
| if (context != null) { |
| final RenderBox referenceBox = context.findRenderObject()! as RenderBox; |
| assert(referenceBox.hasSize, 'InkResponse must be done with layout before starting a splash.'); |
| globalPosition = referenceBox.localToGlobal(referenceBox.paintBounds.center); |
| } else { |
| globalPosition = details!.globalPosition; |
| } |
| final InteractiveInkFeature splash = _createInkFeature(globalPosition); |
| _splashes ??= HashSet<InteractiveInkFeature>(); |
| _splashes!.add(splash); |
| _currentSplash = splash; |
| updateKeepAlive(); |
| updateHighlight(_HighlightType.pressed, value: true); |
| } |
| |
| void _handleTap() { |
| _currentSplash?.confirm(); |
| _currentSplash = null; |
| updateHighlight(_HighlightType.pressed, value: false); |
| if (widget.onTap != null) { |
| if (widget.enableFeedback) |
| Feedback.forTap(context); |
| widget.onTap?.call(); |
| } |
| } |
| |
| void _handleTapCancel() { |
| _currentSplash?.cancel(); |
| _currentSplash = null; |
| widget.onTapCancel?.call(); |
| updateHighlight(_HighlightType.pressed, value: false); |
| } |
| |
| void _handleDoubleTap() { |
| _currentSplash?.confirm(); |
| _currentSplash = null; |
| widget.onDoubleTap?.call(); |
| } |
| |
| void _handleLongPress() { |
| _currentSplash?.confirm(); |
| _currentSplash = null; |
| if (widget.onLongPress != null) { |
| if (widget.enableFeedback) |
| Feedback.forLongPress(context); |
| widget.onLongPress!(); |
| } |
| } |
| |
| @override |
| void deactivate() { |
| if (_splashes != null) { |
| final Set<InteractiveInkFeature> splashes = _splashes!; |
| _splashes = null; |
| for (final InteractiveInkFeature splash in splashes) |
| splash.dispose(); |
| _currentSplash = null; |
| } |
| assert(_currentSplash == null); |
| for (final _HighlightType highlight in _highlights.keys) { |
| _highlights[highlight]?.dispose(); |
| _highlights[highlight] = null; |
| } |
| widget.parentState?.markChildInkResponsePressed(this, false); |
| super.deactivate(); |
| } |
| |
| bool _isWidgetEnabled(_InkResponseStateWidget widget) { |
| return widget.onTap != null || widget.onDoubleTap != null || widget.onLongPress != null; |
| } |
| |
| bool get enabled => _isWidgetEnabled(widget); |
| |
| void _handleMouseEnter(PointerEnterEvent event) { |
| _hovering = true; |
| if (enabled) { |
| _handleHoverChange(); |
| } |
| } |
| |
| void _handleMouseExit(PointerExitEvent event) { |
| _hovering = false; |
| // If the exit occurs after we've been disabled, we still |
| // want to take down the highlights and run widget.onHover. |
| _handleHoverChange(); |
| } |
| |
| void _handleHoverChange() { |
| updateHighlight(_HighlightType.hover, value: _hovering); |
| } |
| |
| bool get _canRequestFocus { |
| final NavigationMode mode = MediaQuery.maybeOf(context)?.navigationMode ?? NavigationMode.traditional; |
| switch (mode) { |
| case NavigationMode.traditional: |
| return enabled && widget.canRequestFocus; |
| case NavigationMode.directional: |
| return true; |
| } |
| } |
| |
| @override |
| Widget build(BuildContext context) { |
| assert(widget.debugCheckContext(context)); |
| super.build(context); // See AutomaticKeepAliveClientMixin. |
| for (final _HighlightType type in _highlights.keys) { |
| _highlights[type]?.color = getHighlightColorForType(type); |
| } |
| |
| const Set<MaterialState> pressed = <MaterialState>{MaterialState.pressed}; |
| _currentSplash?.color = widget.overlayColor?.resolve(pressed) ?? widget.splashColor ?? Theme.of(context).splashColor; |
| |
| final MouseCursor effectiveMouseCursor = MaterialStateProperty.resolveAs<MouseCursor>( |
| widget.mouseCursor ?? MaterialStateMouseCursor.clickable, |
| <MaterialState>{ |
| if (!enabled) MaterialState.disabled, |
| if (_hovering && enabled) MaterialState.hovered, |
| if (_hasFocus) MaterialState.focused, |
| }, |
| ); |
| return _ParentInkResponseProvider( |
| state: this, |
| child: Actions( |
| actions: _actionMap, |
| child: Focus( |
| focusNode: widget.focusNode, |
| canRequestFocus: _canRequestFocus, |
| onFocusChange: _handleFocusUpdate, |
| autofocus: widget.autofocus, |
| child: MouseRegion( |
| cursor: effectiveMouseCursor, |
| onEnter: _handleMouseEnter, |
| onExit: _handleMouseExit, |
| child: Semantics( |
| onTap: widget.excludeFromSemantics || widget.onTap == null ? null : _simulateTap, |
| onLongPress: widget.excludeFromSemantics || widget.onLongPress == null ? null : _simulateLongPress, |
| child: GestureDetector( |
| onTapDown: enabled ? _handleTapDown : null, |
| onTap: enabled ? _handleTap : null, |
| onTapCancel: enabled ? _handleTapCancel : null, |
| onDoubleTap: widget.onDoubleTap != null ? _handleDoubleTap : null, |
| onLongPress: widget.onLongPress != null ? _handleLongPress : null, |
| behavior: HitTestBehavior.opaque, |
| excludeFromSemantics: true, |
| child: widget.child, |
| ), |
| ), |
| ), |
| ), |
| ), |
| ); |
| } |
| } |
| |
| /// A rectangular area of a [Material] that responds to touch. |
| /// |
| /// 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. |
| /// |
| /// ![The highlight is a rectangle the size of the box.](https://flutter.github.io/assets-for-api-docs/assets/material/ink_well.png) |
| /// |
| /// The [InkWell] widget must have a [Material] widget as an ancestor. The |
| /// [Material] widget is where the ink reactions are actually painted. This |
| /// matches the material design premise wherein the [Material] is what is |
| /// actually reacting to touches by spreading ink. |
| /// |
| /// If a Widget uses this class directly, it should include the following line |
| /// at the top of its build function to call [debugCheckHasMaterial]: |
| /// |
| /// ```dart |
| /// assert(debugCheckHasMaterial(context)); |
| /// ``` |
| /// |
| /// ## 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 |
| /// 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. |
| /// |
| /// ### 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: |
| /// |
| /// {@tool dartpad} |
| /// 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. |
| /// |
| /// ** See code in examples/api/lib/material/ink_well/ink_well.0.dart ** |
| /// {@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. |
| /// |
| /// See also: |
| /// |
| /// * [GestureDetector], for listening for gestures without ink splashes. |
| /// * [ElevatedButton] and [TextButton], two kinds of buttons in material design. |
| /// * [InkResponse], a variant of [InkWell] that doesn't force a rectangular |
| /// shape on the ink reaction. |
| class InkWell extends InkResponse { |
| /// Creates an ink well. |
| /// |
| /// Must have an ancestor [Material] widget in which to cause ink reactions. |
| /// |
| /// The [enableFeedback], and [excludeFromSemantics] arguments |
| /// must not be null. |
| const InkWell({ |
| Key? key, |
| Widget? child, |
| GestureTapCallback? onTap, |
| GestureTapCallback? onDoubleTap, |
| GestureLongPressCallback? onLongPress, |
| GestureTapDownCallback? onTapDown, |
| GestureTapCancelCallback? onTapCancel, |
| ValueChanged<bool>? onHighlightChanged, |
| ValueChanged<bool>? onHover, |
| MouseCursor? mouseCursor, |
| Color? focusColor, |
| Color? hoverColor, |
| Color? highlightColor, |
| MaterialStateProperty<Color?>? overlayColor, |
| Color? splashColor, |
| InteractiveInkFeatureFactory? splashFactory, |
| double? radius, |
| BorderRadius? borderRadius, |
| ShapeBorder? customBorder, |
| bool? enableFeedback = true, |
| bool excludeFromSemantics = false, |
| FocusNode? focusNode, |
| bool canRequestFocus = true, |
| ValueChanged<bool>? onFocusChange, |
| bool autofocus = false, |
| }) : super( |
| key: key, |
| child: child, |
| onTap: onTap, |
| onDoubleTap: onDoubleTap, |
| onLongPress: onLongPress, |
| onTapDown: onTapDown, |
| onTapCancel: onTapCancel, |
| onHighlightChanged: onHighlightChanged, |
| onHover: onHover, |
| mouseCursor: mouseCursor, |
| containedInkWell: true, |
| highlightShape: BoxShape.rectangle, |
| focusColor: focusColor, |
| hoverColor: hoverColor, |
| highlightColor: highlightColor, |
| overlayColor: overlayColor, |
| splashColor: splashColor, |
| splashFactory: splashFactory, |
| radius: radius, |
| borderRadius: borderRadius, |
| customBorder: customBorder, |
| enableFeedback: enableFeedback ?? true, |
| excludeFromSemantics: excludeFromSemantics, |
| focusNode: focusNode, |
| canRequestFocus: canRequestFocus, |
| onFocusChange: onFocusChange, |
| autofocus: autofocus, |
| ); |
| } |