switch.dart 17.9 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 6 7
// Examples can assume:
// bool _giveVerse = false;

8 9
import 'dart:ui' show lerpDouble;

xster's avatar
xster committed
10
import 'package:flutter/foundation.dart';
11 12
import 'package:flutter/gestures.dart';
import 'package:flutter/rendering.dart';
13
import 'package:flutter/services.dart';
14
import 'package:flutter/widgets.dart';
15

xster's avatar
xster committed
16
import 'colors.dart';
17 18
import 'thumb_painter.dart';

19
// Examples can assume:
20
// bool _lights = false;
21 22
// void setState(VoidCallback fn) { }

23 24 25 26 27 28 29 30 31
/// An iOS-style switch.
///
/// Used to toggle the on/off state of a single setting.
///
/// The switch itself does not maintain any state. Instead, when the state of
/// the switch changes, the widget calls the [onChanged] callback. Most widgets
/// that use a switch will listen for the [onChanged] callback and rebuild the
/// switch with a new [value] to update the visual appearance of the switch.
///
32 33 34 35 36 37 38
/// {@tool dartpad}
/// This example shows a toggleable [CupertinoSwitch]. When the thumb slides to
/// the other side of the track, the switch is toggled between on/off.
///
/// ** See code in examples/api/lib/cupertino/switch/cupertino_switch.0.dart **
/// {@end-tool}
///
39
/// {@tool snippet}
40 41 42 43 44 45
///
/// This sample shows how to use a [CupertinoSwitch] in a [ListTile]. The
/// [MergeSemantics] is used to turn the entire [ListTile] into a single item
/// for accessibility tools.
///
/// ```dart
46 47
/// MergeSemantics(
///   child: ListTile(
48
///     title: const Text('Lights'),
49
///     trailing: CupertinoSwitch(
50 51 52 53 54 55 56
///       value: _lights,
///       onChanged: (bool value) { setState(() { _lights = value; }); },
///     ),
///     onTap: () { setState(() { _lights = !_lights; }); },
///   ),
/// )
/// ```
57
/// {@end-tool}
58
///
59 60
/// See also:
///
61
///  * [Switch], the Material Design equivalent.
62
///  * <https://developer.apple.com/ios/human-interface-guidelines/controls/switches/>
63
class CupertinoSwitch extends StatefulWidget {
Adam Barth's avatar
Adam Barth committed
64
  /// Creates an iOS-style switch.
65
  ///
66 67
  /// The [value] parameter must not be null.
  /// The [dragStartBehavior] parameter defaults to [DragStartBehavior.start] and must not be null.
68
  const CupertinoSwitch({
69
    super.key,
70 71
    required this.value,
    required this.onChanged,
72
    this.activeColor,
73
    this.trackColor,
74
    this.thumbColor,
75
    this.dragStartBehavior = DragStartBehavior.start,
76
  }) : assert(value != null),
77
       assert(dragStartBehavior != null);
78 79

  /// Whether this switch is on or off.
80 81
  ///
  /// Must not be null.
82 83 84 85 86 87 88 89
  final bool value;

  /// Called when the user toggles with switch on or off.
  ///
  /// The switch passes the new value to the callback but does not actually
  /// change state until the parent widget rebuilds the switch with the new
  /// value.
  ///
90
  /// If null, the switch will be displayed as disabled, which has a reduced opacity.
91 92 93 94 95 96
  ///
  /// The callback provided to onChanged should update the state of the parent
  /// [StatefulWidget] using the [State.setState] method, so that the parent
  /// gets rebuilt; for example:
  ///
  /// ```dart
97
  /// CupertinoSwitch(
98 99 100 101 102 103
  ///   value: _giveVerse,
  ///   onChanged: (bool newValue) {
  ///     setState(() {
  ///       _giveVerse = newValue;
  ///     });
  ///   },
104
  /// )
105
  /// ```
106
  final ValueChanged<bool>? onChanged;
107 108

  /// The color to use when this switch is on.
109
  ///
110
  /// Defaults to [CupertinoColors.systemGreen] when null and ignores
111
  /// the [CupertinoTheme] in accordance to native iOS behavior.
112
  final Color? activeColor;
113

114 115 116
  /// The color to use for the background when the switch is off.
  ///
  /// Defaults to [CupertinoColors.secondarySystemFill] when null.
117
  final Color? trackColor;
118

119 120 121 122 123
  /// The color to use for the thumb of the switch.
  ///
  /// Defaults to [CupertinoColors.white] when null.
  final Color? thumbColor;

124
  /// {@template flutter.cupertino.CupertinoSwitch.dragStartBehavior}
125 126 127
  /// Determines the way that drag start behavior is handled.
  ///
  /// If set to [DragStartBehavior.start], the drag behavior used to move the
128 129 130
  /// switch from on to off will begin at the position where the drag gesture won
  /// the arena. If set to [DragStartBehavior.down] it will begin at the position
  /// where a down event was first detected.
131 132 133 134 135
  ///
  /// In general, setting this to [DragStartBehavior.start] will make drag
  /// animation smoother and setting it to [DragStartBehavior.down] will make
  /// drag behavior feel slightly more reactive.
  ///
136
  /// By default, the drag start behavior is [DragStartBehavior.start].
137 138 139
  ///
  /// See also:
  ///
140 141 142
  ///  * [DragGestureRecognizer.dragStartBehavior], which gives an example for
  ///    the different behaviors.
  ///
143 144 145
  /// {@endtemplate}
  final DragStartBehavior dragStartBehavior;

146
  @override
147
  State<CupertinoSwitch> createState() => _CupertinoSwitchState();
148 149

  @override
150 151
  void debugFillProperties(DiagnosticPropertiesBuilder properties) {
    super.debugFillProperties(properties);
152 153
    properties.add(FlagProperty('value', value: value, ifTrue: 'on', ifFalse: 'off', showName: true));
    properties.add(ObjectFlagProperty<ValueChanged<bool>>('onChanged', onChanged, ifNull: 'disabled'));
154 155 156 157
  }
}

class _CupertinoSwitchState extends State<CupertinoSwitch> with TickerProviderStateMixin {
158 159
  late TapGestureRecognizer _tap;
  late HorizontalDragGestureRecognizer _drag;
160

161 162
  late AnimationController _positionController;
  late CurvedAnimation position;
163

164 165
  late AnimationController _reactionController;
  late Animation<double> _reaction;
166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211

  bool get isInteractive => widget.onChanged != null;

  // A non-null boolean value that changes to true at the end of a drag if the
  // switch must be animated to the position indicated by the widget's value.
  bool needsPositionAnimation = false;

  @override
  void initState() {
    super.initState();

    _tap = TapGestureRecognizer()
      ..onTapDown = _handleTapDown
      ..onTapUp = _handleTapUp
      ..onTap = _handleTap
      ..onTapCancel = _handleTapCancel;
    _drag = HorizontalDragGestureRecognizer()
      ..onStart = _handleDragStart
      ..onUpdate = _handleDragUpdate
      ..onEnd = _handleDragEnd
      ..dragStartBehavior = widget.dragStartBehavior;

    _positionController = AnimationController(
      duration: _kToggleDuration,
      value: widget.value ? 1.0 : 0.0,
      vsync: this,
    );
    position = CurvedAnimation(
      parent: _positionController,
      curve: Curves.linear,
    );
    _reactionController = AnimationController(
      duration: _kReactionDuration,
      vsync: this,
    );
    _reaction = CurvedAnimation(
      parent: _reactionController,
      curve: Curves.ease,
    );
  }

  @override
  void didUpdateWidget(CupertinoSwitch oldWidget) {
    super.didUpdateWidget(oldWidget);
    _drag.dragStartBehavior = widget.dragStartBehavior;

212
    if (needsPositionAnimation || oldWidget.value != widget.value) {
213
      _resumePositionAnimation(isLinear: needsPositionAnimation);
214
    }
215 216 217 218 219 220 221 222 223 224 225
  }

  // `isLinear` must be true if the position animation is trying to move the
  // thumb to the closest end after the most recent drag animation, so the curve
  // does not change when the controller's value is not 0 or 1.
  //
  // It can be set to false when it's an implicit animation triggered by
  // widget.value changes.
  void _resumePositionAnimation({ bool isLinear = true }) {
    needsPositionAnimation = false;
    position
226 227
      ..curve = isLinear ? Curves.linear : Curves.ease
      ..reverseCurve = isLinear ? Curves.linear : Curves.ease.flipped;
228
    if (widget.value) {
229
      _positionController.forward();
230
    } else {
231
      _positionController.reverse();
232
    }
233 234 235
  }

  void _handleTapDown(TapDownDetails details) {
236
    if (isInteractive) {
237
      needsPositionAnimation = false;
238
    }
239 240 241 242 243
      _reactionController.forward();
  }

  void _handleTap() {
    if (isInteractive) {
244
      widget.onChanged!(!widget.value);
245 246 247 248 249 250 251 252 253 254 255 256
      _emitVibration();
    }
  }

  void _handleTapUp(TapUpDetails details) {
    if (isInteractive) {
      needsPositionAnimation = false;
      _reactionController.reverse();
    }
  }

  void _handleTapCancel() {
257
    if (isInteractive) {
258
      _reactionController.reverse();
259
    }
260 261 262 263 264 265 266 267 268 269 270 271 272
  }

  void _handleDragStart(DragStartDetails details) {
    if (isInteractive) {
      needsPositionAnimation = false;
      _reactionController.forward();
      _emitVibration();
    }
  }

  void _handleDragUpdate(DragUpdateDetails details) {
    if (isInteractive) {
      position
273 274 275
        ..curve = Curves.linear
        ..reverseCurve = Curves.linear;
      final double delta = details.primaryDelta! / _kTrackInnerLength;
276
      switch (Directionality.of(context)) {
277 278 279 280 281 282 283 284 285 286 287 288 289 290
        case TextDirection.rtl:
          _positionController.value -= delta;
          break;
        case TextDirection.ltr:
          _positionController.value += delta;
          break;
      }
    }
  }

  void _handleDragEnd(DragEndDetails details) {
    // Deferring the animation to the next build phase.
    setState(() { needsPositionAnimation = true; });
    // Call onChanged when the user's intent to change value is clear.
291
    if (position.value >= 0.5 != widget.value) {
292
      widget.onChanged!(!widget.value);
293
    }
294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310
    _reactionController.reverse();
  }

  void _emitVibration() {
    switch (defaultTargetPlatform) {
      case TargetPlatform.iOS:
        HapticFeedback.lightImpact();
        break;
      case TargetPlatform.android:
      case TargetPlatform.fuchsia:
      case TargetPlatform.linux:
      case TargetPlatform.macOS:
      case TargetPlatform.windows:
        break;
    }
  }

311 312
  @override
  Widget build(BuildContext context) {
313
    if (needsPositionAnimation) {
314
      _resumePositionAnimation();
315
    }
316 317 318 319 320 321 322 323 324 325 326 327 328 329 330
    return MouseRegion(
      cursor: isInteractive && kIsWeb ? SystemMouseCursors.click : MouseCursor.defer,
      child: Opacity(
        opacity: widget.onChanged == null ? _kCupertinoSwitchDisabledOpacity : 1.0,
        child: _CupertinoSwitchRenderObjectWidget(
          value: widget.value,
          activeColor: CupertinoDynamicColor.resolve(
            widget.activeColor ?? CupertinoColors.systemGreen,
            context,
          ),
          trackColor: CupertinoDynamicColor.resolve(widget.trackColor ?? CupertinoColors.secondarySystemFill, context),
          thumbColor: CupertinoDynamicColor.resolve(widget.thumbColor ?? CupertinoColors.white, context),
          onChanged: widget.onChanged,
          textDirection: Directionality.of(context),
          state: this,
331
        ),
332
      ),
333 334
    );
  }
335 336 337 338 339 340 341 342 343 344

  @override
  void dispose() {
    _tap.dispose();
    _drag.dispose();

    _positionController.dispose();
    _reactionController.dispose();
    super.dispose();
  }
345 346 347
}

class _CupertinoSwitchRenderObjectWidget extends LeafRenderObjectWidget {
348
  const _CupertinoSwitchRenderObjectWidget({
349 350 351
    required this.value,
    required this.activeColor,
    required this.trackColor,
352
    required this.thumbColor,
353 354 355
    required this.onChanged,
    required this.textDirection,
    required this.state,
356
  });
357 358 359

  final bool value;
  final Color activeColor;
360
  final Color trackColor;
361
  final Color thumbColor;
362
  final ValueChanged<bool>? onChanged;
363 364
  final _CupertinoSwitchState state;
  final TextDirection textDirection;
365 366 367

  @override
  _RenderCupertinoSwitch createRenderObject(BuildContext context) {
368
    return _RenderCupertinoSwitch(
369 370
      value: value,
      activeColor: activeColor,
371
      trackColor: trackColor,
372
      thumbColor: thumbColor,
373
      onChanged: onChanged,
374 375
      textDirection: textDirection,
      state: state,
376 377 378 379 380
    );
  }

  @override
  void updateRenderObject(BuildContext context, _RenderCupertinoSwitch renderObject) {
381
    assert(renderObject._state == state);
382 383 384
    renderObject
      ..value = value
      ..activeColor = activeColor
385
      ..trackColor = trackColor
386
      ..thumbColor = thumbColor
387
      ..onChanged = onChanged
388
      ..textDirection = textDirection;
389 390 391
  }
}

392 393
const double _kTrackWidth = 51.0;
const double _kTrackHeight = 31.0;
394 395 396 397
const double _kTrackRadius = _kTrackHeight / 2.0;
const double _kTrackInnerStart = _kTrackHeight / 2.0;
const double _kTrackInnerEnd = _kTrackWidth - _kTrackInnerStart;
const double _kTrackInnerLength = _kTrackInnerEnd - _kTrackInnerStart;
398 399
const double _kSwitchWidth = 59.0;
const double _kSwitchHeight = 39.0;
400 401
// Opacity of a disabled switch, as eye-balled from iOS Simulator on Mac.
const double _kCupertinoSwitchDisabledOpacity = 0.5;
402

403 404
const Duration _kReactionDuration = Duration(milliseconds: 300);
const Duration _kToggleDuration = Duration(milliseconds: 200);
405

406
class _RenderCupertinoSwitch extends RenderConstrainedBox {
407
  _RenderCupertinoSwitch({
408 409 410
    required bool value,
    required Color activeColor,
    required Color trackColor,
411
    required Color thumbColor,
412 413 414
    ValueChanged<bool>? onChanged,
    required TextDirection textDirection,
    required _CupertinoSwitchState state,
415 416
  }) : assert(value != null),
       assert(activeColor != null),
417
       assert(state != null),
418 419
       _value = value,
       _activeColor = activeColor,
420
       _trackColor = trackColor,
421
       _thumbPainter = CupertinoThumbPainter.switchThumb(color: thumbColor),
422
       _onChanged = onChanged,
423
       _textDirection = textDirection,
424
       _state = state,
425
       super(additionalConstraints: const BoxConstraints.tightFor(width: _kSwitchWidth, height: _kSwitchHeight)) {
426 427
         state.position.addListener(markNeedsPaint);
         state._reaction.addListener(markNeedsPaint);
428 429
  }

430
  final _CupertinoSwitchState _state;
431 432 433 434 435

  bool get value => _value;
  bool _value;
  set value(bool value) {
    assert(value != null);
436
    if (value == _value) {
437
      return;
438
    }
439
    _value = value;
440
    markNeedsSemanticsUpdate();
441 442 443 444 445 446
  }

  Color get activeColor => _activeColor;
  Color _activeColor;
  set activeColor(Color value) {
    assert(value != null);
447
    if (value == _activeColor) {
448
      return;
449
    }
450 451 452 453
    _activeColor = value;
    markNeedsPaint();
  }

454 455 456 457
  Color get trackColor => _trackColor;
  Color _trackColor;
  set trackColor(Color value) {
    assert(value != null);
458
    if (value == _trackColor) {
459
      return;
460
    }
461 462 463 464
    _trackColor = value;
    markNeedsPaint();
  }

465 466 467 468
  Color get thumbColor => _thumbPainter.color;
  CupertinoThumbPainter _thumbPainter;
  set thumbColor(Color value) {
    assert(value != null);
469
    if (value == thumbColor) {
470
      return;
471
    }
472 473 474 475
    _thumbPainter = CupertinoThumbPainter.switchThumb(color: value);
    markNeedsPaint();
  }

476 477 478
  ValueChanged<bool>? get onChanged => _onChanged;
  ValueChanged<bool>? _onChanged;
  set onChanged(ValueChanged<bool>? value) {
479
    if (value == _onChanged) {
480
      return;
481
    }
482 483 484 485
    final bool wasInteractive = isInteractive;
    _onChanged = value;
    if (wasInteractive != isInteractive) {
      markNeedsPaint();
486
      markNeedsSemanticsUpdate();
487 488 489
    }
  }

490 491 492 493
  TextDirection get textDirection => _textDirection;
  TextDirection _textDirection;
  set textDirection(TextDirection value) {
    assert(value != null);
494
    if (_textDirection == value) {
495
      return;
496
    }
497 498 499 500
    _textDirection = value;
    markNeedsPaint();
  }

501 502 503
  bool get isInteractive => onChanged != null;

  @override
504
  bool hitTestSelf(Offset position) => true;
505 506 507 508 509

  @override
  void handleEvent(PointerEvent event, BoxHitTestEntry entry) {
    assert(debugHandleEvent(event, entry));
    if (event is PointerDownEvent && isInteractive) {
510 511
      _state._drag.addPointer(event);
      _state._tap.addPointer(event);
512 513 514 515
    }
  }

  @override
516 517
  void describeSemanticsConfiguration(SemanticsConfiguration config) {
    super.describeSemanticsConfiguration(config);
518

519
    if (isInteractive) {
520
      config.onTap = _state._handleTap;
521
    }
522 523 524

    config.isEnabled = isInteractive;
    config.isToggled = _value;
525 526 527 528 529 530
  }

  @override
  void paint(PaintingContext context, Offset offset) {
    final Canvas canvas = context.canvas;

531 532
    final double currentValue = _state.position.value;
    final double currentReactionValue = _state._reaction.value;
533

534
    final double visualPosition;
535 536 537 538 539 540 541 542 543
    switch (textDirection) {
      case TextDirection.rtl:
        visualPosition = 1.0 - currentValue;
        break;
      case TextDirection.ltr:
        visualPosition = currentValue;
        break;
    }

544
    final Paint paint = Paint()
545
      ..color = Color.lerp(trackColor, activeColor, currentValue)!;
546

547
    final Rect trackRect = Rect.fromLTWH(
548 549 550
        offset.dx + (size.width - _kTrackWidth) / 2.0,
        offset.dy + (size.height - _kTrackHeight) / 2.0,
        _kTrackWidth,
551
        _kTrackHeight,
552
    );
553 554
    final RRect trackRRect = RRect.fromRectAndRadius(trackRect, const Radius.circular(_kTrackRadius));
    canvas.drawRRect(trackRRect, paint);
555

556
    final double currentThumbExtension = CupertinoThumbPainter.extension * currentReactionValue;
557
    final double thumbLeft = lerpDouble(
558 559
      trackRect.left + _kTrackInnerStart - CupertinoThumbPainter.radius,
      trackRect.left + _kTrackInnerEnd - CupertinoThumbPainter.radius - currentThumbExtension,
560
      visualPosition,
561
    )!;
562
    final double thumbRight = lerpDouble(
563 564
      trackRect.left + _kTrackInnerStart + CupertinoThumbPainter.radius + currentThumbExtension,
      trackRect.left + _kTrackInnerEnd + CupertinoThumbPainter.radius,
565
      visualPosition,
566
    )!;
567
    final double thumbCenterY = offset.dy + size.height / 2.0;
568
    final Rect thumbBounds = Rect.fromLTRB(
569 570 571 572
      thumbLeft,
      thumbCenterY - CupertinoThumbPainter.radius,
      thumbRight,
      thumbCenterY + CupertinoThumbPainter.radius,
573 574
    );

575
    _clipRRectLayer.layer = context.pushClipRRect(needsCompositing, Offset.zero, thumbBounds, trackRRect, (PaintingContext innerContext, Offset offset) {
576
      _thumbPainter.paint(innerContext.canvas, thumbBounds);
577
    }, oldLayer: _clipRRectLayer.layer);
578 579
  }

580 581 582 583 584 585 586
  final LayerHandle<ClipRRectLayer> _clipRRectLayer = LayerHandle<ClipRRectLayer>();

  @override
  void dispose() {
    _clipRRectLayer.layer = null;
    super.dispose();
  }
587

588
  @override
589
  void debugFillProperties(DiagnosticPropertiesBuilder description) {
590
    super.debugFillProperties(description);
591 592
    description.add(FlagProperty('value', value: value, ifTrue: 'checked', ifFalse: 'unchecked', showName: true));
    description.add(FlagProperty('isInteractive', value: isInteractive, ifTrue: 'enabled', ifFalse: 'disabled', showName: true, defaultValue: true));
593 594
  }
}