blob: 05155c7da8f89a477c0fa3093e3a33ad19ebf0e2 [file] [log] [blame]
// 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,
bool containedInkWell: false,
RectCallback rectCallback,
BorderRadius borderRadius,
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 highlight is a disc centered in the box, smaller than the child widget.](https://flutter.github.io/assets-for-api-docs/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/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/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.
/// * [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.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.
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.
///
/// If this is null, it is interpreted as [BorderRadius.zero].
final BorderRadius borderRadius;
/// 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));
return true;
}
@override
_InkResponseState<InkResponse> createState() => new _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');
properties.add(new IterableProperty<String>('gestures', gestures, ifEmpty: '<none>'));
properties.add(new DiagnosticsProperty<bool>('containedInkWell', containedInkWell, level: DiagnosticLevel.fine));
properties.add(new DiagnosticsProperty<BoxShape>(
'highlightShape',
highlightShape,
description: '${containedInkWell ? "clipped to " : ""}$highlightShape',
showName: false,
));
}
}
class _InkResponseState<T extends InkResponse> extends State<T> with AutomaticKeepAliveClientMixin {
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 = new InkHighlight(
controller: Material.of(context),
referenceBox: referenceBox,
color: widget.highlightColor ?? Theme.of(context).highlightColor,
shape: widget.highlightShape,
borderRadius: widget.borderRadius,
rectCallback: widget.getRectCallback(referenceBox),
onRemoved: _handleInkHighlightRemoval,
);
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;
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,
onRemoved: onRemoved,
);
return splash;
}
void _handleTapDown(TapDownDetails details) {
final InteractiveInkFeature splash = _createInkFeature(details);
_splashes ??= new 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 new 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 highlight is a rectangle the size of the box.](https://flutter.github.io/assets-for-api-docs/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.
///
/// 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,
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,
enableFeedback: enableFeedback,
excludeFromSemantics: excludeFromSemantics,
);
}