1// Copyright 2014 The Flutter Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5/// @docImport 'package:flutter/cupertino.dart';
6/// @docImport 'package:flutter/material.dart';
7library;
8
9import 'package:flutter/foundation.dart';
10import 'package:flutter/rendering.dart';
11
12import 'actions.dart';
13import 'basic.dart';
14import 'focus_manager.dart';
15import 'framework.dart';
16import 'gesture_detector.dart';
17import 'ticker_provider.dart';
18import 'widget_state.dart';
19
20// Duration of the animation that moves the toggle from one state to another.
21const Duration _kToggleDuration = Duration(milliseconds: 200);
22
23// Duration of the fade animation for the reaction when focus and hover occur.
24const Duration _kReactionFadeDuration = Duration(milliseconds: 50);
25
26/// A mixin for [StatefulWidget]s that implement toggleable
27/// controls with toggle animations (e.g. [Switch]es, [CupertinoSwitch]es,
28/// [Checkbox]es, [CupertinoCheckbox]es, [Radio]s, and [CupertinoRadio]s).
29///
30/// The mixin implements the logic for toggling the control (e.g. when tapped)
31/// and provides a series of animation controllers to transition the control
32/// from one state to another. It does not have any opinion about the visual
33/// representation of the toggleable widget. The visuals are defined by a
34/// [CustomPainter] passed to the [buildToggleable]. [State] objects using this
35/// mixin should call that method from their [build] method.
36@optionalTypeArgs
37mixin ToggleableStateMixin<S extends StatefulWidget> on TickerProviderStateMixin<S> {
38 /// Used by subclasses to manipulate the visual value of the control.
39 ///
40 /// Some controls respond to user input by updating their visual value. For
41 /// example, the thumb of a switch moves from one position to another when
42 /// dragged. These controls manipulate this animation controller to update
43 /// their [position] and eventually trigger an [onChanged] callback when the
44 /// animation reaches either 0.0 or 1.0.
45 AnimationController get positionController => _positionController;
46 late AnimationController _positionController;
47
48 /// The visual value of the control.
49 ///
50 /// When the control is inactive, the [value] is false and this animation has
51 /// the value 0.0. When the control is active, the value is either true or
52 /// tristate is true and the value is null. When the control is active the
53 /// animation has a value of 1.0. When the control is changing from inactive
54 /// to active (or vice versa), [value] is the target value and this animation
55 /// gradually updates from 0.0 to 1.0 (or vice versa).
56 CurvedAnimation get position => _position;
57 late CurvedAnimation _position;
58
59 /// Used by subclasses to control the radial reaction animation.
60 ///
61 /// Some controls have a radial ink reaction to user input. This animation
62 /// controller can be used to start or stop these ink reactions.
63 ///
64 /// To paint the actual radial reaction, [ToggleablePainter.paintRadialReaction]
65 /// may be used.
66 AnimationController get reactionController => _reactionController;
67 late AnimationController _reactionController;
68
69 /// The visual value of the radial reaction animation.
70 ///
71 /// Some controls have a radial ink reaction to user input. This animation
72 /// controls the progress of these ink reactions.
73 ///
74 /// To paint the actual radial reaction, [ToggleablePainter.paintRadialReaction]
75 /// may be used.
76 CurvedAnimation get reaction => _reaction;
77 late CurvedAnimation _reaction;
78
79 /// Controls the radial reaction's opacity animation for hover changes.
80 ///
81 /// Some controls have a radial ink reaction to pointer hover. This animation
82 /// controls these ink reaction fade-ins and
83 /// fade-outs.
84 ///
85 /// To paint the actual radial reaction, [ToggleablePainter.paintRadialReaction]
86 /// may be used.
87 CurvedAnimation get reactionHoverFade => _reactionHoverFade;
88 late CurvedAnimation _reactionHoverFade;
89 late AnimationController _reactionHoverFadeController;
90
91 /// Controls the radial reaction's opacity animation for focus changes.
92 ///
93 /// Some controls have a radial ink reaction to focus. This animation
94 /// controls these ink reaction fade-ins and fade-outs.
95 ///
96 /// To paint the actual radial reaction, [ToggleablePainter.paintRadialReaction]
97 /// may be used.
98 CurvedAnimation get reactionFocusFade => _reactionFocusFade;
99 late CurvedAnimation _reactionFocusFade;
100 late AnimationController _reactionFocusFadeController;
101
102 /// The amount of time a circular ink response should take to expand to its
103 /// full size if a radial reaction is drawn using
104 /// [ToggleablePainter.paintRadialReaction].
105 Duration? get reactionAnimationDuration => _reactionAnimationDuration;
106 final Duration _reactionAnimationDuration = const Duration(milliseconds: 100);
107
108 /// Whether [value] of this control can be changed by user interaction.
109 ///
110 /// The control is considered interactive if the [onChanged] callback is
111 /// non-null. If the callback is null, then the control is disabled, and
112 /// non-interactive. A disabled checkbox, for example, is displayed using a
113 /// grey color and its value cannot be changed.
114 bool get isInteractive => onChanged != null;
115
116 late final Map<Type, Action<Intent>> _actionMap = <Type, Action<Intent>>{
117 ActivateIntent: CallbackAction<ActivateIntent>(onInvoke: _handleTap),
118 };
119
120 /// Called when the control changes value.
121 ///
122 /// If the control is tapped, [onChanged] is called immediately with the new
123 /// value.
124 ///
125 /// The control is considered interactive (see [isInteractive]) if this
126 /// callback is non-null. If the callback is null, then the control is
127 /// disabled, and non-interactive. A disabled checkbox, for example, is
128 /// displayed using a grey color and its value cannot be changed.
129 ValueChanged<bool?>? get onChanged;
130
131 /// False if this control is "inactive" (not checked, off, or unselected).
132 ///
133 /// If value is true then the control "active" (checked, on, or selected). If
134 /// tristate is true and value is null, then the control is considered to be
135 /// in its third or "indeterminate" state.
136 ///
137 /// When the value changes, this object starts the [positionController] and
138 /// [position] animations to animate the visual appearance of the control to
139 /// the new value.
140 bool? get value;
141
142 /// If true, [value] can be true, false, or null, otherwise [value] must
143 /// be true or false.
144 ///
145 /// When [tristate] is true and [value] is null, then the control is
146 /// considered to be in its third or "indeterminate" state.
147 bool get tristate;
148
149 @override
150 void initState() {
151 super.initState();
152 _positionController = AnimationController(
153 duration: _kToggleDuration,
154 value: value == false ? 0.0 : 1.0,
155 vsync: this,
156 );
157 _position = CurvedAnimation(
158 parent: _positionController,
159 curve: Curves.easeIn,
160 reverseCurve: Curves.easeOut,
161 );
162 _reactionController = AnimationController(duration: _reactionAnimationDuration, vsync: this);
163 _reaction = CurvedAnimation(parent: _reactionController, curve: Curves.fastOutSlowIn);
164 _reactionHoverFadeController = AnimationController(
165 duration: _kReactionFadeDuration,
166 value: _hovering || _focused ? 1.0 : 0.0,
167 vsync: this,
168 );
169 _reactionHoverFade = CurvedAnimation(
170 parent: _reactionHoverFadeController,
171 curve: Curves.fastOutSlowIn,
172 );
173 _reactionFocusFadeController = AnimationController(
174 duration: _kReactionFadeDuration,
175 value: _hovering || _focused ? 1.0 : 0.0,
176 vsync: this,
177 );
178 _reactionFocusFade = CurvedAnimation(
179 parent: _reactionFocusFadeController,
180 curve: Curves.fastOutSlowIn,
181 );
182 }
183
184 /// Runs the [position] animation to transition the Toggleable's appearance
185 /// to match [value].
186 ///
187 /// This method must be called whenever [value] changes to ensure that the
188 /// visual representation of the Toggleable matches the current [value].
189 void animateToValue() {
190 if (tristate) {
191 if (value == null) {
192 _positionController.value = 0.0;
193 }
194 if (value ?? true) {
195 _positionController.forward();
196 } else {
197 _positionController.reverse();
198 }
199 } else {
200 if (value ?? false) {
201 _positionController.forward();
202 } else {
203 _positionController.reverse();
204 }
205 }
206 }
207
208 @override
209 void dispose() {
210 _positionController.dispose();
211 _position.dispose();
212 _reactionController.dispose();
213 _reaction.dispose();
214 _reactionHoverFadeController.dispose();
215 _reactionHoverFade.dispose();
216 _reactionFocusFadeController.dispose();
217 _reactionFocusFade.dispose();
218 super.dispose();
219 }
220
221 /// The most recent [Offset] at which a pointer touched the Toggleable.
222 ///
223 /// This is null if currently no pointer is touching the Toggleable or if
224 /// [isInteractive] is false.
225 Offset? get downPosition => _downPosition;
226 Offset? _downPosition;
227
228 void _handleTapDown(TapDownDetails details) {
229 if (isInteractive) {
230 setState(() {
231 _downPosition = details.localPosition;
232 });
233 _reactionController.forward();
234 }
235 }
236
237 void _handleTap([Intent? _]) {
238 if (!isInteractive) {
239 return;
240 }
241 switch (value) {
242 case false:
243 onChanged!(true);
244 case true:
245 onChanged!(tristate ? null : false);
246 case null:
247 onChanged!(false);
248 }
249 context.findRenderObject()!.sendSemanticsEvent(const TapSemanticEvent());
250 }
251
252 void _handleTapEnd([TapUpDetails? _]) {
253 if (_downPosition != null) {
254 setState(() {
255 _downPosition = null;
256 });
257 }
258 _reactionController.reverse();
259 }
260
261 bool _focused = false;
262 void _handleFocusHighlightChanged(bool focused) {
263 if (focused != _focused) {
264 setState(() {
265 _focused = focused;
266 });
267 if (focused) {
268 _reactionFocusFadeController.forward();
269 } else {
270 _reactionFocusFadeController.reverse();
271 }
272 }
273 }
274
275 bool _hovering = false;
276 void _handleHoverChanged(bool hovering) {
277 if (hovering != _hovering) {
278 setState(() {
279 _hovering = hovering;
280 });
281 if (hovering) {
282 _reactionHoverFadeController.forward();
283 } else {
284 _reactionHoverFadeController.reverse();
285 }
286 }
287 }
288
289 /// Describes the current [WidgetState] of the Toggleable.
290 ///
291 /// The returned set will include:
292 ///
293 /// * [WidgetState.disabled], if [isInteractive] is false
294 /// * [WidgetState.hovered], if a pointer is hovering over the Toggleable
295 /// * [WidgetState.focused], if the Toggleable has input focus
296 /// * [WidgetState.selected], if [value] is true or null
297 Set<WidgetState> get states => <WidgetState>{
298 if (!isInteractive) WidgetState.disabled,
299 if (_hovering) WidgetState.hovered,
300 if (_focused) WidgetState.focused,
301 if (value ?? true) WidgetState.selected,
302 };
303
304 /// Typically wraps a `painter` that draws the actual visuals of the
305 /// Toggleable with logic to toggle it.
306 ///
307 /// Use [buildToggleableWithChild] if one would like to provide a [Widget]
308 /// instead of [CustomPainter].
309 ///
310 /// If drawing a radial ink reaction is desired (in Material Design for
311 /// example), consider providing a subclass of [ToggleablePainter] as a
312 /// `painter`, which implements logic to draw a radial ink reaction for this
313 /// control. The painter is usually configured with the [reaction],
314 /// [position], [reactionHoverFade], and [reactionFocusFade] animation
315 /// provided by this mixin. It is expected to draw the visuals of the
316 /// Toggleable based on the current value of these animations. The animations
317 /// are triggered by this mixin to transition the Toggleable from one state
318 /// to another.
319 ///
320 /// Material Toggleables must provide a [mouseCursor] which resolves to a
321 /// [MouseCursor] based on the current [WidgetState] of the Toggleable.
322 /// Cupertino Toggleables may not provide a [mouseCursor]. If no [mouseCursor]
323 /// is provided, [SystemMouseCursors.basic] will be used as the [mouseCursor]
324 /// across all [WidgetState]s.
325 ///
326 /// This method must be called from the [build] method of the [State] class
327 /// that uses this mixin. The returned [Widget] must be returned from the
328 /// build method - potentially after wrapping it in other widgets.
329 Widget buildToggleable({
330 FocusNode? focusNode,
331 ValueChanged<bool>? onFocusChange,
332 bool autofocus = false,
333 WidgetStateProperty<MouseCursor>? mouseCursor,
334 required Size size,
335 required CustomPainter painter,
336 }) {
337 return buildToggleableWithChild(
338 focusNode: focusNode,
339 onFocusChange: onFocusChange,
340 autofocus: autofocus,
341 mouseCursor: mouseCursor,
342 child: CustomPaint(size: size, painter: painter),
343 );
344 }
345
346 /// Typically wraps a child that draws the actual visuals of the
347 /// Toggleable with logic to toggle it.
348 ///
349 /// {@template flutter.widgets.ToggleableStateMixin.buildToggleableWithChild}
350 /// If drawing a radial ink reaction is desired (in Material Design for
351 /// example), consider providing [CustomPaint] with a subclass of
352 /// [ToggleablePainter] as a [CustomPaint.painter], which implements logic
353 /// to draw a radial ink reaction for this control. The painter is usually
354 /// configured with the [ToggleableStateMixin.reaction],
355 /// [ToggleableStateMixin.position], [ToggleableStateMixin.reactionHoverFade],
356 /// and [ToggleableStateMixin.reactionFocusFade] animation provided by this
357 /// mixin. It is expected to draw the visuals of the Toggleable based on the
358 /// current value of these animations. The animations are triggered by this
359 /// mixin to transition the Toggleable from one state to another.
360 /// {@endtemplate}
361 ///
362 /// Material Toggleables must provide a [mouseCursor] which resolves to a
363 /// [MouseCursor] based on the current [WidgetState] of the Toggleable.
364 /// Cupertino Toggleables may not provide a [mouseCursor]. If no [mouseCursor]
365 /// is provided, [SystemMouseCursors.basic] will be used as the [mouseCursor]
366 /// across all [WidgetState]s.
367 ///
368 /// This method must be called from the [build] method of the [State] class
369 /// that uses this mixin. The returned [Widget] must be returned from the
370 /// build method - potentially after wrapping it in other widgets.
371 Widget buildToggleableWithChild({
372 FocusNode? focusNode,
373 ValueChanged<bool>? onFocusChange,
374 bool autofocus = false,
375 WidgetStateProperty<MouseCursor>? mouseCursor,
376 required Widget child,
377 }) {
378 return FocusableActionDetector(
379 actions: _actionMap,
380 focusNode: focusNode,
381 autofocus: autofocus,
382 onFocusChange: onFocusChange,
383 enabled: isInteractive,
384 onShowFocusHighlight: _handleFocusHighlightChanged,
385 onShowHoverHighlight: _handleHoverChanged,
386 mouseCursor: mouseCursor?.resolve(states) ?? SystemMouseCursors.basic,
387 child: GestureDetector(
388 excludeFromSemantics: !isInteractive,
389 onTapDown: isInteractive ? _handleTapDown : null,
390 onTap: isInteractive ? _handleTap : null,
391 onTapUp: isInteractive ? _handleTapEnd : null,
392 onTapCancel: isInteractive ? _handleTapEnd : null,
393 child: Semantics(enabled: isInteractive, child: child),
394 ),
395 );
396 }
397}
398
399/// A base class for a [CustomPainter] that may be passed to
400/// [ToggleableStateMixin.buildToggleable] to draw the visual representation of
401/// a Toggleable.
402///
403/// Subclasses must implement the [paint] method to draw the actual visuals of
404/// the Toggleable.
405///
406/// If drawing a radial ink reaction is desired (in Material
407/// Design for example), subclasses may call [paintRadialReaction] in their
408/// [paint] method.
409abstract class ToggleablePainter extends ChangeNotifier implements CustomPainter {
410 /// The visual value of the control.
411 ///
412 /// Usually set to [ToggleableStateMixin.position].
413 Animation<double> get position => _position!;
414 Animation<double>? _position;
415 set position(Animation<double> value) {
416 if (value == _position) {
417 return;
418 }
419 _position?.removeListener(notifyListeners);
420 value.addListener(notifyListeners);
421 _position = value;
422 notifyListeners();
423 }
424
425 /// The visual value of the radial reaction animation.
426 ///
427 /// Usually set to [ToggleableStateMixin.reaction].
428 Animation<double> get reaction => _reaction!;
429 Animation<double>? _reaction;
430 set reaction(Animation<double> value) {
431 if (value == _reaction) {
432 return;
433 }
434 _reaction?.removeListener(notifyListeners);
435 value.addListener(notifyListeners);
436 _reaction = value;
437 notifyListeners();
438 }
439
440 /// Controls the radial reaction's opacity animation for focus changes.
441 ///
442 /// Usually set to [ToggleableStateMixin.reactionFocusFade].
443 Animation<double> get reactionFocusFade => _reactionFocusFade!;
444 Animation<double>? _reactionFocusFade;
445 set reactionFocusFade(Animation<double> value) {
446 if (value == _reactionFocusFade) {
447 return;
448 }
449 _reactionFocusFade?.removeListener(notifyListeners);
450 value.addListener(notifyListeners);
451 _reactionFocusFade = value;
452 notifyListeners();
453 }
454
455 /// Controls the radial reaction's opacity animation for hover changes.
456 ///
457 /// Usually set to [ToggleableStateMixin.reactionHoverFade].
458 Animation<double> get reactionHoverFade => _reactionHoverFade!;
459 Animation<double>? _reactionHoverFade;
460 set reactionHoverFade(Animation<double> value) {
461 if (value == _reactionHoverFade) {
462 return;
463 }
464 _reactionHoverFade?.removeListener(notifyListeners);
465 value.addListener(notifyListeners);
466 _reactionHoverFade = value;
467 notifyListeners();
468 }
469
470 /// The color that should be used in the active state (i.e., when
471 /// [ToggleableStateMixin.value] is true).
472 ///
473 /// For example, a checkbox should use this color when checked.
474 Color get activeColor => _activeColor!;
475 Color? _activeColor;
476 set activeColor(Color value) {
477 if (_activeColor == value) {
478 return;
479 }
480 _activeColor = value;
481 notifyListeners();
482 }
483
484 /// The color that should be used in the inactive state (i.e., when
485 /// [ToggleableStateMixin.value] is false).
486 ///
487 /// For example, a checkbox should use this color when unchecked.
488 Color get inactiveColor => _inactiveColor!;
489 Color? _inactiveColor;
490 set inactiveColor(Color value) {
491 if (_inactiveColor == value) {
492 return;
493 }
494 _inactiveColor = value;
495 notifyListeners();
496 }
497
498 /// The color that should be used for the reaction when the toggleable is
499 /// inactive.
500 ///
501 /// Used when the toggleable needs to change the reaction color/transparency
502 /// that is displayed when the toggleable is inactive and tapped.
503 Color get inactiveReactionColor => _inactiveReactionColor!;
504 Color? _inactiveReactionColor;
505 set inactiveReactionColor(Color value) {
506 if (value == _inactiveReactionColor) {
507 return;
508 }
509 _inactiveReactionColor = value;
510 notifyListeners();
511 }
512
513 /// The color that should be used for the reaction when the toggleable is
514 /// active.
515 ///
516 /// Used when the toggleable needs to change the reaction color/transparency
517 /// that is displayed when the toggleable is active and tapped.
518 Color get reactionColor => _reactionColor!;
519 Color? _reactionColor;
520 set reactionColor(Color value) {
521 if (value == _reactionColor) {
522 return;
523 }
524 _reactionColor = value;
525 notifyListeners();
526 }
527
528 /// The color that should be used for the reaction when [isHovered] is true.
529 ///
530 /// Used when the toggleable needs to change the reaction color/transparency,
531 /// when it is being hovered over.
532 Color get hoverColor => _hoverColor!;
533 Color? _hoverColor;
534 set hoverColor(Color value) {
535 if (value == _hoverColor) {
536 return;
537 }
538 _hoverColor = value;
539 notifyListeners();
540 }
541
542 /// The color that should be used for the reaction when [isFocused] is true.
543 ///
544 /// Used when the toggleable needs to change the reaction color/transparency,
545 /// when it has focus.
546 Color get focusColor => _focusColor!;
547 Color? _focusColor;
548 set focusColor(Color value) {
549 if (value == _focusColor) {
550 return;
551 }
552 _focusColor = value;
553 notifyListeners();
554 }
555
556 /// The splash radius for the radial reaction.
557 double get splashRadius => _splashRadius!;
558 double? _splashRadius;
559 set splashRadius(double value) {
560 if (value == _splashRadius) {
561 return;
562 }
563 _splashRadius = value;
564 notifyListeners();
565 }
566
567 /// The [Offset] within the Toggleable at which a pointer touched the Toggleable.
568 ///
569 /// This is null if currently no pointer is touching the Toggleable.
570 ///
571 /// Usually set to [ToggleableStateMixin.downPosition].
572 Offset? get downPosition => _downPosition;
573 Offset? _downPosition;
574 set downPosition(Offset? value) {
575 if (value == _downPosition) {
576 return;
577 }
578 _downPosition = value;
579 notifyListeners();
580 }
581
582 /// True if this toggleable has the input focus.
583 bool get isFocused => _isFocused!;
584 bool? _isFocused;
585 set isFocused(bool? value) {
586 if (value == _isFocused) {
587 return;
588 }
589 _isFocused = value;
590 notifyListeners();
591 }
592
593 /// True if this toggleable is being hovered over by a pointer.
594 bool get isHovered => _isHovered!;
595 bool? _isHovered;
596 set isHovered(bool? value) {
597 if (value == _isHovered) {
598 return;
599 }
600 _isHovered = value;
601 notifyListeners();
602 }
603
604 /// Determines whether the toggleable shows as active.
605 bool get isActive => _isActive!;
606 bool? _isActive;
607 set isActive(bool? value) {
608 if (value == _isActive) {
609 return;
610 }
611 _isActive = value;
612 notifyListeners();
613 }
614
615 /// Used by subclasses to paint the radial ink reaction for this control.
616 ///
617 /// The reaction is painted on the given canvas at the given offset. The
618 /// origin is the center point of the reaction (usually distinct from the
619 /// [downPosition] at which the user interacted with the control).
620 void paintRadialReaction({
621 required Canvas canvas,
622 Offset offset = Offset.zero,
623 required Offset origin,
624 }) {
625 if (!reaction.isDismissed || !reactionFocusFade.isDismissed || !reactionHoverFade.isDismissed) {
626 final Paint reactionPaint = Paint()
627 ..color = Color.lerp(
628 Color.lerp(
629 Color.lerp(inactiveReactionColor, reactionColor, position.value),
630 hoverColor,
631 reactionHoverFade.value,
632 ),
633 focusColor,
634 reactionFocusFade.value,
635 )!;
636 final Animatable<double> radialReactionRadiusTween = Tween<double>(
637 begin: 0.0,
638 end: splashRadius,
639 );
640 final double reactionRadius = isFocused || isHovered
641 ? splashRadius
642 : radialReactionRadiusTween.evaluate(reaction);
643 if (reactionRadius > 0.0) {
644 canvas.drawCircle(origin + offset, reactionRadius, reactionPaint);
645 }
646 }
647 }
648
649 @override
650 void dispose() {
651 _position?.removeListener(notifyListeners);
652 _reaction?.removeListener(notifyListeners);
653 _reactionFocusFade?.removeListener(notifyListeners);
654 _reactionHoverFade?.removeListener(notifyListeners);
655 super.dispose();
656 }
657
658 @override
659 bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
660
661 @override
662 bool? hitTest(Offset position) => null;
663
664 @override
665 SemanticsBuilderCallback? get semanticsBuilder => null;
666
667 @override
668 bool shouldRebuildSemantics(covariant CustomPainter oldDelegate) => false;
669
670 @override
671 String toString() => describeIdentity(this);
672}
673