// Copyright 2015 The Chromium 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 '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 cancelled 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, 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(); } } /// 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 { /// Subclasses should provide a const constructor. const InteractiveInkFeatureFactory(); /// The factory method. /// /// Subclasses should override this method to return a new instance of an /// [InteractiveInkFeature]. 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, }); } /// 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 second diagram shows how it looks if the [InkResponse] is small: /// ///  /// /// 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 [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. /// * [RaisedButton] and [FlatButton], two kinds of buttons in material design. /// * [IconButton], which combines [InkResponse] with an [Icon]. class InkResponse extends StatefulWidget { /// 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.containedInkWell = false, this.highlightShape = BoxShape.circle, this.radius, this.borderRadius, this.customBorder, this.highlightColor, this.splashColor, this.splashFactory, this.enableFeedback = true, this.excludeFromSemantics = false, }) : assert(containedInkWell != null), assert(highlightShape != null), assert(enableFeedback != null), assert(excludeFromSemantics != null), super(key: key); /// The widget below this widget in the tree. /// /// {@macro flutter.widgets.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; /// 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], which determines the shape of the highlight. /// * [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. /// /// 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 highlight color of the ink response. If this property is null then the /// highlight color of the theme, [ThemeData.highlightColor], will be used. /// /// See also: /// /// * [highlightShape], the shape of the highlight. /// * [splashColor], the color of the splash. /// * [splashFactory], which defines the appearance of the splash. final Color highlightColor; /// 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; /// 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; /// 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; } @override _InkResponseState<InkResponse> createState() => _InkResponseState<InkResponse>(); @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); final List<String> gestures = <String>[]; if (onTap != null) gestures.add('tap'); if (onDoubleTap != null) gestures.add('double tap'); if (onLongPress != null) gestures.add('long press'); if (onTapDown != null) gestures.add('tap down'); if (onTapCancel != null) gestures.add('tap cancel'); properties.add(IterableProperty<String>('gestures', gestures, ifEmpty: '<none>')); properties.add(DiagnosticsProperty<bool>('containedInkWell', containedInkWell, level: DiagnosticLevel.fine)); properties.add(DiagnosticsProperty<BoxShape>( 'highlightShape', highlightShape, description: '${containedInkWell ? "clipped to " : ""}$highlightShape', showName: false, )); } } class _InkResponseState<T extends InkResponse> extends State<T> with AutomaticKeepAliveClientMixin<T> { Set<InteractiveInkFeature> _splashes; InteractiveInkFeature _currentSplash; InkHighlight _lastHighlight; @override bool get wantKeepAlive => _lastHighlight != null || (_splashes != null && _splashes.isNotEmpty); void updateHighlight(bool value) { if (value == (_lastHighlight != null && _lastHighlight.active)) return; if (value) { if (_lastHighlight == null) { final RenderBox referenceBox = context.findRenderObject(); _lastHighlight = InkHighlight( controller: Material.of(context), referenceBox: referenceBox, color: widget.highlightColor ?? Theme.of(context).highlightColor, shape: widget.highlightShape, borderRadius: widget.borderRadius, customBorder: widget.customBorder, rectCallback: widget.getRectCallback(referenceBox), onRemoved: _handleInkHighlightRemoval, textDirection: Directionality.of(context), ); updateKeepAlive(); } else { _lastHighlight.activate(); } } else { _lastHighlight.deactivate(); } assert(value == (_lastHighlight != null && _lastHighlight.active)); if (widget.onHighlightChanged != null) widget.onHighlightChanged(value); } void _handleInkHighlightRemoval() { assert(_lastHighlight != null); _lastHighlight = null; updateKeepAlive(); } InteractiveInkFeature _createInkFeature(TapDownDetails details) { final MaterialInkController inkController = Material.of(context); final RenderBox referenceBox = context.findRenderObject(); final Offset position = referenceBox.globalToLocal(details.globalPosition); final Color color = 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 _handleTapDown(TapDownDetails details) { final InteractiveInkFeature splash = _createInkFeature(details); _splashes ??= HashSet<InteractiveInkFeature>(); _splashes.add(splash); _currentSplash = splash; if (widget.onTapDown != null) { widget.onTapDown(details); } updateKeepAlive(); updateHighlight(true); } void _handleTap(BuildContext context) { _currentSplash?.confirm(); _currentSplash = null; updateHighlight(false); if (widget.onTap != null) { if (widget.enableFeedback) Feedback.forTap(context); widget.onTap(); } } void _handleTapCancel() { _currentSplash?.cancel(); _currentSplash = null; if (widget.onTapCancel != null) { widget.onTapCancel(); } updateHighlight(false); } void _handleDoubleTap() { _currentSplash?.confirm(); _currentSplash = null; if (widget.onDoubleTap != null) widget.onDoubleTap(); } void _handleLongPress(BuildContext context) { _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 (InteractiveInkFeature splash in splashes) splash.dispose(); _currentSplash = null; } assert(_currentSplash == null); _lastHighlight?.dispose(); _lastHighlight = null; super.deactivate(); } @override Widget build(BuildContext context) { assert(widget.debugCheckContext(context)); super.build(context); // See AutomaticKeepAliveClientMixin. final ThemeData themeData = Theme.of(context); _lastHighlight?.color = widget.highlightColor ?? themeData.highlightColor; _currentSplash?.color = widget.splashColor ?? themeData.splashColor; final bool enabled = widget.onTap != null || widget.onDoubleTap != null || widget.onLongPress != null; return GestureDetector( onTapDown: enabled ? _handleTapDown : null, onTap: enabled ? () => _handleTap(context) : null, onTapCancel: enabled ? _handleTapCancel : null, onDoubleTap: widget.onDoubleTap != null ? _handleDoubleTap : null, onLongPress: widget.onLongPress != null ? () => _handleLongPress(context) : null, behavior: HitTestBehavior.opaque, child: widget.child, excludeFromSemantics: widget.excludeFromSemantics, ); } } /// 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 [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. /// /// See also: /// /// * [GestureDetector], for listening for gestures without ink splashes. /// * [RaisedButton] and [FlatButton], 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, Color highlightColor, Color splashColor, InteractiveInkFeatureFactory splashFactory, double radius, BorderRadius borderRadius, ShapeBorder customBorder, bool enableFeedback = true, bool excludeFromSemantics = false, }) : super( key: key, child: child, onTap: onTap, onDoubleTap: onDoubleTap, onLongPress: onLongPress, onTapDown: onTapDown, onTapCancel: onTapCancel, onHighlightChanged: onHighlightChanged, containedInkWell: true, highlightShape: BoxShape.rectangle, highlightColor: highlightColor, splashColor: splashColor, splashFactory: splashFactory, radius: radius, borderRadius: borderRadius, customBorder: customBorder, enableFeedback: enableFeedback, excludeFromSemantics: excludeFromSemantics, ); }