blob: eef7bb77c066dc81f519b24d6a9bcfccf12bf888 [file] [log] [blame] [edit]
// 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 'package:flutter/foundation.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
import 'constants.dart';
import 'material_state.dart';
// Duration of the animation that moves the toggle from one state to another.
const Duration _kToggleDuration = Duration(milliseconds: 200);
// Duration of the fade animation for the reaction when focus and hover occur.
const Duration _kReactionFadeDuration = Duration(milliseconds: 50);
/// A mixin for [StatefulWidget]s that implement material-themed toggleable
/// controls with toggle animations (e.g. [Switch]es, [Checkbox]es, and
/// [Radio]s).
///
/// The mixin implements the logic for toggling the control (e.g. when tapped)
/// and provides a series of animation controllers to transition the control
/// from one state to another. It does not have any opinion about the visual
/// representation of the toggleable widget. The visuals are defined by a
/// [CustomPainter] passed to the [buildToggleable]. [State] objects using this
/// mixin should call that method from their [build] method.
///
/// This mixin is used to implement the material components for [Switch],
/// [Checkbox], and [Radio] controls.
@optionalTypeArgs
mixin ToggleableStateMixin<S extends StatefulWidget> on TickerProviderStateMixin<S> {
/// Used by subclasses to manipulate the visual value of the control.
///
/// Some controls respond to user input by updating their visual value. For
/// example, the thumb of a switch moves from one position to another when
/// dragged. These controls manipulate this animation controller to update
/// their [position] and eventually trigger an [onChanged] callback when the
/// animation reaches either 0.0 or 1.0.
AnimationController get positionController => _positionController;
late AnimationController _positionController;
/// The visual value of the control.
///
/// When the control is inactive, the [value] is false and this animation has
/// the value 0.0. When the control is active, the value is either true or
/// tristate is true and the value is null. When the control is active the
/// animation has a value of 1.0. When the control is changing from inactive
/// to active (or vice versa), [value] is the target value and this animation
/// gradually updates from 0.0 to 1.0 (or vice versa).
CurvedAnimation get position => _position;
late CurvedAnimation _position;
/// Used by subclasses to control the radial reaction animation.
///
/// Some controls have a radial ink reaction to user input. This animation
/// controller can be used to start or stop these ink reactions.
///
/// To paint the actual radial reaction, [ToggleablePainter.paintRadialReaction]
/// may be used.
AnimationController get reactionController => _reactionController;
late AnimationController _reactionController;
/// The visual value of the radial reaction animation.
///
/// Some controls have a radial ink reaction to user input. This animation
/// controls the progress of these ink reactions.
///
/// To paint the actual radial reaction, [ToggleablePainter.paintRadialReaction]
/// may be used.
Animation<double> get reaction => _reaction;
late Animation<double> _reaction;
/// Controls the radial reaction's opacity animation for hover changes.
///
/// Some controls have a radial ink reaction to pointer hover. This animation
/// controls these ink reaction fade-ins and
/// fade-outs.
///
/// To paint the actual radial reaction, [ToggleablePainter.paintRadialReaction]
/// may be used.
Animation<double> get reactionHoverFade => _reactionHoverFade;
late Animation<double> _reactionHoverFade;
late AnimationController _reactionHoverFadeController;
/// Controls the radial reaction's opacity animation for focus changes.
///
/// Some controls have a radial ink reaction to focus. This animation
/// controls these ink reaction fade-ins and fade-outs.
///
/// To paint the actual radial reaction, [ToggleablePainter.paintRadialReaction]
/// may be used.
Animation<double> get reactionFocusFade => _reactionFocusFade;
late Animation<double> _reactionFocusFade;
late AnimationController _reactionFocusFadeController;
/// Whether [value] of this control can be changed by user interaction.
///
/// The control is considered interactive if the [onChanged] callback is
/// non-null. If the callback is null, then the control is disabled, and
/// non-interactive. A disabled checkbox, for example, is displayed using a
/// grey color and its value cannot be changed.
bool get isInteractive => onChanged != null;
late final Map<Type, Action<Intent>> _actionMap = <Type, Action<Intent>>{
ActivateIntent: CallbackAction<ActivateIntent>(onInvoke: _handleTap),
};
/// Called when the control changes value.
///
/// If the control is tapped, [onChanged] is called immediately with the new
/// value.
///
/// The control is considered interactive (see [isInteractive]) if this
/// callback is non-null. If the callback is null, then the control is
/// disabled, and non-interactive. A disabled checkbox, for example, is
/// displayed using a grey color and its value cannot be changed.
ValueChanged<bool?>? get onChanged;
/// False if this control is "inactive" (not checked, off, or unselected).
///
/// If value is true then the control "active" (checked, on, or selected). If
/// tristate is true and value is null, then the control is considered to be
/// in its third or "indeterminate" state.
///
/// When the value changes, this object starts the [positionController] and
/// [position] animations to animate the visual appearance of the control to
/// the new value.
bool? get value;
/// If true, [value] can be true, false, or null, otherwise [value] must
/// be true or false.
///
/// When [tristate] is true and [value] is null, then the control is
/// considered to be in its third or "indeterminate" state.
bool get tristate;
@override
void initState() {
super.initState();
_positionController = AnimationController(
duration: _kToggleDuration,
value: value == false ? 0.0 : 1.0,
vsync: this,
);
_position = CurvedAnimation(
parent: _positionController,
curve: Curves.easeIn,
reverseCurve: Curves.easeOut,
);
_reactionController = AnimationController(
duration: kRadialReactionDuration,
vsync: this,
);
_reaction = CurvedAnimation(
parent: _reactionController,
curve: Curves.fastOutSlowIn,
);
_reactionHoverFadeController = AnimationController(
duration: _kReactionFadeDuration,
value: _hovering || _focused ? 1.0 : 0.0,
vsync: this,
);
_reactionHoverFade = CurvedAnimation(
parent: _reactionHoverFadeController,
curve: Curves.fastOutSlowIn,
);
_reactionFocusFadeController = AnimationController(
duration: _kReactionFadeDuration,
value: _hovering || _focused ? 1.0 : 0.0,
vsync: this,
);
_reactionFocusFade = CurvedAnimation(
parent: _reactionFocusFadeController,
curve: Curves.fastOutSlowIn,
);
}
/// Runs the [position] animation to transition the Toggleable's appearance
/// to match [value].
///
/// This method must be called whenever [value] changes to ensure that the
/// visual representation of the Toggleable matches the current [value].
void animateToValue() {
if (tristate) {
if (value == null) {
_positionController.value = 0.0;
}
if (value ?? true) {
_positionController.forward();
} else {
_positionController.reverse();
}
} else {
if (value ?? false) {
_positionController.forward();
} else {
_positionController.reverse();
}
}
}
@override
void dispose() {
_positionController.dispose();
_reactionController.dispose();
_reactionHoverFadeController.dispose();
_reactionFocusFadeController.dispose();
super.dispose();
}
/// The most recent [Offset] at which a pointer touched the Toggleable.
///
/// This is null if currently no pointer is touching the Toggleable or if
/// [isInteractive] is false.
Offset? get downPosition => _downPosition;
Offset? _downPosition;
void _handleTapDown(TapDownDetails details) {
if (isInteractive) {
setState(() {
_downPosition = details.localPosition;
});
_reactionController.forward();
}
}
void _handleTap([Intent? _]) {
if (!isInteractive) {
return;
}
switch (value) {
case false:
onChanged!(true);
break;
case true:
onChanged!(tristate ? null : false);
break;
case null:
onChanged!(false);
break;
}
context.findRenderObject()!.sendSemanticsEvent(const TapSemanticEvent());
}
void _handleTapEnd([TapUpDetails? _]) {
if (_downPosition != null) {
setState(() { _downPosition = null; });
}
_reactionController.reverse();
}
bool _focused = false;
void _handleFocusHighlightChanged(bool focused) {
if (focused != _focused) {
setState(() { _focused = focused; });
if (focused) {
_reactionFocusFadeController.forward();
} else {
_reactionFocusFadeController.reverse();
}
}
}
bool _hovering = false;
void _handleHoverChanged(bool hovering) {
if (hovering != _hovering) {
setState(() { _hovering = hovering; });
if (hovering) {
_reactionHoverFadeController.forward();
} else {
_reactionHoverFadeController.reverse();
}
}
}
/// Describes the current [MaterialState] of the Toggleable.
///
/// The returned set will include:
///
/// * [MaterialState.disabled], if [isInteractive] is false
/// * [MaterialState.hovered], if a pointer is hovering over the Toggleable
/// * [MaterialState.focused], if the Toggleable has input focus
/// * [MaterialState.selected], if [value] is true or null
Set<MaterialState> get states => <MaterialState>{
if (!isInteractive) MaterialState.disabled,
if (_hovering) MaterialState.hovered,
if (_focused) MaterialState.focused,
if (value ?? true) MaterialState.selected,
};
/// Typically wraps a `painter` that draws the actual visuals of the
/// Toggleable with logic to toggle it.
///
/// Consider providing a subclass of [ToggleablePainter] as a `painter`, which
/// implements logic to draw a radial ink reaction for this control. The
/// painter is usually configured with the [reaction], [position],
/// [reactionHoverFade], and [reactionFocusFade] animation provided by this
/// mixin. It is expected to draw the visuals of the Toggleable based on the
/// current value of these animations. The animations are triggered by
/// this mixin to transition the Toggleable from one state to another.
///
/// This method must be called from the [build] method of the [State] class
/// that uses this mixin. The returned [Widget] must be returned from the
/// build method - potentially after wrapping it in other widgets.
Widget buildToggleable({
FocusNode? focusNode,
Function(bool)? onFocusChange,
bool autofocus = false,
required MaterialStateProperty<MouseCursor> mouseCursor,
required Size size,
required CustomPainter painter,
}) {
return FocusableActionDetector(
actions: _actionMap,
focusNode: focusNode,
autofocus: autofocus,
onFocusChange: onFocusChange,
enabled: isInteractive,
onShowFocusHighlight: _handleFocusHighlightChanged,
onShowHoverHighlight: _handleHoverChanged,
mouseCursor: mouseCursor.resolve(states),
child: GestureDetector(
excludeFromSemantics: !isInteractive,
onTapDown: isInteractive ? _handleTapDown : null,
onTap: isInteractive ? _handleTap : null,
onTapUp: isInteractive ? _handleTapEnd : null,
onTapCancel: isInteractive ? _handleTapEnd : null,
child: Semantics(
enabled: isInteractive,
child: CustomPaint(
size: size,
painter: painter,
),
),
),
);
}
}
/// A base class for a [CustomPainter] that may be passed to
/// [ToggleableStateMixin.buildToggleable] to draw the visual representation of
/// a Toggleable.
///
/// Subclasses must implement the [paint] method to draw the actual visuals of
/// the Toggleable. In their [paint] method subclasses may call
/// [paintRadialReaction] to draw a radial ink reaction for this control.
abstract class ToggleablePainter extends ChangeNotifier implements CustomPainter {
/// The visual value of the control.
///
/// Usually set to [ToggleableStateMixin.position].
Animation<double> get position => _position!;
Animation<double>? _position;
set position(Animation<double> value) {
if (value == _position) {
return;
}
_position?.removeListener(notifyListeners);
value.addListener(notifyListeners);
_position = value;
notifyListeners();
}
/// The visual value of the radial reaction animation.
///
/// Usually set to [ToggleableStateMixin.reaction].
Animation<double> get reaction => _reaction!;
Animation<double>? _reaction;
set reaction(Animation<double> value) {
if (value == _reaction) {
return;
}
_reaction?.removeListener(notifyListeners);
value.addListener(notifyListeners);
_reaction = value;
notifyListeners();
}
/// Controls the radial reaction's opacity animation for focus changes.
///
/// Usually set to [ToggleableStateMixin.reactionFocusFade].
Animation<double> get reactionFocusFade => _reactionFocusFade!;
Animation<double>? _reactionFocusFade;
set reactionFocusFade(Animation<double> value) {
if (value == _reactionFocusFade) {
return;
}
_reactionFocusFade?.removeListener(notifyListeners);
value.addListener(notifyListeners);
_reactionFocusFade = value;
notifyListeners();
}
/// Controls the radial reaction's opacity animation for hover changes.
///
/// Usually set to [ToggleableStateMixin.reactionHoverFade].
Animation<double> get reactionHoverFade => _reactionHoverFade!;
Animation<double>? _reactionHoverFade;
set reactionHoverFade(Animation<double> value) {
if (value == _reactionHoverFade) {
return;
}
_reactionHoverFade?.removeListener(notifyListeners);
value.addListener(notifyListeners);
_reactionHoverFade = value;
notifyListeners();
}
/// The color that should be used in the active state (i.e., when
/// [ToggleableStateMixin.value] is true).
///
/// For example, a checkbox should use this color when checked.
Color get activeColor => _activeColor!;
Color? _activeColor;
set activeColor(Color value) {
if (_activeColor == value) {
return;
}
_activeColor = value;
notifyListeners();
}
/// The color that should be used in the inactive state (i.e., when
/// [ToggleableStateMixin.value] is false).
///
/// For example, a checkbox should use this color when unchecked.
Color get inactiveColor => _inactiveColor!;
Color? _inactiveColor;
set inactiveColor(Color value) {
if (_inactiveColor == value) {
return;
}
_inactiveColor = value;
notifyListeners();
}
/// The color that should be used for the reaction when the toggleable is
/// inactive.
///
/// Used when the toggleable needs to change the reaction color/transparency
/// that is displayed when the toggleable is inactive and tapped.
Color get inactiveReactionColor => _inactiveReactionColor!;
Color? _inactiveReactionColor;
set inactiveReactionColor(Color value) {
if (value == _inactiveReactionColor) {
return;
}
_inactiveReactionColor = value;
notifyListeners();
}
/// The color that should be used for the reaction when the toggleable is
/// active.
///
/// Used when the toggleable needs to change the reaction color/transparency
/// that is displayed when the toggleable is active and tapped.
Color get reactionColor => _reactionColor!;
Color? _reactionColor;
set reactionColor(Color value) {
if (value == _reactionColor) {
return;
}
_reactionColor = value;
notifyListeners();
}
/// The color that should be used for the reaction when [isHovered] is true.
///
/// Used when the toggleable needs to change the reaction color/transparency,
/// when it is being hovered over.
Color get hoverColor => _hoverColor!;
Color? _hoverColor;
set hoverColor(Color value) {
if (value == _hoverColor) {
return;
}
_hoverColor = value;
notifyListeners();
}
/// The color that should be used for the reaction when [isFocused] is true.
///
/// Used when the toggleable needs to change the reaction color/transparency,
/// when it has focus.
Color get focusColor => _focusColor!;
Color? _focusColor;
set focusColor(Color value) {
if (value == _focusColor) {
return;
}
_focusColor = value;
notifyListeners();
}
/// The splash radius for the radial reaction.
double get splashRadius => _splashRadius!;
double? _splashRadius;
set splashRadius(double value) {
if (value == _splashRadius) {
return;
}
_splashRadius = value;
notifyListeners();
}
/// The [Offset] within the Toggleable at which a pointer touched the Toggleable.
///
/// This is null if currently no pointer is touching the Toggleable.
///
/// Usually set to [ToggleableStateMixin.downPosition].
Offset? get downPosition => _downPosition;
Offset? _downPosition;
set downPosition(Offset? value) {
if (value == _downPosition) {
return;
}
_downPosition = value;
notifyListeners();
}
/// True if this toggleable has the input focus.
bool get isFocused => _isFocused!;
bool? _isFocused;
set isFocused(bool? value) {
if (value == _isFocused) {
return;
}
_isFocused = value;
notifyListeners();
}
/// True if this toggleable is being hovered over by a pointer.
bool get isHovered => _isHovered!;
bool? _isHovered;
set isHovered(bool? value) {
if (value == _isHovered) {
return;
}
_isHovered = value;
notifyListeners();
}
/// Used by subclasses to paint the radial ink reaction for this control.
///
/// The reaction is painted on the given canvas at the given offset. The
/// origin is the center point of the reaction (usually distinct from the
/// [downPosition] at which the user interacted with the control).
void paintRadialReaction({
required Canvas canvas,
Offset offset = Offset.zero,
required Offset origin,
}) {
if (!reaction.isDismissed || !reactionFocusFade.isDismissed || !reactionHoverFade.isDismissed) {
final Paint reactionPaint = Paint()
..color = Color.lerp(
Color.lerp(
Color.lerp(inactiveReactionColor, reactionColor, position.value),
hoverColor,
reactionHoverFade.value,
),
focusColor,
reactionFocusFade.value,
)!;
final Animatable<double> radialReactionRadiusTween = Tween<double>(
begin: 0.0,
end: splashRadius,
);
final double reactionRadius = isFocused || isHovered
? splashRadius
: radialReactionRadiusTween.evaluate(reaction);
if (reactionRadius > 0.0) {
canvas.drawCircle(origin + offset, reactionRadius, reactionPaint);
}
}
}
@override
void dispose() {
_position?.removeListener(notifyListeners);
_reaction?.removeListener(notifyListeners);
_reactionFocusFade?.removeListener(notifyListeners);
_reactionHoverFade?.removeListener(notifyListeners);
super.dispose();
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
@override
bool? hitTest(Offset position) => null;
@override
SemanticsBuilderCallback? get semanticsBuilder => null;
@override
bool shouldRebuildSemantics(covariant CustomPainter oldDelegate) => false;
@override
String toString() => describeIdentity(this);
}