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

import 'dart:ui' show lerpDouble;

xster's avatar
xster committed
7
import 'package:flutter/foundation.dart';
8 9 10
import 'package:flutter/gestures.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
11
import 'package:flutter/services.dart';
12

xster's avatar
xster committed
13
import 'colors.dart';
14 15
import 'thumb_painter.dart';

16 17 18 19
// Examples can assume:
// bool _lights;
// void setState(VoidCallback fn) { }

20 21 22 23 24 25 26 27 28
/// 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.
///
29
/// {@tool sample}
30 31 32 33 34 35
///
/// 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
36 37 38 39
/// MergeSemantics(
///   child: ListTile(
///     title: Text('Lights'),
///     trailing: CupertinoSwitch(
40 41 42 43 44 45 46
///       value: _lights,
///       onChanged: (bool value) { setState(() { _lights = value; }); },
///     ),
///     onTap: () { setState(() { _lights = !_lights; }); },
///   ),
/// )
/// ```
47
/// {@end-tool}
48
///
49 50
/// See also:
///
51
///  * [Switch], the material design equivalent.
52
///  * <https://developer.apple.com/ios/human-interface-guidelines/controls/switches/>
53
class CupertinoSwitch extends StatefulWidget {
Adam Barth's avatar
Adam Barth committed
54
  /// Creates an iOS-style switch.
55
  ///
56 57
  /// The [value] parameter must not be null.
  /// The [dragStartBehavior] parameter defaults to [DragStartBehavior.start] and must not be null.
58
  const CupertinoSwitch({
59 60 61
    Key key,
    @required this.value,
    @required this.onChanged,
62
    this.activeColor,
63
    this.trackColor,
64
    this.dragStartBehavior = DragStartBehavior.start,
65 66
  }) : assert(value != null),
       assert(dragStartBehavior != null),
67
       super(key: key);
68 69

  /// Whether this switch is on or off.
70 71
  ///
  /// Must not be null.
72 73 74 75 76 77 78 79
  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.
  ///
80
  /// If null, the switch will be displayed as disabled, which has a reduced opacity.
81 82 83 84 85 86
  ///
  /// 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
87
  /// CupertinoSwitch(
88 89 90 91 92 93
  ///   value: _giveVerse,
  ///   onChanged: (bool newValue) {
  ///     setState(() {
  ///       _giveVerse = newValue;
  ///     });
  ///   },
94
  /// )
95 96 97 98
  /// ```
  final ValueChanged<bool> onChanged;

  /// The color to use when this switch is on.
99
  ///
100
  /// Defaults to [CupertinoColors.systemGreen] when null and ignores
101
  /// the [CupertinoTheme] in accordance to native iOS behavior.
102 103
  final Color activeColor;

104 105 106 107 108
  /// The color to use for the background when the switch is off.
  ///
  /// Defaults to [CupertinoColors.secondarySystemFill] when null.
  final Color trackColor;

109 110 111 112 113 114 115 116 117 118 119 120
  /// {@template flutter.cupertino.switch.dragStartBehavior}
  /// Determines the way that drag start behavior is handled.
  ///
  /// If set to [DragStartBehavior.start], the drag behavior used to move the
  /// switch from on to off will begin upon the detection of a drag gesture. If
  /// set to [DragStartBehavior.down] it will begin when a down event is first
  /// detected.
  ///
  /// 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.
  ///
121
  /// By default, the drag start behavior is [DragStartBehavior.start].
122 123 124
  ///
  /// See also:
  ///
125 126 127
  ///  * [DragGestureRecognizer.dragStartBehavior], which gives an example for
  ///    the different behaviors.
  ///
128 129 130
  /// {@endtemplate}
  final DragStartBehavior dragStartBehavior;

131
  @override
132
  _CupertinoSwitchState createState() => _CupertinoSwitchState();
133 134

  @override
135 136
  void debugFillProperties(DiagnosticPropertiesBuilder properties) {
    super.debugFillProperties(properties);
137 138
    properties.add(FlagProperty('value', value: value, ifTrue: 'on', ifFalse: 'off', showName: true));
    properties.add(ObjectFlagProperty<ValueChanged<bool>>('onChanged', onChanged, ifNull: 'disabled'));
139 140 141 142 143 144
  }
}

class _CupertinoSwitchState extends State<CupertinoSwitch> with TickerProviderStateMixin {
  @override
  Widget build(BuildContext context) {
145 146 147 148
    return Opacity(
      opacity: widget.onChanged == null ? _kCupertinoSwitchDisabledOpacity : 1.0,
      child: _CupertinoSwitchRenderObjectWidget(
        value: widget.value,
149
        activeColor: CupertinoDynamicColor.resolve(
150
          widget.activeColor ?? CupertinoColors.systemGreen,
151 152
          context,
        ),
153
        trackColor: CupertinoDynamicColor.resolve(widget.trackColor ?? CupertinoColors.secondarySystemFill, context),
154 155 156 157
        onChanged: widget.onChanged,
        vsync: this,
        dragStartBehavior: widget.dragStartBehavior,
      ),
158 159 160 161 162
    );
  }
}

class _CupertinoSwitchRenderObjectWidget extends LeafRenderObjectWidget {
163
  const _CupertinoSwitchRenderObjectWidget({
164 165 166
    Key key,
    this.value,
    this.activeColor,
167
    this.trackColor,
168 169
    this.onChanged,
    this.vsync,
170
    this.dragStartBehavior = DragStartBehavior.start,
171 172 173 174
  }) : super(key: key);

  final bool value;
  final Color activeColor;
175
  final Color trackColor;
176 177
  final ValueChanged<bool> onChanged;
  final TickerProvider vsync;
178
  final DragStartBehavior dragStartBehavior;
179 180 181

  @override
  _RenderCupertinoSwitch createRenderObject(BuildContext context) {
182
    return _RenderCupertinoSwitch(
183 184
      value: value,
      activeColor: activeColor,
185
      trackColor: trackColor,
186
      onChanged: onChanged,
187
      textDirection: Directionality.of(context),
188
      vsync: vsync,
189
      dragStartBehavior: dragStartBehavior,
190 191 192 193 194 195 196 197
    );
  }

  @override
  void updateRenderObject(BuildContext context, _RenderCupertinoSwitch renderObject) {
    renderObject
      ..value = value
      ..activeColor = activeColor
198
      ..trackColor = trackColor
199
      ..onChanged = onChanged
200
      ..textDirection = Directionality.of(context)
201 202
      ..vsync = vsync
      ..dragStartBehavior = dragStartBehavior;
203 204 205
  }
}

206 207
const double _kTrackWidth = 51.0;
const double _kTrackHeight = 31.0;
208 209 210 211
const double _kTrackRadius = _kTrackHeight / 2.0;
const double _kTrackInnerStart = _kTrackHeight / 2.0;
const double _kTrackInnerEnd = _kTrackWidth - _kTrackInnerStart;
const double _kTrackInnerLength = _kTrackInnerEnd - _kTrackInnerStart;
212 213
const double _kSwitchWidth = 59.0;
const double _kSwitchHeight = 39.0;
214 215
// Opacity of a disabled switch, as eye-balled from iOS Simulator on Mac.
const double _kCupertinoSwitchDisabledOpacity = 0.5;
216

217 218
const Duration _kReactionDuration = Duration(milliseconds: 300);
const Duration _kToggleDuration = Duration(milliseconds: 200);
219

220
class _RenderCupertinoSwitch extends RenderConstrainedBox {
221
  _RenderCupertinoSwitch({
222 223
    @required bool value,
    @required Color activeColor,
224
    @required Color trackColor,
225
    ValueChanged<bool> onChanged,
226
    @required TextDirection textDirection,
227
    @required TickerProvider vsync,
228
    DragStartBehavior dragStartBehavior = DragStartBehavior.start,
229 230 231 232 233
  }) : assert(value != null),
       assert(activeColor != null),
       assert(vsync != null),
       _value = value,
       _activeColor = activeColor,
234
       _trackColor = trackColor,
235
       _onChanged = onChanged,
236
       _textDirection = textDirection,
237 238
       _vsync = vsync,
       super(additionalConstraints: const BoxConstraints.tightFor(width: _kSwitchWidth, height: _kSwitchHeight)) {
239
    _tap = TapGestureRecognizer()
240 241 242 243
      ..onTapDown = _handleTapDown
      ..onTap = _handleTap
      ..onTapUp = _handleTapUp
      ..onTapCancel = _handleTapCancel;
244
    _drag = HorizontalDragGestureRecognizer()
245 246
      ..onStart = _handleDragStart
      ..onUpdate = _handleDragUpdate
247 248
      ..onEnd = _handleDragEnd
      ..dragStartBehavior = dragStartBehavior;
249
    _positionController = AnimationController(
250 251 252 253
      duration: _kToggleDuration,
      value: value ? 1.0 : 0.0,
      vsync: vsync,
    );
254
    _position = CurvedAnimation(
255 256 257 258
      parent: _positionController,
      curve: Curves.linear,
    )..addListener(markNeedsPaint)
     ..addStatusListener(_handlePositionStateChanged);
259
    _reactionController = AnimationController(
260 261 262
      duration: _kReactionDuration,
      vsync: vsync,
    );
263
    _reaction = CurvedAnimation(
264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281
      parent: _reactionController,
      curve: Curves.ease,
    )..addListener(markNeedsPaint);
  }

  AnimationController _positionController;
  CurvedAnimation _position;

  AnimationController _reactionController;
  Animation<double> _reaction;

  bool get value => _value;
  bool _value;
  set value(bool value) {
    assert(value != null);
    if (value == _value)
      return;
    _value = value;
282
    markNeedsSemanticsUpdate();
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
    _position
      ..curve = Curves.ease
      ..reverseCurve = Curves.ease.flipped;
    if (value)
      _positionController.forward();
    else
      _positionController.reverse();
  }

  TickerProvider get vsync => _vsync;
  TickerProvider _vsync;
  set vsync(TickerProvider value) {
    assert(value != null);
    if (value == _vsync)
      return;
    _vsync = value;
    _positionController.resync(vsync);
    _reactionController.resync(vsync);
  }

  Color get activeColor => _activeColor;
  Color _activeColor;
  set activeColor(Color value) {
    assert(value != null);
    if (value == _activeColor)
      return;
    _activeColor = value;
    markNeedsPaint();
  }

313 314 315 316 317 318 319 320 321 322
  Color get trackColor => _trackColor;
  Color _trackColor;
  set trackColor(Color value) {
    assert(value != null);
    if (value == _trackColor)
      return;
    _trackColor = value;
    markNeedsPaint();
  }

323 324 325 326 327 328 329 330 331
  ValueChanged<bool> get onChanged => _onChanged;
  ValueChanged<bool> _onChanged;
  set onChanged(ValueChanged<bool> value) {
    if (value == _onChanged)
      return;
    final bool wasInteractive = isInteractive;
    _onChanged = value;
    if (wasInteractive != isInteractive) {
      markNeedsPaint();
332
      markNeedsSemanticsUpdate();
333 334 335
    }
  }

336 337 338 339 340 341 342 343 344 345
  TextDirection get textDirection => _textDirection;
  TextDirection _textDirection;
  set textDirection(TextDirection value) {
    assert(value != null);
    if (_textDirection == value)
      return;
    _textDirection = value;
    markNeedsPaint();
  }

346 347 348 349 350 351 352 353
  DragStartBehavior get dragStartBehavior => _drag.dragStartBehavior;
  set dragStartBehavior(DragStartBehavior value) {
    assert(value != null);
    if (_drag.dragStartBehavior == value)
      return;
    _drag.dragStartBehavior = value;
  }

354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403
  bool get isInteractive => onChanged != null;

  TapGestureRecognizer _tap;
  HorizontalDragGestureRecognizer _drag;

  @override
  void attach(PipelineOwner owner) {
    super.attach(owner);
    if (value)
      _positionController.forward();
    else
      _positionController.reverse();
    if (isInteractive) {
      switch (_reactionController.status) {
        case AnimationStatus.forward:
          _reactionController.forward();
          break;
        case AnimationStatus.reverse:
          _reactionController.reverse();
          break;
        case AnimationStatus.dismissed:
        case AnimationStatus.completed:
          // nothing to do
          break;
      }
    }
  }

  @override
  void detach() {
    _positionController.stop();
    _reactionController.stop();
    super.detach();
  }

  void _handlePositionStateChanged(AnimationStatus status) {
    if (isInteractive) {
      if (status == AnimationStatus.completed && !_value)
        onChanged(true);
      else if (status == AnimationStatus.dismissed && _value)
        onChanged(false);
    }
  }

  void _handleTapDown(TapDownDetails details) {
    if (isInteractive)
      _reactionController.forward();
  }

  void _handleTap() {
404
    if (isInteractive) {
405
      onChanged(!_value);
406 407
      _emitVibration();
    }
408 409 410 411 412 413 414 415 416 417 418 419 420
  }

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

  void _handleTapCancel() {
    if (isInteractive)
      _reactionController.reverse();
  }

  void _handleDragStart(DragStartDetails details) {
421
    if (isInteractive) {
422
      _reactionController.forward();
423 424
      _emitVibration();
    }
425 426 427 428 429 430 431
  }

  void _handleDragUpdate(DragUpdateDetails details) {
    if (isInteractive) {
      _position
        ..curve = null
        ..reverseCurve = null;
432 433 434 435 436 437 438 439 440
      final double delta = details.primaryDelta / _kTrackInnerLength;
      switch (textDirection) {
        case TextDirection.rtl:
          _positionController.value -= delta;
          break;
        case TextDirection.ltr:
          _positionController.value += delta;
          break;
      }
441 442 443 444 445 446 447 448 449 450 451
    }
  }

  void _handleDragEnd(DragEndDetails details) {
    if (_position.value >= 0.5)
      _positionController.forward();
    else
      _positionController.reverse();
    _reactionController.reverse();
  }

452
  void _emitVibration() {
453
    switch (defaultTargetPlatform) {
454 455 456 457
      case TargetPlatform.iOS:
        HapticFeedback.lightImpact();
        break;
      case TargetPlatform.android:
458 459
      case TargetPlatform.fuchsia:
      case TargetPlatform.macOS:
460 461 462 463
        break;
    }
  }

464
  @override
465
  bool hitTestSelf(Offset position) => true;
466 467 468 469 470 471 472 473 474 475 476

  @override
  void handleEvent(PointerEvent event, BoxHitTestEntry entry) {
    assert(debugHandleEvent(event, entry));
    if (event is PointerDownEvent && isInteractive) {
      _drag.addPointer(event);
      _tap.addPointer(event);
    }
  }

  @override
477 478
  void describeSemanticsConfiguration(SemanticsConfiguration config) {
    super.describeSemanticsConfiguration(config);
479 480

    if (isInteractive)
481
      config.onTap = _handleTap;
482 483 484

    config.isEnabled = isInteractive;
    config.isToggled = _value;
485 486 487 488 489 490
  }

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

491
    final double currentValue = _position.value;
492 493
    final double currentReactionValue = _reaction.value;

494 495 496 497 498 499 500 501 502 503
    double visualPosition;
    switch (textDirection) {
      case TextDirection.rtl:
        visualPosition = 1.0 - currentValue;
        break;
      case TextDirection.ltr:
        visualPosition = currentValue;
        break;
    }

504
    final Paint paint = Paint()
505
      ..color = Color.lerp(trackColor, activeColor, currentValue);
506

507
    final Rect trackRect = Rect.fromLTWH(
508 509 510
        offset.dx + (size.width - _kTrackWidth) / 2.0,
        offset.dy + (size.height - _kTrackHeight) / 2.0,
        _kTrackWidth,
511
        _kTrackHeight,
512
    );
513 514
    final RRect trackRRect = RRect.fromRectAndRadius(trackRect, const Radius.circular(_kTrackRadius));
    canvas.drawRRect(trackRRect, paint);
515

516
    final double currentThumbExtension = CupertinoThumbPainter.extension * currentReactionValue;
517
    final double thumbLeft = lerpDouble(
518 519
      trackRect.left + _kTrackInnerStart - CupertinoThumbPainter.radius,
      trackRect.left + _kTrackInnerEnd - CupertinoThumbPainter.radius - currentThumbExtension,
520
      visualPosition,
521 522
    );
    final double thumbRight = lerpDouble(
523 524
      trackRect.left + _kTrackInnerStart + CupertinoThumbPainter.radius + currentThumbExtension,
      trackRect.left + _kTrackInnerEnd + CupertinoThumbPainter.radius,
525
      visualPosition,
526 527
    );
    final double thumbCenterY = offset.dy + size.height / 2.0;
528
    final Rect thumbBounds = Rect.fromLTRB(
529 530 531 532
      thumbLeft,
      thumbCenterY - CupertinoThumbPainter.radius,
      thumbRight,
      thumbCenterY + CupertinoThumbPainter.radius,
533 534 535
    );

    context.pushClipRRect(needsCompositing, Offset.zero, thumbBounds, trackRRect, (PaintingContext innerContext, Offset offset) {
536
      const CupertinoThumbPainter.switchThumb().paint(innerContext.canvas, thumbBounds);
537
    });
538 539 540
  }

  @override
541
  void debugFillProperties(DiagnosticPropertiesBuilder description) {
542
    super.debugFillProperties(description);
543 544
    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));
545 546
  }
}