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(
163 duration: _reactionAnimationDuration,
164 vsync: this,
165 );
166 _reaction = CurvedAnimation(
167 parent: _reactionController,
168 curve: Curves.fastOutSlowIn,
169 );
170 _reactionHoverFadeController = AnimationController(
171 duration: _kReactionFadeDuration,
172 value: _hovering || _focused ? 1.0 : 0.0,
173 vsync: this,
174 );
175 _reactionHoverFade = CurvedAnimation(
176 parent: _reactionHoverFadeController,
177 curve: Curves.fastOutSlowIn,
178 );
179 _reactionFocusFadeController = AnimationController(
180 duration: _kReactionFadeDuration,
181 value: _hovering || _focused ? 1.0 : 0.0,
182 vsync: this,
183 );
184 _reactionFocusFade = CurvedAnimation(
185 parent: _reactionFocusFadeController,
186 curve: Curves.fastOutSlowIn,
187 );
188 }
189
190 /// Runs the [position] animation to transition the Toggleable's appearance
191 /// to match [value].
192 ///
193 /// This method must be called whenever [value] changes to ensure that the
194 /// visual representation of the Toggleable matches the current [value].
195 void animateToValue() {
196 if (tristate) {
197 if (value == null) {
198 _positionController.value = 0.0;
199 }
200 if (value ?? true) {
201 _positionController.forward();
202 } else {
203 _positionController.reverse();
204 }
205 } else {
206 if (value ?? false) {
207 _positionController.forward();
208 } else {
209 _positionController.reverse();
210 }
211 }
212 }
213
214 @override
215 void dispose() {
216 _positionController.dispose();
217 _position.dispose();
218 _reactionController.dispose();
219 _reaction.dispose();
220 _reactionHoverFadeController.dispose();
221 _reactionHoverFade.dispose();
222 _reactionFocusFadeController.dispose();
223 _reactionFocusFade.dispose();
224 super.dispose();
225 }
226
227 /// The most recent [Offset] at which a pointer touched the Toggleable.
228 ///
229 /// This is null if currently no pointer is touching the Toggleable or if
230 /// [isInteractive] is false.
231 Offset? get downPosition => _downPosition;
232 Offset? _downPosition;
233
234 void _handleTapDown(TapDownDetails details) {
235 if (isInteractive) {
236 setState(() {
237 _downPosition = details.localPosition;
238 });
239 _reactionController.forward();
240 }
241 }
242
243 void _handleTap([Intent? _]) {
244 if (!isInteractive) {
245 return;
246 }
247 switch (value) {
248 case false:
249 onChanged!(true);
250 case true:
251 onChanged!(tristate ? null : false);
252 case null:
253 onChanged!(false);
254 }
255 context.findRenderObject()!.sendSemanticsEvent(const TapSemanticEvent());
256 }
257
258 void _handleTapEnd([TapUpDetails? _]) {
259 if (_downPosition != null) {
260 setState(() { _downPosition = null; });
261 }
262 _reactionController.reverse();
263 }
264
265 bool _focused = false;
266 void _handleFocusHighlightChanged(bool focused) {
267 if (focused != _focused) {
268 setState(() { _focused = focused; });
269 if (focused) {
270 _reactionFocusFadeController.forward();
271 } else {
272 _reactionFocusFadeController.reverse();
273 }
274 }
275 }
276
277 bool _hovering = false;
278 void _handleHoverChanged(bool hovering) {
279 if (hovering != _hovering) {
280 setState(() { _hovering = hovering; });
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 /// If drawing a radial ink reaction is desired (in Material Design for
308 /// example), consider providing a subclass of [ToggleablePainter] as a
309 /// `painter`, which implements logic to draw a radial ink reaction for this
310 /// control. The painter is usually configured with the [reaction],
311 /// [position], [reactionHoverFade], and [reactionFocusFade] animation
312 /// provided by this mixin. It is expected to draw the visuals of the
313 /// Toggleable based on the current value of these animations. The animations
314 /// are triggered by this mixin to transition the Toggleable from one state
315 /// to another.
316 ///
317 /// Material Toggleables must provide a [mouseCursor] which resolves to a
318 /// [MouseCursor] based on the current [WidgetState] of the Toggleable.
319 /// Cupertino Toggleables may not provide a [mouseCursor]. If no [mouseCursor]
320 /// is provided, [SystemMouseCursors.basic] will be used as the [mouseCursor]
321 /// across all [WidgetState]s.
322 ///
323 /// This method must be called from the [build] method of the [State] class
324 /// that uses this mixin. The returned [Widget] must be returned from the
325 /// build method - potentially after wrapping it in other widgets.
326 Widget buildToggleable({
327 FocusNode? focusNode,
328 ValueChanged<bool>? onFocusChange,
329 bool autofocus = false,
330 WidgetStateProperty<MouseCursor>? mouseCursor,
331 required Size size,
332 required CustomPainter painter,
333 }) {
334 return FocusableActionDetector(
335 actions: _actionMap,
336 focusNode: focusNode,
337 autofocus: autofocus,
338 onFocusChange: onFocusChange,
339 enabled: isInteractive,
340 onShowFocusHighlight: _handleFocusHighlightChanged,
341 onShowHoverHighlight: _handleHoverChanged,
342 mouseCursor: mouseCursor?.resolve(states) ?? SystemMouseCursors.basic,
343 child: GestureDetector(
344 excludeFromSemantics: !isInteractive,
345 onTapDown: isInteractive ? _handleTapDown : null,
346 onTap: isInteractive ? _handleTap : null,
347 onTapUp: isInteractive ? _handleTapEnd : null,
348 onTapCancel: isInteractive ? _handleTapEnd : null,
349 child: Semantics(
350 enabled: isInteractive,
351 child: CustomPaint(
352 size: size,
353 painter: painter,
354 ),
355 ),
356 ),
357 );
358 }
359}
360
361/// A base class for a [CustomPainter] that may be passed to
362/// [ToggleableStateMixin.buildToggleable] to draw the visual representation of
363/// a Toggleable.
364///
365/// Subclasses must implement the [paint] method to draw the actual visuals of
366/// the Toggleable.
367///
368/// If drawing a radial ink reaction is desired (in Material
369/// Design for example), subclasses may call [paintRadialReaction] in their
370/// [paint] method.
371abstract class ToggleablePainter extends ChangeNotifier implements CustomPainter {
372 /// The visual value of the control.
373 ///
374 /// Usually set to [ToggleableStateMixin.position].
375 Animation<double> get position => _position!;
376 Animation<double>? _position;
377 set position(Animation<double> value) {
378 if (value == _position) {
379 return;
380 }
381 _position?.removeListener(notifyListeners);
382 value.addListener(notifyListeners);
383 _position = value;
384 notifyListeners();
385 }
386
387 /// The visual value of the radial reaction animation.
388 ///
389 /// Usually set to [ToggleableStateMixin.reaction].
390 Animation<double> get reaction => _reaction!;
391 Animation<double>? _reaction;
392 set reaction(Animation<double> value) {
393 if (value == _reaction) {
394 return;
395 }
396 _reaction?.removeListener(notifyListeners);
397 value.addListener(notifyListeners);
398 _reaction = value;
399 notifyListeners();
400 }
401
402 /// Controls the radial reaction's opacity animation for focus changes.
403 ///
404 /// Usually set to [ToggleableStateMixin.reactionFocusFade].
405 Animation<double> get reactionFocusFade => _reactionFocusFade!;
406 Animation<double>? _reactionFocusFade;
407 set reactionFocusFade(Animation<double> value) {
408 if (value == _reactionFocusFade) {
409 return;
410 }
411 _reactionFocusFade?.removeListener(notifyListeners);
412 value.addListener(notifyListeners);
413 _reactionFocusFade = value;
414 notifyListeners();
415 }
416
417 /// Controls the radial reaction's opacity animation for hover changes.
418 ///
419 /// Usually set to [ToggleableStateMixin.reactionHoverFade].
420 Animation<double> get reactionHoverFade => _reactionHoverFade!;
421 Animation<double>? _reactionHoverFade;
422 set reactionHoverFade(Animation<double> value) {
423 if (value == _reactionHoverFade) {
424 return;
425 }
426 _reactionHoverFade?.removeListener(notifyListeners);
427 value.addListener(notifyListeners);
428 _reactionHoverFade = value;
429 notifyListeners();
430 }
431
432 /// The color that should be used in the active state (i.e., when
433 /// [ToggleableStateMixin.value] is true).
434 ///
435 /// For example, a checkbox should use this color when checked.
436 Color get activeColor => _activeColor!;
437 Color? _activeColor;
438 set activeColor(Color value) {
439 if (_activeColor == value) {
440 return;
441 }
442 _activeColor = value;
443 notifyListeners();
444 }
445
446 /// The color that should be used in the inactive state (i.e., when
447 /// [ToggleableStateMixin.value] is false).
448 ///
449 /// For example, a checkbox should use this color when unchecked.
450 Color get inactiveColor => _inactiveColor!;
451 Color? _inactiveColor;
452 set inactiveColor(Color value) {
453 if (_inactiveColor == value) {
454 return;
455 }
456 _inactiveColor = value;
457 notifyListeners();
458 }
459
460 /// The color that should be used for the reaction when the toggleable is
461 /// inactive.
462 ///
463 /// Used when the toggleable needs to change the reaction color/transparency
464 /// that is displayed when the toggleable is inactive and tapped.
465 Color get inactiveReactionColor => _inactiveReactionColor!;
466 Color? _inactiveReactionColor;
467 set inactiveReactionColor(Color value) {
468 if (value == _inactiveReactionColor) {
469 return;
470 }
471 _inactiveReactionColor = value;
472 notifyListeners();
473 }
474
475 /// The color that should be used for the reaction when the toggleable is
476 /// active.
477 ///
478 /// Used when the toggleable needs to change the reaction color/transparency
479 /// that is displayed when the toggleable is active and tapped.
480 Color get reactionColor => _reactionColor!;
481 Color? _reactionColor;
482 set reactionColor(Color value) {
483 if (value == _reactionColor) {
484 return;
485 }
486 _reactionColor = value;
487 notifyListeners();
488 }
489
490 /// The color that should be used for the reaction when [isHovered] is true.
491 ///
492 /// Used when the toggleable needs to change the reaction color/transparency,
493 /// when it is being hovered over.
494 Color get hoverColor => _hoverColor!;
495 Color? _hoverColor;
496 set hoverColor(Color value) {
497 if (value == _hoverColor) {
498 return;
499 }
500 _hoverColor = value;
501 notifyListeners();
502 }
503
504 /// The color that should be used for the reaction when [isFocused] is true.
505 ///
506 /// Used when the toggleable needs to change the reaction color/transparency,
507 /// when it has focus.
508 Color get focusColor => _focusColor!;
509 Color? _focusColor;
510 set focusColor(Color value) {
511 if (value == _focusColor) {
512 return;
513 }
514 _focusColor = value;
515 notifyListeners();
516 }
517
518 /// The splash radius for the radial reaction.
519 double get splashRadius => _splashRadius!;
520 double? _splashRadius;
521 set splashRadius(double value) {
522 if (value == _splashRadius) {
523 return;
524 }
525 _splashRadius = value;
526 notifyListeners();
527 }
528
529 /// The [Offset] within the Toggleable at which a pointer touched the Toggleable.
530 ///
531 /// This is null if currently no pointer is touching the Toggleable.
532 ///
533 /// Usually set to [ToggleableStateMixin.downPosition].
534 Offset? get downPosition => _downPosition;
535 Offset? _downPosition;
536 set downPosition(Offset? value) {
537 if (value == _downPosition) {
538 return;
539 }
540 _downPosition = value;
541 notifyListeners();
542 }
543
544 /// True if this toggleable has the input focus.
545 bool get isFocused => _isFocused!;
546 bool? _isFocused;
547 set isFocused(bool? value) {
548 if (value == _isFocused) {
549 return;
550 }
551 _isFocused = value;
552 notifyListeners();
553 }
554
555 /// True if this toggleable is being hovered over by a pointer.
556 bool get isHovered => _isHovered!;
557 bool? _isHovered;
558 set isHovered(bool? value) {
559 if (value == _isHovered) {
560 return;
561 }
562 _isHovered = value;
563 notifyListeners();
564 }
565
566 /// Determines whether the toggleable shows as active.
567 bool get isActive => _isActive!;
568 bool? _isActive;
569 set isActive(bool? value) {
570 if (value == _isActive) {
571 return;
572 }
573 _isActive = value;
574 notifyListeners();
575 }
576
577 /// Used by subclasses to paint the radial ink reaction for this control.
578 ///
579 /// The reaction is painted on the given canvas at the given offset. The
580 /// origin is the center point of the reaction (usually distinct from the
581 /// [downPosition] at which the user interacted with the control).
582 void paintRadialReaction({
583 required Canvas canvas,
584 Offset offset = Offset.zero,
585 required Offset origin,
586 }) {
587 if (!reaction.isDismissed || !reactionFocusFade.isDismissed || !reactionHoverFade.isDismissed) {
588 final Paint reactionPaint = Paint()
589 ..color = Color.lerp(
590 Color.lerp(
591 Color.lerp(inactiveReactionColor, reactionColor, position.value),
592 hoverColor,
593 reactionHoverFade.value,
594 ),
595 focusColor,
596 reactionFocusFade.value,
597 )!;
598 final Animatable<double> radialReactionRadiusTween = Tween<double>(
599 begin: 0.0,
600 end: splashRadius,
601 );
602 final double reactionRadius = isFocused || isHovered
603 ? splashRadius
604 : radialReactionRadiusTween.evaluate(reaction);
605 if (reactionRadius > 0.0) {
606 canvas.drawCircle(origin + offset, reactionRadius, reactionPaint);
607 }
608 }
609 }
610
611 @override
612 void dispose() {
613 _position?.removeListener(notifyListeners);
614 _reaction?.removeListener(notifyListeners);
615 _reactionFocusFade?.removeListener(notifyListeners);
616 _reactionHoverFade?.removeListener(notifyListeners);
617 super.dispose();
618 }
619
620 @override
621 bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
622
623 @override
624 bool? hitTest(Offset position) => null;
625
626 @override
627 SemanticsBuilderCallback? get semanticsBuilder => null;
628
629 @override
630 bool shouldRebuildSemantics(covariant CustomPainter oldDelegate) => false;
631
632 @override
633 String toString() => describeIdentity(this);
634}
635