| // 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); |
| } |