switch.dart 19.3 KB
Newer Older
1 2 3 4
// Copyright 2015 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

5
import 'package:flutter/cupertino.dart';
6
import 'package:flutter/foundation.dart';
7
import 'package:flutter/gestures.dart';
8 9
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
10

11
import 'colors.dart';
12
import 'constants.dart';
13
import 'debug.dart';
Adam Barth's avatar
Adam Barth committed
14
import 'shadows.dart';
15
import 'theme.dart';
16
import 'theme_data.dart';
17
import 'toggleable.dart';
18

19 20 21 22 23 24 25 26
const double _kTrackHeight = 14.0;
const double _kTrackWidth = 33.0;
const double _kTrackRadius = _kTrackHeight / 2.0;
const double _kThumbRadius = 10.0;
const double _kSwitchWidth = _kTrackWidth - 2 * _kTrackRadius + 2 * kRadialReactionRadius;
const double _kSwitchHeight = 2 * kRadialReactionRadius + 8.0;
const double _kSwitchHeightCollapsed = 2 * kRadialReactionRadius;

27 28
enum _SwitchType { material, adaptive }

29 30 31 32 33 34 35 36 37
/// A material design 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.
///
38 39 40 41 42
/// If the [onChanged] callback is null, then the switch will be disabled (it
/// will not respond to input). A disabled switch's thumb and track are rendered
/// in shades of grey by default. The default appearance of a disabled switch
/// can be overridden with [inactiveThumbColor] and [inactiveTrackColor].
///
43 44 45
/// Requires one of its ancestors to be a [Material] widget.
///
/// See also:
46
///
47 48
///  * [SwitchListTile], which combines this widget with a [ListTile] so that
///    you can give the switch a label.
49 50 51
///  * [Checkbox], another widget with similar semantics.
///  * [Radio], for selecting among a set of explicit values.
///  * [Slider], for selecting a value in a range.
52
///  * <https://material.io/design/components/selection-controls.html#switches>
53
class Switch extends StatefulWidget {
54 55 56 57 58 59 60
  /// Creates a material design switch.
  ///
  /// 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.
  ///
61 62 63 64
  /// The following arguments are required:
  ///
  /// * [value] determines whether this switch is on or off.
  /// * [onChanged] is called when the user toggles the switch on or off.
65
  const Switch({
66
    Key key,
67 68
    @required this.value,
    @required this.onChanged,
69
    this.activeColor,
70 71 72
    this.activeTrackColor,
    this.inactiveThumbColor,
    this.inactiveTrackColor,
73
    this.activeThumbImage,
74 75
    this.inactiveThumbImage,
    this.materialTapTargetSize,
76
    this.dragStartBehavior = DragStartBehavior.start,
77
  }) : _switchType = _SwitchType.material,
78
       assert(dragStartBehavior != null),
79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99
       super(key: key);

  /// Creates a [CupertinoSwitch] if the target platform is iOS, creates a
  /// material design switch otherwise.
  ///
  /// If a [CupertinoSwitch] is created, the following parameters are
  /// ignored: [activeTrackColor], [inactiveThumbColor], [inactiveTrackColor],
  /// [activeThumbImage], [inactiveThumbImage], [materialTapTargetSize].
  ///
  /// The target platform is based on the current [Theme]: [ThemeData.platform].
  const Switch.adaptive({
    Key key,
    @required this.value,
    @required this.onChanged,
    this.activeColor,
    this.activeTrackColor,
    this.inactiveThumbColor,
    this.inactiveTrackColor,
    this.activeThumbImage,
    this.inactiveThumbImage,
    this.materialTapTargetSize,
100
    this.dragStartBehavior = DragStartBehavior.start,
101 102
  }) : _switchType = _SwitchType.adaptive,
       super(key: key);
103

104
  /// Whether this switch is on or off.
105 106
  ///
  /// This property must not be null.
107
  final bool value;
108

109
  /// Called when the user toggles the switch on or off.
110 111 112 113 114 115
  ///
  /// 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.
  ///
  /// If null, the switch will be displayed as disabled.
116
  ///
117
  /// The callback provided to [onChanged] should update the state of the parent
118 119 120 121
  /// [StatefulWidget] using the [State.setState] method, so that the parent
  /// gets rebuilt; for example:
  ///
  /// ```dart
122
  /// Switch(
123 124 125 126 127 128
  ///   value: _giveVerse,
  ///   onChanged: (bool newValue) {
  ///     setState(() {
  ///       _giveVerse = newValue;
  ///     });
  ///   },
129
  /// )
130
  /// ```
131 132
  final ValueChanged<bool> onChanged;

133 134
  /// The color to use when this switch is on.
  ///
135
  /// Defaults to [ThemeData.toggleableActiveColor].
136
  final Color activeColor;
137

138 139
  /// The color to use on the track when this switch is on.
  ///
140
  /// Defaults to [ThemeData.toggleableActiveColor] with the opacity set at 50%.
141 142
  ///
  /// Ignored if this switch is created with [Switch.adaptive].
143 144 145 146 147
  final Color activeTrackColor;

  /// The color to use on the thumb when this switch is off.
  ///
  /// Defaults to the colors described in the Material design specification.
148 149
  ///
  /// Ignored if this switch is created with [Switch.adaptive].
150 151 152 153 154
  final Color inactiveThumbColor;

  /// The color to use on the track when this switch is off.
  ///
  /// Defaults to the colors described in the Material design specification.
155 156
  ///
  /// Ignored if this switch is created with [Switch.adaptive].
157 158
  final Color inactiveTrackColor;

159
  /// An image to use on the thumb of this switch when the switch is on.
160 161
  ///
  /// Ignored if this switch is created with [Switch.adaptive].
162
  final ImageProvider activeThumbImage;
163

164
  /// An image to use on the thumb of this switch when the switch is off.
165 166
  ///
  /// Ignored if this switch is created with [Switch.adaptive].
167
  final ImageProvider inactiveThumbImage;
168

169 170 171 172 173 174
  /// Configures the minimum size of the tap target.
  ///
  /// Defaults to [ThemeData.materialTapTargetSize].
  ///
  /// See also:
  ///
175
  ///  * [MaterialTapTargetSize], for a description of how this affects tap targets.
176 177
  final MaterialTapTargetSize materialTapTargetSize;

178 179
  final _SwitchType _switchType;

180 181 182
  /// {@macro flutter.cupertino.switch.dragStartBehavior}
  final DragStartBehavior dragStartBehavior;

183
  @override
184
  _SwitchState createState() => _SwitchState();
185 186

  @override
187 188
  void debugFillProperties(DiagnosticPropertiesBuilder properties) {
    super.debugFillProperties(properties);
189 190
    properties.add(FlagProperty('value', value: value, ifTrue: 'on', ifFalse: 'off', showName: true));
    properties.add(ObjectFlagProperty<ValueChanged<bool>>('onChanged', onChanged, ifNull: 'disabled'));
191 192 193 194
  }
}

class _SwitchState extends State<Switch> with TickerProviderStateMixin {
195 196 197 198 199 200 201 202 203 204 205 206 207 208
  Size getSwitchSize(ThemeData theme) {
    switch (widget.materialTapTargetSize ?? theme.materialTapTargetSize) {
      case MaterialTapTargetSize.padded:
        return const Size(_kSwitchWidth, _kSwitchHeight);
        break;
      case MaterialTapTargetSize.shrinkWrap:
        return const Size(_kSwitchWidth, _kSwitchHeightCollapsed);
        break;
    }
    assert(false);
    return null;
  }

  Widget buildMaterialSwitch(BuildContext context) {
209
    assert(debugCheckHasMaterial(context));
210 211
    final ThemeData theme = Theme.of(context);
    final bool isDark = theme.brightness == Brightness.dark;
212

213
    final Color activeThumbColor = widget.activeColor ?? theme.toggleableActiveColor;
214
    final Color activeTrackColor = widget.activeTrackColor ?? activeThumbColor.withAlpha(0x80);
215 216 217

    Color inactiveThumbColor;
    Color inactiveTrackColor;
218
    if (widget.onChanged != null) {
219
      const Color black32 = Color(0x52000000); // Black with 32% opacity
220
      inactiveThumbColor = widget.inactiveThumbColor ?? (isDark ? Colors.grey.shade400 : Colors.grey.shade50);
221
      inactiveTrackColor = widget.inactiveTrackColor ?? (isDark ? Colors.white30 : black32);
222
    } else {
223 224
      inactiveThumbColor = widget.inactiveThumbColor ?? (isDark ? Colors.grey.shade800 : Colors.grey.shade400);
      inactiveTrackColor = widget.inactiveTrackColor ?? (isDark ? Colors.white10 : Colors.black12);
225 226
    }

227
    return _SwitchRenderObjectWidget(
228
      dragStartBehavior: widget.dragStartBehavior,
229
      value: widget.value,
230 231
      activeColor: activeThumbColor,
      inactiveColor: inactiveThumbColor,
232 233
      activeThumbImage: widget.activeThumbImage,
      inactiveThumbImage: widget.inactiveThumbImage,
234 235
      activeTrackColor: activeTrackColor,
      inactiveTrackColor: inactiveTrackColor,
236
      configuration: createLocalImageConfiguration(context),
237
      onChanged: widget.onChanged,
238
      additionalConstraints: BoxConstraints.tight(getSwitchSize(theme)),
239
      vsync: this,
240 241
    );
  }
242 243 244 245 246 247 248 249

  Widget buildCupertinoSwitch(BuildContext context) {
    final Size size = getSwitchSize(Theme.of(context));
    return Container(
      width: size.width,  // Same size as the Material switch.
      height: size.height,
      alignment: Alignment.center,
      child: CupertinoSwitch(
250
        dragStartBehavior: widget.dragStartBehavior,
251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278
        value: widget.value,
        onChanged: widget.onChanged,
        activeColor: widget.activeColor,
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    switch (widget._switchType) {
      case _SwitchType.material:
        return buildMaterialSwitch(context);

      case _SwitchType.adaptive: {
        final ThemeData theme = Theme.of(context);
        assert(theme.platform != null);
        switch (theme.platform) {
          case TargetPlatform.android:
          case TargetPlatform.fuchsia:
            return buildMaterialSwitch(context);
          case TargetPlatform.iOS:
            return buildCupertinoSwitch(context);
        }
      }
    }
    assert(false);
    return null;
  }
279 280
}

281
class _SwitchRenderObjectWidget extends LeafRenderObjectWidget {
282
  const _SwitchRenderObjectWidget({
283 284
    Key key,
    this.value,
285 286
    this.activeColor,
    this.inactiveColor,
287 288
    this.activeThumbImage,
    this.inactiveThumbImage,
289 290
    this.activeTrackColor,
    this.inactiveTrackColor,
291
    this.configuration,
292 293
    this.onChanged,
    this.vsync,
294
    this.additionalConstraints,
295
    this.dragStartBehavior,
296
  }) : super(key: key);
297 298

  final bool value;
299 300
  final Color activeColor;
  final Color inactiveColor;
301 302
  final ImageProvider activeThumbImage;
  final ImageProvider inactiveThumbImage;
303 304
  final Color activeTrackColor;
  final Color inactiveTrackColor;
305
  final ImageConfiguration configuration;
Hixie's avatar
Hixie committed
306
  final ValueChanged<bool> onChanged;
307
  final TickerProvider vsync;
308
  final BoxConstraints additionalConstraints;
309
  final DragStartBehavior dragStartBehavior;
310

311
  @override
312
  _RenderSwitch createRenderObject(BuildContext context) {
313
    return _RenderSwitch(
314
      dragStartBehavior: dragStartBehavior,
315 316 317 318 319 320 321 322 323 324
      value: value,
      activeColor: activeColor,
      inactiveColor: inactiveColor,
      activeThumbImage: activeThumbImage,
      inactiveThumbImage: inactiveThumbImage,
      activeTrackColor: activeTrackColor,
      inactiveTrackColor: inactiveTrackColor,
      configuration: configuration,
      onChanged: onChanged,
      textDirection: Directionality.of(context),
325
      additionalConstraints: additionalConstraints,
326 327 328
      vsync: vsync,
    );
  }
329

330
  @override
331
  void updateRenderObject(BuildContext context, _RenderSwitch renderObject) {
332 333 334 335
    renderObject
      ..value = value
      ..activeColor = activeColor
      ..inactiveColor = inactiveColor
336 337
      ..activeThumbImage = activeThumbImage
      ..inactiveThumbImage = inactiveThumbImage
338 339
      ..activeTrackColor = activeTrackColor
      ..inactiveTrackColor = inactiveTrackColor
340
      ..configuration = configuration
341
      ..onChanged = onChanged
342
      ..textDirection = Directionality.of(context)
343
      ..additionalConstraints = additionalConstraints
344
      ..dragStartBehavior = dragStartBehavior
345
      ..vsync = vsync;
346 347 348
  }
}

349
class _RenderSwitch extends RenderToggleable {
350 351
  _RenderSwitch({
    bool value,
352 353
    Color activeColor,
    Color inactiveColor,
354 355
    ImageProvider activeThumbImage,
    ImageProvider inactiveThumbImage,
356 357
    Color activeTrackColor,
    Color inactiveTrackColor,
358
    ImageConfiguration configuration,
359
    BoxConstraints additionalConstraints,
360
    @required TextDirection textDirection,
361 362
    ValueChanged<bool> onChanged,
    @required TickerProvider vsync,
363
    DragStartBehavior dragStartBehavior,
364 365
  }) : assert(textDirection != null),
       _activeThumbImage = activeThumbImage,
366
       _inactiveThumbImage = inactiveThumbImage,
367 368
       _activeTrackColor = activeTrackColor,
       _inactiveTrackColor = inactiveTrackColor,
369
       _configuration = configuration,
370
       _textDirection = textDirection,
371 372
       super(
         value: value,
373
         tristate: false,
374 375 376
         activeColor: activeColor,
         inactiveColor: inactiveColor,
         onChanged: onChanged,
377
         additionalConstraints: additionalConstraints,
378
         vsync: vsync,
379
       ) {
380
    _drag = HorizontalDragGestureRecognizer()
381 382
      ..onStart = _handleDragStart
      ..onUpdate = _handleDragUpdate
383 384
      ..onEnd = _handleDragEnd
      ..dragStartBehavior = dragStartBehavior;
385 386
  }

387 388 389 390
  ImageProvider get activeThumbImage => _activeThumbImage;
  ImageProvider _activeThumbImage;
  set activeThumbImage(ImageProvider value) {
    if (value == _activeThumbImage)
391
      return;
392
    _activeThumbImage = value;
393 394 395
    markNeedsPaint();
  }

396 397 398 399
  ImageProvider get inactiveThumbImage => _inactiveThumbImage;
  ImageProvider _inactiveThumbImage;
  set inactiveThumbImage(ImageProvider value) {
    if (value == _inactiveThumbImage)
400
      return;
401
    _inactiveThumbImage = value;
402 403 404
    markNeedsPaint();
  }

405 406
  Color get activeTrackColor => _activeTrackColor;
  Color _activeTrackColor;
407
  set activeTrackColor(Color value) {
408 409 410 411 412 413 414 415 416
    assert(value != null);
    if (value == _activeTrackColor)
      return;
    _activeTrackColor = value;
    markNeedsPaint();
  }

  Color get inactiveTrackColor => _inactiveTrackColor;
  Color _inactiveTrackColor;
417
  set inactiveTrackColor(Color value) {
418 419 420 421 422 423 424
    assert(value != null);
    if (value == _inactiveTrackColor)
      return;
    _inactiveTrackColor = value;
    markNeedsPaint();
  }

425 426
  ImageConfiguration get configuration => _configuration;
  ImageConfiguration _configuration;
427
  set configuration(ImageConfiguration value) {
428 429 430 431 432
    assert(value != null);
    if (value == _configuration)
      return;
    _configuration = value;
    markNeedsPaint();
433 434
  }

435 436 437 438 439 440 441 442 443 444
  TextDirection get textDirection => _textDirection;
  TextDirection _textDirection;
  set textDirection(TextDirection value) {
    assert(value != null);
    if (_textDirection == value)
      return;
    _textDirection = value;
    markNeedsPaint();
  }

445 446 447
  DragStartBehavior get dragStartBehavior => _drag.dragStartBehavior;
  set dragStartBehavior(DragStartBehavior value) {
    assert(value != null);
448
    if (_drag.dragStartBehavior == value)
449 450 451 452
      return;
    _drag.dragStartBehavior = value;
  }

453 454
  @override
  void detach() {
455 456
    _cachedThumbPainter?.dispose();
    _cachedThumbPainter = null;
457 458 459
    super.detach();
  }

460
  double get _trackInnerLength => size.width - 2.0 * kRadialReactionRadius;
461

462 463
  HorizontalDragGestureRecognizer _drag;

464
  void _handleDragStart(DragStartDetails details) {
465
    if (isInteractive)
466
      reactionController.forward();
467 468
  }

469
  void _handleDragUpdate(DragUpdateDetails details) {
470
    if (isInteractive) {
471
      position
472 473
        ..curve = null
        ..reverseCurve = null;
474 475 476 477 478 479 480 481 482
      final double delta = details.primaryDelta / _trackInnerLength;
      switch (textDirection) {
        case TextDirection.rtl:
          positionController.value -= delta;
          break;
        case TextDirection.ltr:
          positionController.value += delta;
          break;
      }
483 484 485
    }
  }

486
  void _handleDragEnd(DragEndDetails details) {
487 488
    if (position.value >= 0.5)
      positionController.forward();
489
    else
490 491
      positionController.reverse();
    reactionController.reverse();
492 493
  }

494
  @override
Ian Hickson's avatar
Ian Hickson committed
495
  void handleEvent(PointerEvent event, BoxHitTestEntry entry) {
496
    assert(debugHandleEvent(event, entry));
Ian Hickson's avatar
Ian Hickson committed
497
    if (event is PointerDownEvent && onChanged != null)
498 499
      _drag.addPointer(event);
    super.handleEvent(event, entry);
500 501
  }

502
  Color _cachedThumbColor;
503
  ImageProvider _cachedThumbImage;
504 505
  BoxPainter _cachedThumbPainter;

506
  BoxDecoration _createDefaultThumbDecoration(Color color, ImageProvider image) {
507
    return BoxDecoration(
508
      color: color,
509
      image: image == null ? null : DecorationImage(image: image),
510
      shape: BoxShape.circle,
511
      boxShadow: kElevationToShadow[1],
512 513
    );
  }
514

515 516 517 518 519 520 521 522 523 524 525
  bool _isPainting = false;

  void _handleDecorationChanged() {
    // If the image decoration is available synchronously, we'll get called here
    // during paint. There's no reason to mark ourselves as needing paint if we
    // are already in the middle of painting. (In fact, doing so would trigger
    // an assert).
    if (!_isPainting)
      markNeedsPaint();
  }

526 527 528 529 530 531
  @override
  void describeSemanticsConfiguration(SemanticsConfiguration config) {
    super.describeSemanticsConfiguration(config);
    config.isToggled = value == true;
  }

532
  @override
533
  void paint(PaintingContext context, Offset offset) {
Adam Barth's avatar
Adam Barth committed
534
    final Canvas canvas = context.canvas;
535
    final bool isEnabled = onChanged != null;
536 537 538 539 540 541 542 543 544 545 546
    final double currentValue = position.value;

    double visualPosition;
    switch (textDirection) {
      case TextDirection.rtl:
        visualPosition = 1.0 - currentValue;
        break;
      case TextDirection.ltr:
        visualPosition = currentValue;
        break;
    }
547

548 549 550 551 552 553 554 555 556 557 558
    final Color trackColor = isEnabled
      ? Color.lerp(inactiveTrackColor, activeTrackColor, currentValue)
      : inactiveTrackColor;

    final Color thumbColor = isEnabled
      ? Color.lerp(inactiveColor, activeColor, currentValue)
      : inactiveColor;

    final ImageProvider thumbImage = isEnabled
      ? (currentValue < 0.5 ? inactiveThumbImage : activeThumbImage)
      : inactiveThumbImage;
559

560
    // Paint the track
561
    final Paint paint = Paint()
562
      ..color = trackColor;
563
    const double trackHorizontalPadding = kRadialReactionRadius - _kTrackRadius;
564
    final Rect trackRect = Rect.fromLTWH(
565 566 567
      offset.dx + trackHorizontalPadding,
      offset.dy + (size.height - _kTrackHeight) / 2.0,
      size.width - 2.0 * trackHorizontalPadding,
568
      _kTrackHeight,
569
    );
570
    final RRect trackRRect = RRect.fromRectAndRadius(trackRect, const Radius.circular(_kTrackRadius));
571 572
    canvas.drawRRect(trackRRect, paint);

573
    final Offset thumbPosition = Offset(
574
      kRadialReactionRadius + visualPosition * _trackInnerLength,
575
      size.height / 2.0,
576
    );
577

Adam Barth's avatar
Adam Barth committed
578
    paintRadialReaction(canvas, offset, thumbPosition);
579

580 581 582 583
    try {
      _isPainting = true;
      BoxPainter thumbPainter;
      if (_cachedThumbPainter == null || thumbColor != _cachedThumbColor || thumbImage != _cachedThumbImage) {
584
        _cachedThumbColor = thumbColor;
585 586
        _cachedThumbImage = thumbImage;
        _cachedThumbPainter = _createDefaultThumbDecoration(thumbColor, thumbImage).createBoxPainter(_handleDecorationChanged);
587 588
      }
      thumbPainter = _cachedThumbPainter;
589

590
      // The thumb contracts slightly during the animation
591
      final double inset = 1.0 - (currentValue - 0.5).abs() * 2.0;
592 593 594
      final double radius = _kThumbRadius - inset;
      thumbPainter.paint(
        canvas,
595
        thumbPosition + offset - Offset(radius, radius),
596
        configuration.copyWith(size: Size.fromRadius(radius)),
597 598 599 600
      );
    } finally {
      _isPainting = false;
    }
601 602
  }
}