slider.dart 17.5 KB
Newer Older
1 2 3 4 5 6 7 8 9 10 11
// Copyright 2017 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.

import 'dart:math' as math;
import 'dart:ui' show lerpDouble;

import 'package:flutter/gestures.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';

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

16 17
// Examples can assume:
// int _cupertinoSliderValue = 1;
18
// void setState(VoidCallback fn) { }
19

20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37
/// An iOS-style slider.
///
/// Used to select from a range of values.
///
/// A slider can be used to select from either a continuous or a discrete set of
/// values. The default is use a continuous range of values from [min] to [max].
/// To use discrete values, use a non-null value for [divisions], which
/// indicates the number of discrete intervals. For example, if [min] is 0.0 and
/// [max] is 50.0 and [divisions] is 5, then the slider can take on the values
/// discrete values 0.0, 10.0, 20.0, 30.0, 40.0, and 50.0.
///
/// The slider itself does not maintain any state. Instead, when the state of
/// the slider changes, the widget calls the [onChanged] callback. Most widgets
/// that use a slider will listen for the [onChanged] callback and rebuild the
/// slider with a new [value] to update the visual appearance of the slider.
///
/// See also:
///
38
///  * <https://developer.apple.com/ios/human-interface-guidelines/controls/sliders/>
39 40 41 42 43 44 45 46 47 48
class CupertinoSlider extends StatefulWidget {
  /// Creates an iOS-style slider.
  ///
  /// The slider itself does not maintain any state. Instead, when the state of
  /// the slider changes, the widget calls the [onChanged] callback. Most widgets
  /// that use a slider will listen for the [onChanged] callback and rebuild the
  /// slider with a new [value] to update the visual appearance of the slider.
  ///
  /// * [value] determines currently selected value for this slider.
  /// * [onChanged] is called when the user selects a new value for the slider.
49 50 51 52
  /// * [onChangeStart] is called when the user starts to select a new value for
  ///   the slider.
  /// * [onChangeEnd] is called when the user is done selecting a new value for
  ///   the slider.
53
  const CupertinoSlider({
54 55 56
    Key key,
    @required this.value,
    @required this.onChanged,
57 58
    this.onChangeStart,
    this.onChangeEnd,
59 60
    this.min = 0.0,
    this.max = 1.0,
61
    this.divisions,
62
    this.activeColor,
63
    this.thumbColor = CupertinoColors.white,
64 65 66 67 68
  }) : assert(value != null),
       assert(min != null),
       assert(max != null),
       assert(value >= min && value <= max),
       assert(divisions == null || divisions > 0),
69
       assert(thumbColor != null),
70
       super(key: key);
71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89

  /// The currently selected value for this slider.
  ///
  /// The slider's thumb is drawn at a position that corresponds to this value.
  final double value;

  /// Called when the user selects a new value for the slider.
  ///
  /// The slider passes the new value to the callback but does not actually
  /// change state until the parent widget rebuilds the slider with the new
  /// value.
  ///
  /// If null, the slider will be displayed as disabled.
  ///
  /// 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
90
  /// CupertinoSlider(
91
  ///   value: _cupertinoSliderValue.toDouble(),
92 93 94 95 96
  ///   min: 1.0,
  ///   max: 10.0,
  ///   divisions: 10,
  ///   onChanged: (double newValue) {
  ///     setState(() {
97
  ///       _cupertinoSliderValue = newValue.round();
98 99
  ///     });
  ///   },
100
  /// )
101
  /// ```
102
  ///
103 104 105 106 107 108
  /// See also:
  ///
  ///  * [onChangeStart] for a callback that is called when the user starts
  ///    changing the value.
  ///  * [onChangeEnd] for a callback that is called when the user stops
  ///    changing the value.
109 110
  final ValueChanged<double> onChanged;

111 112 113 114 115 116 117 118 119
  /// Called when the user starts selecting a new value for the slider.
  ///
  /// This callback shouldn't be used to update the slider [value] (use
  /// [onChanged] for that), but rather to be notified when the user has started
  /// selecting a new value by starting a drag.
  ///
  /// The value passed will be the last [value] that the slider had before the
  /// change began.
  ///
120
  /// {@tool sample}
121 122
  ///
  /// ```dart
123
  /// CupertinoSlider(
124 125 126 127 128 129 130 131 132 133 134 135 136 137
  ///   value: _cupertinoSliderValue.toDouble(),
  ///   min: 1.0,
  ///   max: 10.0,
  ///   divisions: 10,
  ///   onChanged: (double newValue) {
  ///     setState(() {
  ///       _cupertinoSliderValue = newValue.round();
  ///     });
  ///   },
  ///   onChangeStart: (double startValue) {
  ///     print('Started change at $startValue');
  ///   },
  /// )
  /// ```
138
  /// {@end-tool}
139 140 141 142 143 144 145 146 147 148 149 150 151
  ///
  /// See also:
  ///
  ///  * [onChangeEnd] for a callback that is called when the value change is
  ///    complete.
  final ValueChanged<double> onChangeStart;

  /// Called when the user is done selecting a new value for the slider.
  ///
  /// This callback shouldn't be used to update the slider [value] (use
  /// [onChanged] for that), but rather to know when the user has completed
  /// selecting a new [value] by ending a drag.
  ///
152
  /// {@tool sample}
153 154
  ///
  /// ```dart
155
  /// CupertinoSlider(
156 157 158 159 160 161 162 163 164 165 166 167 168 169
  ///   value: _cupertinoSliderValue.toDouble(),
  ///   min: 1.0,
  ///   max: 10.0,
  ///   divisions: 10,
  ///   onChanged: (double newValue) {
  ///     setState(() {
  ///       _cupertinoSliderValue = newValue.round();
  ///     });
  ///   },
  ///   onChangeEnd: (double newValue) {
  ///     print('Ended change on $newValue');
  ///   },
  /// )
  /// ```
170
  /// {@end-tool}
171 172 173 174 175 176 177
  ///
  /// See also:
  ///
  ///  * [onChangeStart] for a callback that is called when a value change
  ///    begins.
  final ValueChanged<double> onChangeEnd;

178
  /// The minimum value the user can select.
179 180 181 182 183 184 185 186 187 188 189 190 191 192 193
  ///
  /// Defaults to 0.0.
  final double min;

  /// The maximum value the user can select.
  ///
  /// Defaults to 1.0.
  final double max;

  /// The number of discrete divisions.
  ///
  /// If null, the slider is continuous.
  final int divisions;

  /// The color to use for the portion of the slider that has been selected.
194
  ///
xster's avatar
xster committed
195
  /// Defaults to the [CupertinoTheme]'s primary color if null.
196 197
  final Color activeColor;

198 199 200 201 202 203 204
  /// The color to use for the thumb of the slider.
  ///
  /// Thumb color must not be null.
  ///
  /// Defaults to [CupertinoColors.white].
  final Color thumbColor;

205
  @override
206
  _CupertinoSliderState createState() => _CupertinoSliderState();
207 208

  @override
209 210
  void debugFillProperties(DiagnosticPropertiesBuilder properties) {
    super.debugFillProperties(properties);
211 212 213
    properties.add(DoubleProperty('value', value));
    properties.add(DoubleProperty('min', min));
    properties.add(DoubleProperty('max', max));
214
  }
215 216 217 218
}

class _CupertinoSliderState extends State<CupertinoSlider> with TickerProviderStateMixin {
  void _handleChanged(double value) {
219
    assert(widget.onChanged != null);
220 221 222 223 224 225 226 227 228 229 230 231 232 233
    final double lerpValue = lerpDouble(widget.min, widget.max, value);
    if (lerpValue != widget.value) {
      widget.onChanged(lerpValue);
    }
  }

  void _handleDragStart(double value) {
    assert(widget.onChangeStart != null);
    widget.onChangeStart(lerpDouble(widget.min, widget.max, value));
  }

  void _handleDragEnd(double value) {
    assert(widget.onChangeEnd != null);
    widget.onChangeEnd(lerpDouble(widget.min, widget.max, value));
234 235 236 237
  }

  @override
  Widget build(BuildContext context) {
238
    return _CupertinoSliderRenderObjectWidget(
239 240
      value: (widget.value - widget.min) / (widget.max - widget.min),
      divisions: widget.divisions,
241 242
      activeColor: CupertinoDynamicColor.resolve(
        widget.activeColor ?? CupertinoTheme.of(context).primaryColor,
243
        context,
244
      ),
245
      thumbColor: widget.thumbColor,
246
      onChanged: widget.onChanged != null ? _handleChanged : null,
247 248
      onChangeStart: widget.onChangeStart != null ? _handleDragStart : null,
      onChangeEnd: widget.onChangeEnd != null ? _handleDragEnd : null,
249 250 251 252 253 254
      vsync: this,
    );
  }
}

class _CupertinoSliderRenderObjectWidget extends LeafRenderObjectWidget {
255
  const _CupertinoSliderRenderObjectWidget({
256 257 258 259
    Key key,
    this.value,
    this.divisions,
    this.activeColor,
260
    this.thumbColor,
261
    this.onChanged,
262 263
    this.onChangeStart,
    this.onChangeEnd,
264 265 266 267 268 269
    this.vsync,
  }) : super(key: key);

  final double value;
  final int divisions;
  final Color activeColor;
270
  final Color thumbColor;
271
  final ValueChanged<double> onChanged;
272 273
  final ValueChanged<double> onChangeStart;
  final ValueChanged<double> onChangeEnd;
274 275 276 277
  final TickerProvider vsync;

  @override
  _RenderCupertinoSlider createRenderObject(BuildContext context) {
278
    return _RenderCupertinoSlider(
279 280 281
      value: value,
      divisions: divisions,
      activeColor: activeColor,
282
      thumbColor: CupertinoDynamicColor.resolve(thumbColor, context),
283
      trackColor: CupertinoDynamicColor.resolve(CupertinoColors.systemFill, context),
284
      onChanged: onChanged,
285 286
      onChangeStart: onChangeStart,
      onChangeEnd: onChangeEnd,
287
      vsync: vsync,
288
      textDirection: Directionality.of(context),
289 290 291 292 293 294 295 296 297
    );
  }

  @override
  void updateRenderObject(BuildContext context, _RenderCupertinoSlider renderObject) {
    renderObject
      ..value = value
      ..divisions = divisions
      ..activeColor = activeColor
298
      ..thumbColor = CupertinoDynamicColor.resolve(thumbColor, context)
299
      ..trackColor = CupertinoDynamicColor.resolve(CupertinoColors.systemFill, context)
300
      ..onChanged = onChanged
301 302
      ..onChangeStart = onChangeStart
      ..onChangeEnd = onChangeEnd
303
      ..textDirection = Directionality.of(context);
304 305 306 307 308 309 310 311
    // Ticker provider cannot change since there's a 1:1 relationship between
    // the _SliderRenderObjectWidget object and the _SliderState object.
  }
}

const double _kPadding = 8.0;
const double _kSliderHeight = 2.0 * (CupertinoThumbPainter.radius + _kPadding);
const double _kSliderWidth = 176.0; // Matches Material Design slider.
312
const Duration _kDiscreteTransitionDuration = Duration(milliseconds: 500);
313 314 315

const double _kAdjustmentUnit = 0.1; // Matches iOS implementation of material slider.

316
class _RenderCupertinoSlider extends RenderConstrainedBox {
317
  _RenderCupertinoSlider({
318
    @required double value,
319 320
    int divisions,
    Color activeColor,
321
    Color thumbColor,
322
    Color trackColor,
323
    ValueChanged<double> onChanged,
324 325
    this.onChangeStart,
    this.onChangeEnd,
326
    TickerProvider vsync,
327
    @required TextDirection textDirection,
328
  }) : assert(value != null && value >= 0.0 && value <= 1.0),
329
       assert(textDirection != null),
330
       _value = value,
331 332
       _divisions = divisions,
       _activeColor = activeColor,
333
       _thumbColor = thumbColor,
334
       _trackColor = trackColor,
335
       _onChanged = onChanged,
336
       _textDirection = textDirection,
337
       super(additionalConstraints: const BoxConstraints.tightFor(width: _kSliderWidth, height: _kSliderHeight)) {
338
    _drag = HorizontalDragGestureRecognizer()
339 340 341
      ..onStart = _handleDragStart
      ..onUpdate = _handleDragUpdate
      ..onEnd = _handleDragEnd;
342
    _position = AnimationController(
343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359
      value: value,
      duration: _kDiscreteTransitionDuration,
      vsync: vsync,
    )..addListener(markNeedsPaint);
  }

  double get value => _value;
  double _value;
  set value(double newValue) {
    assert(newValue != null && newValue >= 0.0 && newValue <= 1.0);
    if (newValue == _value)
      return;
    _value = newValue;
    if (divisions != null)
      _position.animateTo(newValue, curve: Curves.fastOutSlowIn);
    else
      _position.value = newValue;
360
    markNeedsSemanticsUpdate();
361 362 363 364
  }

  int get divisions => _divisions;
  int _divisions;
365 366
  set divisions(int value) {
    if (value == _divisions)
367
      return;
368
    _divisions = value;
369 370 371 372 373 374 375 376 377 378 379 380
    markNeedsPaint();
  }

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

381 382 383 384 385 386 387 388 389
  Color get thumbColor => _thumbColor;
  Color _thumbColor;
  set thumbColor(Color value) {
    if (value == _thumbColor)
      return;
    _thumbColor = value;
    markNeedsPaint();
  }

390 391 392 393 394 395 396 397 398
  Color get trackColor => _trackColor;
  Color _trackColor;
  set trackColor(Color value) {
    if (value == _trackColor)
      return;
    _trackColor = value;
    markNeedsPaint();
  }

399 400 401 402 403 404 405 406
  ValueChanged<double> get onChanged => _onChanged;
  ValueChanged<double> _onChanged;
  set onChanged(ValueChanged<double> value) {
    if (value == _onChanged)
      return;
    final bool wasInteractive = isInteractive;
    _onChanged = value;
    if (wasInteractive != isInteractive)
407
      markNeedsSemanticsUpdate();
408
  }
409

410 411 412
  ValueChanged<double> onChangeStart;
  ValueChanged<double> onChangeEnd;

413 414 415 416 417 418 419 420 421 422
  TextDirection get textDirection => _textDirection;
  TextDirection _textDirection;
  set textDirection(TextDirection value) {
    assert(value != null);
    if (_textDirection == value)
      return;
    _textDirection = value;
    markNeedsPaint();
  }

423 424 425 426 427 428 429 430 431 432 433 434 435 436
  AnimationController _position;

  HorizontalDragGestureRecognizer _drag;
  double _currentDragValue = 0.0;

  double get _discretizedCurrentDragValue {
    double dragValue = _currentDragValue.clamp(0.0, 1.0);
    if (divisions != null)
      dragValue = (dragValue * divisions).round() / divisions;
    return dragValue;
  }

  double get _trackLeft => _kPadding;
  double get _trackRight => size.width - _kPadding;
437 438 439 440 441 442 443 444 445 446 447 448
  double get _thumbCenter {
    double visualPosition;
    switch (textDirection) {
      case TextDirection.rtl:
        visualPosition = 1.0 - _value;
        break;
      case TextDirection.ltr:
        visualPosition = _value;
        break;
    }
    return lerpDouble(_trackLeft + CupertinoThumbPainter.radius, _trackRight - CupertinoThumbPainter.radius, visualPosition);
  }
449 450 451

  bool get isInteractive => onChanged != null;

452
  void _handleDragStart(DragStartDetails details) => _startInteraction(details.globalPosition);
453 454 455 456

  void _handleDragUpdate(DragUpdateDetails details) {
    if (isInteractive) {
      final double extent = math.max(_kPadding, size.width - 2.0 * (_kPadding + CupertinoThumbPainter.radius));
457 458 459 460 461 462 463 464 465
      final double valueDelta = details.primaryDelta / extent;
      switch (textDirection) {
        case TextDirection.rtl:
          _currentDragValue -= valueDelta;
          break;
        case TextDirection.ltr:
          _currentDragValue += valueDelta;
          break;
      }
466 467 468 469
      onChanged(_discretizedCurrentDragValue);
    }
  }

470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485
  void _handleDragEnd(DragEndDetails details) => _endInteraction();

  void _startInteraction(Offset globalPosition) {
    if (isInteractive) {
      if (onChangeStart != null) {
        onChangeStart(_discretizedCurrentDragValue);
      }
      _currentDragValue = _value;
      onChanged(_discretizedCurrentDragValue);
    }
  }

  void _endInteraction() {
    if (onChangeEnd != null) {
      onChangeEnd(_discretizedCurrentDragValue);
    }
486 487 488 489
    _currentDragValue = 0.0;
  }

  @override
490 491
  bool hitTestSelf(Offset position) {
    return (position.dx - _thumbCenter).abs() < CupertinoThumbPainter.radius + _kPadding;
492 493 494 495 496 497 498 499 500 501 502
  }

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

  @override
  void paint(PaintingContext context, Offset offset) {
503 504 505 506 507 508
    double visualPosition;
    Color leftColor;
    Color rightColor;
    switch (textDirection) {
      case TextDirection.rtl:
        visualPosition = 1.0 - _position.value;
Ian Hickson's avatar
Ian Hickson committed
509
        leftColor = _activeColor;
510
        rightColor = trackColor;
511 512 513
        break;
      case TextDirection.ltr:
        visualPosition = _position.value;
514
        leftColor = trackColor;
Ian Hickson's avatar
Ian Hickson committed
515
        rightColor = _activeColor;
516 517
        break;
    }
518 519 520 521 522 523 524 525

    final double trackCenter = offset.dy + size.height / 2.0;
    final double trackLeft = offset.dx + _trackLeft;
    final double trackTop = trackCenter - 1.0;
    final double trackBottom = trackCenter + 1.0;
    final double trackRight = offset.dx + _trackRight;
    final double trackActive = offset.dx + _thumbCenter;

526
    final Canvas canvas = context.canvas;
527

528
    if (visualPosition > 0.0) {
xster's avatar
xster committed
529
      final Paint paint = Paint()..color = rightColor;
530
      canvas.drawRRect(RRect.fromLTRBXY(trackLeft, trackTop, trackActive, trackBottom, 1.0, 1.0), paint);
531 532
    }

533
    if (visualPosition < 1.0) {
xster's avatar
xster committed
534
      final Paint paint = Paint()..color = leftColor;
535
      canvas.drawRRect(RRect.fromLTRBXY(trackActive, trackTop, trackRight, trackBottom, 1.0, 1.0), paint);
536 537
    }

538
    final Offset thumbCenter = Offset(trackActive, trackCenter);
539
    CupertinoThumbPainter(color: thumbColor).paint(canvas, Rect.fromCircle(center: thumbCenter, radius: CupertinoThumbPainter.radius));
540 541 542
  }

  @override
543 544
  void describeSemanticsConfiguration(SemanticsConfiguration config) {
    super.describeSemanticsConfiguration(config);
545

546 547
    config.isSemanticBoundary = isInteractive;
    if (isInteractive) {
548
      config.textDirection = textDirection;
549 550
      config.onIncrease = _increaseAction;
      config.onDecrease = _decreaseAction;
551 552 553
      config.value = '${(value * 100).round()}%';
      config.increasedValue = '${((value + _semanticActionUnit).clamp(0.0, 1.0) * 100).round()}%';
      config.decreasedValue = '${((value - _semanticActionUnit).clamp(0.0, 1.0) * 100).round()}%';
554 555 556 557
    }
  }

  double get _semanticActionUnit => divisions != null ? 1.0 / divisions : _kAdjustmentUnit;
558

559
  void _increaseAction() {
560
    if (isInteractive)
561
      onChanged((value + _semanticActionUnit).clamp(0.0, 1.0));
562 563
  }

564 565 566
  void _decreaseAction() {
    if (isInteractive)
      onChanged((value - _semanticActionUnit).clamp(0.0, 1.0));
567 568
  }
}