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