toggleable.dart 19.8 KB
Newer Older
Ian Hickson's avatar
Ian Hickson committed
1
// Copyright 2014 The Flutter Authors. All rights reserved.
2 3 4
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

Ian Hickson's avatar
Ian Hickson committed
5
import 'package:flutter/foundation.dart';
6
import 'package:flutter/rendering.dart';
7
import 'package:flutter/widgets.dart';
8

9
import 'constants.dart';
10
import 'material_state.dart';
11

12
// Duration of the animation that moves the toggle from one state to another.
13
const Duration _kToggleDuration = Duration(milliseconds: 200);
14 15 16 17

// Duration of the fade animation for the reaction when focus and hover occur.
const Duration _kReactionFadeDuration = Duration(milliseconds: 50);

18 19 20
/// A mixin for [StatefulWidget]s that implement material-themed toggleable
/// controls with toggle animations (e.g. [Switch]es, [Checkbox]es, and
/// [Radio]s).
21
///
22 23 24 25 26 27 28 29 30 31 32
/// 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> {
33 34 35 36 37 38 39 40
  /// 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;
41
  late AnimationController _positionController;
42 43 44 45

  /// The visual value of the control.
  ///
  /// When the control is inactive, the [value] is false and this animation has
46 47 48
  /// 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
49 50 51
  /// 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;
52
  late CurvedAnimation _position;
53 54 55 56 57 58

  /// 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.
  ///
59 60
  /// To paint the actual radial reaction, [ToggleablePainter.paintRadialReaction]
  /// may be used.
61
  AnimationController get reactionController => _reactionController;
62
  late AnimationController _reactionController;
63

64
  /// The visual value of the radial reaction animation.
65
  ///
66 67
  /// Some controls have a radial ink reaction to user input. This animation
  /// controls the progress of these ink reactions.
68
  ///
69 70 71 72
  /// To paint the actual radial reaction, [ToggleablePainter.paintRadialReaction]
  /// may be used.
  Animation<double> get reaction => _reaction;
  late Animation<double> _reaction;
73

74
  /// Controls the radial reaction's opacity animation for hover changes.
75 76
  ///
  /// Some controls have a radial ink reaction to pointer hover. This animation
77
  /// controls these ink reaction fade-ins and
78 79
  /// fade-outs.
  ///
80 81 82
  /// To paint the actual radial reaction, [ToggleablePainter.paintRadialReaction]
  /// may be used.
  Animation<double> get reactionHoverFade => _reactionHoverFade;
83
  late Animation<double> _reactionHoverFade;
84
  late AnimationController _reactionHoverFadeController;
85

86 87 88 89 90 91 92 93 94 95
  /// 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;
96

97 98 99 100 101 102 103
  /// 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;
104

105 106 107 108 109 110 111 112 113 114 115 116 117 118
  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;
119

120 121 122 123 124
  /// 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.
125 126 127 128
  ///
  /// When the value changes, this object starts the [positionController] and
  /// [position] animations to animate the visual appearance of the control to
  /// the new value.
129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184
  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() {
185
    if (tristate) {
186
      if (value == null) {
187
        _positionController.value = 0.0;
188 189
      }
      if (value ?? true) {
190
        _positionController.forward();
191
      } else {
192
        _positionController.reverse();
193
      }
194
    } else {
195
      if (value ?? false) {
196
        _positionController.forward();
197
      } else {
198
        _positionController.reverse();
199
      }
200 201 202
    }
  }

203 204 205 206 207 208 209 210 211 212
  @override
  void dispose() {
    _positionController.dispose();
    _reactionController.dispose();
    _reactionHoverFadeController.dispose();
    _reactionFocusFadeController.dispose();
    super.dispose();
  }

  /// The most recent [Offset] at which a pointer touched the Toggleable.
213
  ///
214 215 216 217 218 219 220 221 222 223 224 225 226 227 228
  /// 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? _]) {
229
    if (!isInteractive) {
230
      return;
231
    }
232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262
    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();
      }
    }
263 264
  }

265 266 267 268 269 270 271 272 273 274 275 276 277
  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.
278
  ///
279 280 281 282 283 284 285 286 287 288
  /// 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,
289
    if (value ?? true) MaterialState.selected,
290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335
  };

  /// 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,
    bool autofocus = false,
    required MaterialStateProperty<MouseCursor> mouseCursor,
    required Size size,
    required CustomPainter painter,
  }) {
    return FocusableActionDetector(
      actions: _actionMap,
      focusNode: focusNode,
      autofocus: autofocus,
      enabled: isInteractive,
      onShowFocusHighlight: _handleFocusHighlightChanged,
      onShowHoverHighlight: _handleHoverChanged,
      mouseCursor: mouseCursor.resolve(states),
      child: GestureDetector(
        excludeFromSemantics: !isInteractive,
        onTapDown: _handleTapDown,
        onTap: _handleTap,
        onTapUp: _handleTapEnd,
        onTapCancel: _handleTapEnd,
        child: Semantics(
          enabled: isInteractive,
          child: CustomPaint(
            size: size,
            painter: painter,
          ),
        ),
      ),
    );
336
  }
337
}
338

339 340 341 342 343 344 345
/// 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.
346
abstract class ToggleablePainter extends ChangeNotifier implements CustomPainter {
347
  /// The visual value of the control.
348
  ///
349 350 351 352 353
  /// Usually set to [ToggleableStateMixin.position].
  Animation<double> get position => _position!;
  Animation<double>? _position;
  set position(Animation<double> value) {
    if (value == _position) {
354
      return;
355 356 357 358 359
    }
    _position?.removeListener(notifyListeners);
    value.addListener(notifyListeners);
    _position = value;
    notifyListeners();
360 361
  }

362
  /// The visual value of the radial reaction animation.
363
  ///
364 365 366 367 368
  /// Usually set to [ToggleableStateMixin.reaction].
  Animation<double> get reaction => _reaction!;
  Animation<double>? _reaction;
  set reaction(Animation<double> value) {
    if (value == _reaction) {
369
      return;
370 371 372 373 374
    }
    _reaction?.removeListener(notifyListeners);
    value.addListener(notifyListeners);
    _reaction = value;
    notifyListeners();
375 376
  }

377
  /// Controls the radial reaction's opacity animation for focus changes.
378
  ///
379 380 381 382 383 384 385 386 387 388 389 390 391 392
  /// 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.
393
  ///
394 395 396 397 398
  /// Usually set to [ToggleableStateMixin.reactionHoverFade].
  Animation<double> get reactionHoverFade => _reactionHoverFade!;
  Animation<double>? _reactionHoverFade;
  set reactionHoverFade(Animation<double> value) {
    if (value == _reactionHoverFade) {
399
      return;
400 401 402 403 404
    }
    _reactionHoverFade?.removeListener(notifyListeners);
    value.addListener(notifyListeners);
    _reactionHoverFade = value;
    notifyListeners();
405 406
  }

407 408
  /// The color that should be used in the active state (i.e., when
  /// [ToggleableStateMixin.value] is true).
409
  ///
410 411 412 413 414 415 416 417 418 419 420 421 422
  /// 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).
423
  ///
424 425 426 427 428
  /// For example, a checkbox should use this color when unchecked.
  Color get inactiveColor => _inactiveColor!;
  Color? _inactiveColor;
  set inactiveColor(Color value) {
    if (_inactiveColor == value) {
429
      return;
430 431 432
    }
    _inactiveColor = value;
    notifyListeners();
433 434
  }

435 436 437 438 439
  /// 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.
440
  Color get inactiveReactionColor => _inactiveReactionColor!;
441
  Color? _inactiveReactionColor;
442 443
  set inactiveReactionColor(Color value) {
    if (value == _inactiveReactionColor) {
444
      return;
445
    }
446
    _inactiveReactionColor = value;
447
    notifyListeners();
448 449
  }

450 451 452 453 454 455 456 457 458
  /// 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) {
459
      return;
460 461 462
    }
    _reactionColor = value;
    notifyListeners();
463 464
  }

465
  /// The color that should be used for the reaction when [isHovered] is true.
466
  ///
467 468 469 470 471 472
  /// 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) {
Hixie's avatar
Hixie committed
473 474
      return;
    }
475 476
    _hoverColor = value;
    notifyListeners();
Hixie's avatar
Hixie committed
477
  }
478

479
  /// The color that should be used for the reaction when [isFocused] is true.
480
  ///
481 482 483 484 485 486 487
  /// 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;
488
    }
489 490
    _focusColor = value;
    notifyListeners();
491 492
  }

493 494 495 496 497 498
  /// The splash radius for the radial reaction.
  double get splashRadius => _splashRadius!;
  double? _splashRadius;
  set splashRadius(double value) {
    if (value == _splashRadius) {
      return;
499
    }
500 501
    _splashRadius = value;
    notifyListeners();
502 503
  }

504 505 506 507 508 509 510 511 512
  /// 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) {
513 514
      return;
    }
515 516
    _downPosition = value;
    notifyListeners();
517 518
  }

519 520 521 522 523 524 525 526 527
  /// 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();
528 529
  }

530 531 532 533 534 535 536 537 538
  /// 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();
539 540
  }

541 542 543 544
  /// 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
545 546 547 548 549 550 551
  /// [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) {
552 553
      final Paint reactionPaint = Paint()
        ..color = Color.lerp(
554
          Color.lerp(
555
            Color.lerp(inactiveReactionColor, reactionColor, position.value),
556
            hoverColor,
557
            reactionHoverFade.value,
558
          ),
559
          focusColor,
560
          reactionFocusFade.value,
561
        )!;
562
      final Offset center = Offset.lerp(downPosition ?? origin, origin, reaction.value)!;
563 564 565 566
      final Animatable<double> radialReactionRadiusTween = Tween<double>(
        begin: 0.0,
        end: splashRadius,
      );
567
      final double reactionRadius = isFocused || isHovered
568
          ? splashRadius
569
          : radialReactionRadiusTween.evaluate(reaction);
570 571 572
      if (reactionRadius > 0.0) {
        canvas.drawCircle(center + offset, reactionRadius, reactionPaint);
      }
573 574
    }
  }
Hixie's avatar
Hixie committed
575

576

577 578 579 580 581 582 583
  @override
  void dispose() {
    _position?.removeListener(notifyListeners);
    _reaction?.removeListener(notifyListeners);
    _reactionFocusFade?.removeListener(notifyListeners);
    _reactionHoverFade?.removeListener(notifyListeners);
    super.dispose();
584
  }
585 586

  @override
587 588 589 590 591 592 593 594 595 596
  bool shouldRepaint(covariant CustomPainter oldDelegate) => true;

  @override
  bool? hitTest(Offset position) => null;

  @override
  SemanticsBuilderCallback? get semanticsBuilder => null;

  @override
  bool shouldRebuildSemantics(covariant CustomPainter oldDelegate) => false;
Ian Hickson's avatar
Ian Hickson committed
597 598 599

  @override
  String toString() => describeIdentity(this);
600
}