toggleable.dart 19.7 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.

5
import 'package:flutter/rendering.dart';
6
import 'package:flutter/widgets.dart';
7

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

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

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

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

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

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

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

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

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

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

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

119 120 121 122 123
  /// 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.
124 125 126 127
  ///
  /// When the value changes, this object starts the [positionController] and
  /// [position] animations to animate the visual appearance of the control to
  /// the new value.
128 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
  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() {
184
    if (tristate) {
185 186 187 188 189 190
      if (value == null)
        _positionController.value = 0.0;
      if (value != false)
        _positionController.forward();
      else
        _positionController.reverse();
191 192
    } else {
      if (value == true)
193
        _positionController.forward();
194 195
      else
        _positionController.reverse();
196 197 198
    }
  }

199 200 201 202 203 204 205 206 207 208
  @override
  void dispose() {
    _positionController.dispose();
    _reactionController.dispose();
    _reactionHoverFadeController.dispose();
    _reactionFocusFadeController.dispose();
    super.dispose();
  }

  /// The most recent [Offset] at which a pointer touched the Toggleable.
209
  ///
210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225
  /// 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)
226
      return;
227 228 229 230 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
    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();
      }
    }
258 259
  }

260 261 262 263 264 265 266 267 268 269 270 271 272
  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.
273
  ///
274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 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
  /// 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 != false) 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,
    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,
          ),
        ),
      ),
    );
331
  }
332
}
333

334 335 336 337 338 339 340 341 342
/// 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.
343
  ///
344 345 346 347 348
  /// Usually set to [ToggleableStateMixin.position].
  Animation<double> get position => _position!;
  Animation<double>? _position;
  set position(Animation<double> value) {
    if (value == _position) {
349
      return;
350 351 352 353 354
    }
    _position?.removeListener(notifyListeners);
    value.addListener(notifyListeners);
    _position = value;
    notifyListeners();
355 356
  }

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

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

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

430 431 432 433 434
  /// 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.
435
  Color get inactiveReactionColor => _inactiveReactionColor!;
436
  Color? _inactiveReactionColor;
437 438
  set inactiveReactionColor(Color value) {
    if (value == _inactiveReactionColor) {
439
      return;
440
    }
441
    _inactiveReactionColor = value;
442
    notifyListeners();
443 444
  }

445 446 447 448 449 450 451 452 453
  /// 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) {
454
      return;
455 456 457
    }
    _reactionColor = value;
    notifyListeners();
458 459
  }

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

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

488 489 490 491 492 493
  /// The splash radius for the radial reaction.
  double get splashRadius => _splashRadius!;
  double? _splashRadius;
  set splashRadius(double value) {
    if (value == _splashRadius) {
      return;
494
    }
495 496
    _splashRadius = value;
    notifyListeners();
497 498
  }

499 500 501 502 503 504 505 506 507
  /// 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) {
508 509
      return;
    }
510 511
    _downPosition = value;
    notifyListeners();
512 513
  }

514 515 516 517 518 519 520 521 522
  /// 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();
523 524
  }

525 526 527 528 529 530 531 532 533
  /// 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();
534 535
  }

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

571

572 573 574 575 576 577 578
  @override
  void dispose() {
    _position?.removeListener(notifyListeners);
    _reaction?.removeListener(notifyListeners);
    _reactionFocusFade?.removeListener(notifyListeners);
    _reactionHoverFade?.removeListener(notifyListeners);
    super.dispose();
579
  }
580 581

  @override
582 583 584 585 586 587 588 589 590 591
  bool shouldRepaint(covariant CustomPainter oldDelegate) => true;

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

  @override
  SemanticsBuilderCallback? get semanticsBuilder => null;

  @override
  bool shouldRebuildSemantics(covariant CustomPainter oldDelegate) => false;
592
}