slider.dart 18.8 KB
Newer Older
Ian Hickson's avatar
Ian Hickson committed
1
// Copyright 2014 The Flutter Authors. All rights reserved.
2 3 4 5 6 7
// 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;

8
import 'package:flutter/foundation.dart';
9 10
import 'package:flutter/gestures.dart';
import 'package:flutter/rendering.dart';
11
import 'package:flutter/services.dart';
12 13
import 'package:flutter/widgets.dart';

14
import 'colors.dart';
xster's avatar
xster committed
15
import 'theme.dart';
16 17
import 'thumb_painter.dart';

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

22 23
/// An iOS-style slider.
///
24 25
/// {@youtube 560 315 https://www.youtube.com/watch?v=ufb4gIPDmEs}
///
26 27 28 29 30 31 32 33 34 35 36 37 38 39
/// 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.
///
40 41 42 43 44 45
/// {@tool dartpad}
/// This example shows how to show the current slider value as it changes.
///
/// ** See code in examples/api/lib/cupertino/slider/cupertino_slider.0.dart **
/// {@end-tool}
///
46 47
/// See also:
///
48
///  * <https://developer.apple.com/ios/human-interface-guidelines/controls/sliders/>
49 50 51 52 53 54 55 56 57 58
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.
59 60 61 62
  /// * [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.
63
  const CupertinoSlider({
64
    super.key,
65 66
    required this.value,
    required this.onChanged,
67 68
    this.onChangeStart,
    this.onChangeEnd,
69 70
    this.min = 0.0,
    this.max = 1.0,
71
    this.divisions,
72
    this.activeColor,
73
    this.thumbColor = CupertinoColors.white,
74 75 76 77 78
  }) : assert(value != null),
       assert(min != null),
       assert(max != null),
       assert(value >= min && value <= max),
       assert(divisions == null || divisions > 0),
79
       assert(thumbColor != null);
80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98

  /// 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
99
  /// CupertinoSlider(
100
  ///   value: _cupertinoSliderValue.toDouble(),
101 102 103 104 105
  ///   min: 1.0,
  ///   max: 10.0,
  ///   divisions: 10,
  ///   onChanged: (double newValue) {
  ///     setState(() {
106
  ///       _cupertinoSliderValue = newValue.round();
107 108
  ///     });
  ///   },
109
  /// )
110
  /// ```
111
  ///
112 113 114 115 116 117
  /// 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.
118
  final ValueChanged<double>? onChanged;
119

120 121 122 123 124 125 126 127 128
  /// 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.
  ///
129
  /// {@tool snippet}
130 131
  ///
  /// ```dart
132
  /// CupertinoSlider(
133 134 135 136 137 138 139 140 141 142 143 144 145 146
  ///   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');
  ///   },
  /// )
  /// ```
147
  /// {@end-tool}
148 149 150 151 152
  ///
  /// See also:
  ///
  ///  * [onChangeEnd] for a callback that is called when the value change is
  ///    complete.
153
  final ValueChanged<double>? onChangeStart;
154 155 156 157 158 159 160

  /// 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.
  ///
161
  /// {@tool snippet}
162 163
  ///
  /// ```dart
164
  /// CupertinoSlider(
165 166 167 168 169 170 171 172 173 174 175 176 177 178
  ///   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');
  ///   },
  /// )
  /// ```
179
  /// {@end-tool}
180 181 182 183 184
  ///
  /// See also:
  ///
  ///  * [onChangeStart] for a callback that is called when a value change
  ///    begins.
185
  final ValueChanged<double>? onChangeEnd;
186

187
  /// The minimum value the user can select.
188 189 190 191 192 193 194 195 196 197 198 199
  ///
  /// 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.
200
  final int? divisions;
201 202

  /// The color to use for the portion of the slider that has been selected.
203
  ///
xster's avatar
xster committed
204
  /// Defaults to the [CupertinoTheme]'s primary color if null.
205
  final Color? activeColor;
206

207 208 209 210 211 212 213
  /// The color to use for the thumb of the slider.
  ///
  /// Thumb color must not be null.
  ///
  /// Defaults to [CupertinoColors.white].
  final Color thumbColor;

214
  @override
215
  State<CupertinoSlider> createState() => _CupertinoSliderState();
216 217

  @override
218 219
  void debugFillProperties(DiagnosticPropertiesBuilder properties) {
    super.debugFillProperties(properties);
220 221 222
    properties.add(DoubleProperty('value', value));
    properties.add(DoubleProperty('min', min));
    properties.add(DoubleProperty('max', max));
223
  }
224 225 226 227
}

class _CupertinoSliderState extends State<CupertinoSlider> with TickerProviderStateMixin {
  void _handleChanged(double value) {
228
    assert(widget.onChanged != null);
229
    final double lerpValue = lerpDouble(widget.min, widget.max, value)!;
230
    if (lerpValue != widget.value) {
231
      widget.onChanged!(lerpValue);
232 233 234 235 236
    }
  }

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

  void _handleDragEnd(double value) {
    assert(widget.onChangeEnd != null);
242
    widget.onChangeEnd!(lerpDouble(widget.min, widget.max, value)!);
243 244 245 246
  }

  @override
  Widget build(BuildContext context) {
247
    return _CupertinoSliderRenderObjectWidget(
248 249
      value: (widget.value - widget.min) / (widget.max - widget.min),
      divisions: widget.divisions,
250 251
      activeColor: CupertinoDynamicColor.resolve(
        widget.activeColor ?? CupertinoTheme.of(context).primaryColor,
252
        context,
253
      ),
254
      thumbColor: widget.thumbColor,
255
      onChanged: widget.onChanged != null ? _handleChanged : null,
256 257
      onChangeStart: widget.onChangeStart != null ? _handleDragStart : null,
      onChangeEnd: widget.onChangeEnd != null ? _handleDragEnd : null,
258 259 260 261 262 263
      vsync: this,
    );
  }
}

class _CupertinoSliderRenderObjectWidget extends LeafRenderObjectWidget {
264
  const _CupertinoSliderRenderObjectWidget({
265
    required this.value,
266
    this.divisions,
267 268
    required this.activeColor,
    required this.thumbColor,
269
    this.onChanged,
270 271
    this.onChangeStart,
    this.onChangeEnd,
272
    required this.vsync,
273
  });
274 275

  final double value;
276
  final int? divisions;
277
  final Color activeColor;
278
  final Color thumbColor;
279 280 281
  final ValueChanged<double>? onChanged;
  final ValueChanged<double>? onChangeStart;
  final ValueChanged<double>? onChangeEnd;
282 283 284 285
  final TickerProvider vsync;

  @override
  _RenderCupertinoSlider createRenderObject(BuildContext context) {
286
    assert(debugCheckHasDirectionality(context));
287
    return _RenderCupertinoSlider(
288 289 290
      value: value,
      divisions: divisions,
      activeColor: activeColor,
291 292
      thumbColor: CupertinoDynamicColor.resolve(thumbColor, context),
      trackColor: CupertinoDynamicColor.resolve(CupertinoColors.systemFill, context),
293
      onChanged: onChanged,
294 295
      onChangeStart: onChangeStart,
      onChangeEnd: onChangeEnd,
296
      vsync: vsync,
297
      textDirection: Directionality.of(context),
298
      cursor: kIsWeb ? SystemMouseCursors.click : MouseCursor.defer,
299 300 301 302 303
    );
  }

  @override
  void updateRenderObject(BuildContext context, _RenderCupertinoSlider renderObject) {
304
    assert(debugCheckHasDirectionality(context));
305 306 307 308
    renderObject
      ..value = value
      ..divisions = divisions
      ..activeColor = activeColor
309 310
      ..thumbColor = CupertinoDynamicColor.resolve(thumbColor, context)
      ..trackColor = CupertinoDynamicColor.resolve(CupertinoColors.systemFill, context)
311
      ..onChanged = onChanged
312 313
      ..onChangeStart = onChangeStart
      ..onChangeEnd = onChangeEnd
314
      ..textDirection = Directionality.of(context);
315 316 317 318 319 320 321 322
    // 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.
323
const Duration _kDiscreteTransitionDuration = Duration(milliseconds: 500);
324 325 326

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

327
class _RenderCupertinoSlider extends RenderConstrainedBox implements MouseTrackerAnnotation {
328
  _RenderCupertinoSlider({
329 330 331 332 333 334
    required double value,
    int? divisions,
    required Color activeColor,
    required Color thumbColor,
    required Color trackColor,
    ValueChanged<double>? onChanged,
335 336
    this.onChangeStart,
    this.onChangeEnd,
337 338
    required TickerProvider vsync,
    required TextDirection textDirection,
339
    MouseCursor cursor = MouseCursor.defer,
340
  }) : assert(value != null && value >= 0.0 && value <= 1.0),
341
       assert(textDirection != null),
342 343
       assert(cursor != null),
       _cursor = cursor,
344
       _value = value,
345 346
       _divisions = divisions,
       _activeColor = activeColor,
347
       _thumbColor = thumbColor,
348
       _trackColor = trackColor,
349
       _onChanged = onChanged,
350
       _textDirection = textDirection,
351
       super(additionalConstraints: const BoxConstraints.tightFor(width: _kSliderWidth, height: _kSliderHeight)) {
352
    _drag = HorizontalDragGestureRecognizer()
353 354 355
      ..onStart = _handleDragStart
      ..onUpdate = _handleDragUpdate
      ..onEnd = _handleDragEnd;
356
    _position = AnimationController(
357 358 359 360 361 362 363 364 365 366
      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);
367
    if (newValue == _value) {
368
      return;
369
    }
370
    _value = newValue;
371
    if (divisions != null) {
372
      _position.animateTo(newValue, curve: Curves.fastOutSlowIn);
373
    } else {
374
      _position.value = newValue;
375
    }
376
    markNeedsSemanticsUpdate();
377 378
  }

379 380 381
  int? get divisions => _divisions;
  int? _divisions;
  set divisions(int? value) {
382
    if (value == _divisions) {
383
      return;
384
    }
385
    _divisions = value;
386 387 388 389 390 391
    markNeedsPaint();
  }

  Color get activeColor => _activeColor;
  Color _activeColor;
  set activeColor(Color value) {
392
    if (value == _activeColor) {
393
      return;
394
    }
395 396 397 398
    _activeColor = value;
    markNeedsPaint();
  }

399 400 401
  Color get thumbColor => _thumbColor;
  Color _thumbColor;
  set thumbColor(Color value) {
402
    if (value == _thumbColor) {
403
      return;
404
    }
405 406 407 408
    _thumbColor = value;
    markNeedsPaint();
  }

409 410 411
  Color get trackColor => _trackColor;
  Color _trackColor;
  set trackColor(Color value) {
412
    if (value == _trackColor) {
413
      return;
414
    }
415 416 417 418
    _trackColor = value;
    markNeedsPaint();
  }

419 420 421
  ValueChanged<double>? get onChanged => _onChanged;
  ValueChanged<double>? _onChanged;
  set onChanged(ValueChanged<double>? value) {
422
    if (value == _onChanged) {
423
      return;
424
    }
425 426
    final bool wasInteractive = isInteractive;
    _onChanged = value;
427
    if (wasInteractive != isInteractive) {
428
      markNeedsSemanticsUpdate();
429
    }
430
  }
431

432 433
  ValueChanged<double>? onChangeStart;
  ValueChanged<double>? onChangeEnd;
434

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

446
  late AnimationController _position;
447

448
  late HorizontalDragGestureRecognizer _drag;
449 450 451
  double _currentDragValue = 0.0;

  double get _discretizedCurrentDragValue {
452
    double dragValue = clampDouble(_currentDragValue, 0.0, 1.0);
453
    if (divisions != null) {
454
      dragValue = (dragValue * divisions!).round() / divisions!;
455
    }
456 457 458 459 460
    return dragValue;
  }

  double get _trackLeft => _kPadding;
  double get _trackRight => size.width - _kPadding;
461
  double get _thumbCenter {
462
    final double visualPosition;
463 464 465 466 467 468 469 470
    switch (textDirection) {
      case TextDirection.rtl:
        visualPosition = 1.0 - _value;
        break;
      case TextDirection.ltr:
        visualPosition = _value;
        break;
    }
471
    return lerpDouble(_trackLeft + CupertinoThumbPainter.radius, _trackRight - CupertinoThumbPainter.radius, visualPosition)!;
472
  }
473 474 475

  bool get isInteractive => onChanged != null;

476
  void _handleDragStart(DragStartDetails details) => _startInteraction(details.globalPosition);
477 478 479 480

  void _handleDragUpdate(DragUpdateDetails details) {
    if (isInteractive) {
      final double extent = math.max(_kPadding, size.width - 2.0 * (_kPadding + CupertinoThumbPainter.radius));
481
      final double valueDelta = details.primaryDelta! / extent;
482 483 484 485 486 487 488 489
      switch (textDirection) {
        case TextDirection.rtl:
          _currentDragValue -= valueDelta;
          break;
        case TextDirection.ltr:
          _currentDragValue += valueDelta;
          break;
      }
490
      onChanged!(_discretizedCurrentDragValue);
491 492 493
    }
  }

494 495 496 497
  void _handleDragEnd(DragEndDetails details) => _endInteraction();

  void _startInteraction(Offset globalPosition) {
    if (isInteractive) {
498
      onChangeStart?.call(_discretizedCurrentDragValue);
499
      _currentDragValue = _value;
500
      onChanged!(_discretizedCurrentDragValue);
501 502 503 504
    }
  }

  void _endInteraction() {
505
    onChangeEnd?.call(_discretizedCurrentDragValue);
506 507 508 509
    _currentDragValue = 0.0;
  }

  @override
510 511
  bool hitTestSelf(Offset position) {
    return (position.dx - _thumbCenter).abs() < CupertinoThumbPainter.radius + _kPadding;
512 513 514 515 516
  }

  @override
  void handleEvent(PointerEvent event, BoxHitTestEntry entry) {
    assert(debugHandleEvent(event, entry));
517
    if (event is PointerDownEvent && isInteractive) {
518
      _drag.addPointer(event);
519
    }
520 521 522 523
  }

  @override
  void paint(PaintingContext context, Offset offset) {
524 525 526
    final double visualPosition;
    final Color leftColor;
    final Color rightColor;
527 528 529
    switch (textDirection) {
      case TextDirection.rtl:
        visualPosition = 1.0 - _position.value;
Ian Hickson's avatar
Ian Hickson committed
530
        leftColor = _activeColor;
531
        rightColor = trackColor;
532 533 534
        break;
      case TextDirection.ltr:
        visualPosition = _position.value;
535
        leftColor = trackColor;
Ian Hickson's avatar
Ian Hickson committed
536
        rightColor = _activeColor;
537 538
        break;
    }
539 540 541 542 543 544 545 546

    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;

547
    final Canvas canvas = context.canvas;
548

549
    if (visualPosition > 0.0) {
xster's avatar
xster committed
550
      final Paint paint = Paint()..color = rightColor;
551
      canvas.drawRRect(RRect.fromLTRBXY(trackLeft, trackTop, trackActive, trackBottom, 1.0, 1.0), paint);
552 553
    }

554
    if (visualPosition < 1.0) {
xster's avatar
xster committed
555
      final Paint paint = Paint()..color = leftColor;
556
      canvas.drawRRect(RRect.fromLTRBXY(trackActive, trackTop, trackRight, trackBottom, 1.0, 1.0), paint);
557 558
    }

559
    final Offset thumbCenter = Offset(trackActive, trackCenter);
560
    CupertinoThumbPainter(color: thumbColor).paint(canvas, Rect.fromCircle(center: thumbCenter, radius: CupertinoThumbPainter.radius));
561 562 563
  }

  @override
564 565
  void describeSemanticsConfiguration(SemanticsConfiguration config) {
    super.describeSemanticsConfiguration(config);
566

567
    config.isSemanticBoundary = isInteractive;
568
    config.isSlider = true;
569
    if (isInteractive) {
570
      config.textDirection = textDirection;
571 572
      config.onIncrease = _increaseAction;
      config.onDecrease = _decreaseAction;
573
      config.value = '${(value * 100).round()}%';
574 575
      config.increasedValue = '${(clampDouble(value + _semanticActionUnit, 0.0, 1.0) * 100).round()}%';
      config.decreasedValue = '${(clampDouble(value - _semanticActionUnit, 0.0, 1.0) * 100).round()}%';
576 577 578
    }
  }

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

581
  void _increaseAction() {
582
    if (isInteractive) {
583
      onChanged!(clampDouble(value + _semanticActionUnit, 0.0, 1.0));
584
    }
585 586
  }

587
  void _decreaseAction() {
588
    if (isInteractive) {
589
      onChanged!(clampDouble(value - _semanticActionUnit, 0.0, 1.0));
590
    }
591
  }
592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614

  @override
  MouseCursor get cursor => _cursor;
  MouseCursor _cursor;
  set cursor(MouseCursor value) {
    if (_cursor != value) {
      _cursor = value;
      // A repaint is needed in order to trigger a device update of
      // [MouseTracker] so that this new value can be found.
      markNeedsPaint();
    }
  }

  @override
  PointerEnterEventListener? onEnter;

  PointerHoverEventListener? onHover;

  @override
  PointerExitEventListener? onExit;

  @override
  bool get validForMouseTracker => false;
615
}