slider.dart 37.1 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 'dart:async';
6 7
import 'dart:math' as math;

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

14
import 'constants.dart';
15
import 'debug.dart';
16 17
import 'material.dart';
import 'slider_theme.dart';
18 19
import 'theme.dart';

20
// Examples can assume:
21
// int _dollars = 0;
22
// int _duelCommandment = 1;
23
// void setState(VoidCallback fn) { }
24

25 26 27 28 29
/// A callback that formats a numeric value from a [Slider] widget.
///
/// See also:
///
///   * [Slider.semanticFormatterCallback], which shows an example use case.
30
typedef SemanticFormatterCallback = String Function(double value);
31

32
/// A Material Design slider.
33
///
34 35 36
/// Used to select from a range of values.
///
/// A slider can be used to select from either a continuous or a discrete set of
37 38
/// values. The default is to use a continuous range of values from [min] to
/// [max]. To use discrete values, use a non-null value for [divisions], which
39
/// indicates the number of discrete intervals. For example, if [min] is 0.0 and
40
/// [max] is 50.0 and [divisions] is 5, then the slider can take on the
41
/// discrete values 0.0, 10.0, 20.0, 30.0, 40.0, and 50.0.
42
///
43 44 45 46
/// The terms for the parts of a slider are:
///
///  * The "thumb", which is a shape that slides horizontally when the user
///    drags it.
47
///  * The "track", which is the line that the slider thumb slides along.
48 49 50 51 52 53 54
///  * The "value indicator", which is a shape that pops up when the user
///    is dragging the thumb to indicate the value being selected.
///  * The "active" side of the slider is the side between the thumb and the
///    minimum value.
///  * The "inactive" side of the slider is the side between the thumb and the
///    maximum value.
///
55 56 57
/// The slider will be disabled if [onChanged] is null or if the range given by
/// [min]..[max] is empty (i.e. if [min] is equal to [max]).
///
58 59 60 61
/// The slider widget 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
62 63
/// slider. To know when the value starts to change, or when it is done
/// changing, set the optional callbacks [onChangeStart] and/or [onChangeEnd].
64
///
65
/// By default, a slider will be as wide as possible, centered vertically. When
66
/// given unbounded constraints, it will attempt to make the track 144 pixels
67
/// wide (with margins on each side) and will shrink-wrap vertically.
68
///
69 70
/// Requires one of its ancestors to be a [Material] widget.
///
71 72 73 74
/// Requires one of its ancestors to be a [MediaQuery] widget. Typically, these
/// are introduced by the [MaterialApp] or [WidgetsApp] widget at the top of
/// your application widget tree.
///
75 76 77 78 79 80 81
/// To determine how it should be displayed (e.g. colors, thumb shape, etc.),
/// a slider uses the [SliderThemeData] available from either a [SliderTheme]
/// widget or the [ThemeData.sliderTheme] a [Theme] widget above it in the
/// widget tree. You can also override some of the colors with the [activeColor]
/// and [inactiveColor] properties, although more fine-grained control of the
/// look is achieved using a [SliderThemeData].
///
82
/// See also:
83
///
84 85
///  * [SliderTheme] and [SliderThemeData] for information about controlling
///    the visual appearance of the slider.
86 87
///  * [Radio], for selecting among a set of explicit values.
///  * [Checkbox] and [Switch], for toggling a particular value on or off.
88
///  * <https://material.google.com/components/sliders.html>
89
///  * [MediaQuery], from which the text scale factor is obtained.
90
class Slider extends StatefulWidget {
91 92 93
  /// Creates a material design slider.
  ///
  /// The slider itself does not maintain any state. Instead, when the state of
94 95 96 97
  /// 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.
98 99
  ///
  /// * [value] determines currently selected value for this slider.
100 101 102 103 104 105
  /// * [onChanged] is called while the user is selecting a new value for the
  ///   slider.
  /// * [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.
106 107 108 109
  ///
  /// You can override some of the colors with the [activeColor] and
  /// [inactiveColor] properties, although more fine-grained control of the
  /// appearance is achieved using a [SliderThemeData].
110
  const Slider({
Hixie's avatar
Hixie committed
111
    Key key,
112 113
    @required this.value,
    @required this.onChanged,
114 115
    this.onChangeStart,
    this.onChangeEnd,
116 117
    this.min = 0.0,
    this.max = 1.0,
118 119
    this.divisions,
    this.label,
120
    this.activeColor,
121
    this.inactiveColor,
122
    this.semanticFormatterCallback,
123 124 125
  }) : assert(value != null),
       assert(min != null),
       assert(max != null),
126
       assert(min <= max),
127 128 129
       assert(value >= min && value <= max),
       assert(divisions == null || divisions > 0),
       super(key: key);
130

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

136 137
  /// Called during a drag when the user is selecting a new value for the slider
  /// by dragging.
138 139 140 141 142 143
  ///
  /// 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.
144 145 146 147 148
  ///
  /// 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:
  ///
149
  /// {@tool sample}
150
  ///
151
  /// ```dart
152
  /// Slider(
153 154 155 156 157 158 159 160 161 162
  ///   value: _duelCommandment.toDouble(),
  ///   min: 1.0,
  ///   max: 10.0,
  ///   divisions: 10,
  ///   label: '$_duelCommandment',
  ///   onChanged: (double newValue) {
  ///     setState(() {
  ///       _duelCommandment = newValue.round();
  ///     });
  ///   },
163
  /// )
164
  /// ```
165
  /// {@end-tool}
166 167 168 169 170 171 172
  ///
  /// 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.
173 174
  final ValueChanged<double> onChanged;

175 176 177 178 179 180 181 182 183
  /// 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 or with a tap.
  ///
  /// The value passed will be the last [value] that the slider had before the
  /// change began.
  ///
184
  /// {@tool sample}
185 186
  ///
  /// ```dart
187
  /// Slider(
188 189 190 191 192 193 194 195 196 197 198 199 200 201 202
  ///   value: _duelCommandment.toDouble(),
  ///   min: 1.0,
  ///   max: 10.0,
  ///   divisions: 10,
  ///   label: '$_duelCommandment',
  ///   onChanged: (double newValue) {
  ///     setState(() {
  ///       _duelCommandment = newValue.round();
  ///     });
  ///   },
  ///   onChangeStart: (double startValue) {
  ///     print('Started change at $startValue');
  ///   },
  /// )
  /// ```
203
  /// {@end-tool}
204 205 206 207 208 209 210 211 212 213 214 215 216
  ///
  /// 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 or a click.
  ///
217
  /// {@tool sample}
218 219
  ///
  /// ```dart
220
  /// Slider(
221 222 223 224 225 226 227 228 229 230 231 232 233 234 235
  ///   value: _duelCommandment.toDouble(),
  ///   min: 1.0,
  ///   max: 10.0,
  ///   divisions: 10,
  ///   label: '$_duelCommandment',
  ///   onChanged: (double newValue) {
  ///     setState(() {
  ///       _duelCommandment = newValue.round();
  ///     });
  ///   },
  ///   onChangeEnd: (double newValue) {
  ///     print('Ended change on $newValue');
  ///   },
  /// )
  /// ```
236
  /// {@end-tool}
237 238 239 240 241 242 243
  ///
  /// See also:
  ///
  ///  * [onChangeStart] for a callback that is called when a value change
  ///    begins.
  final ValueChanged<double> onChangeEnd;

244
  /// The minimum value the user can select.
245
  ///
246 247 248
  /// Defaults to 0.0. Must be less than or equal to [max].
  ///
  /// If the [max] is equal to the [min], then the slider is disabled.
Hixie's avatar
Hixie committed
249
  final double min;
250 251 252

  /// The maximum value the user can select.
  ///
253 254 255
  /// Defaults to 1.0. Must be greater than or equal to [min].
  ///
  /// If the [max] is equal to the [min], then the slider is disabled.
Hixie's avatar
Hixie committed
256
  final double max;
257

258 259 260 261 262 263 264 265 266
  /// The number of discrete divisions.
  ///
  /// Typically used with [label] to show the current discrete value.
  ///
  /// If null, the slider is continuous.
  final int divisions;

  /// A label to show above the slider when the slider is active.
  ///
267 268 269 270 271 272 273 274 275 276 277 278
  /// It is used to display the value of a discrete slider, and it is displayed
  /// as part of the value indicator shape.
  ///
  /// The label is rendered using the active [ThemeData]'s
  /// [ThemeData.accentTextTheme.body2] text style.
  ///
  /// If null, then the value indicator will not be displayed.
  ///
  /// See also:
  ///
  ///  * [SliderComponentShape] for how to create a custom value indicator
  ///    shape.
279 280
  final String label;

281
  /// The color to use for the portion of the slider track that is active.
282
  ///
283 284
  /// The "active" side of the slider is the side between the thumb and the
  /// minimum value.
285
  ///
286
  /// Defaults to [SliderTheme.activeTrackColor] of the current [SliderTheme].
287 288 289 290
  ///
  /// Using a [SliderTheme] gives much more fine-grained control over the
  /// appearance of various components of the slider.
  final Color activeColor;
291

292
  /// The color for the inactive portion of the slider track.
293
  ///
294 295
  /// The "inactive" side of the slider is the side between the thumb and the
  /// maximum value.
296
  ///
297
  /// Defaults to the [SliderTheme.inactiveTrackColor] of the current
298
  /// [SliderTheme].
299
  ///
300 301 302
  /// Using a [SliderTheme] gives much more fine-grained control over the
  /// appearance of various components of the slider.
  final Color inactiveColor;
303

304 305 306 307 308 309 310
  /// The callback used to create a semantic value from a slider value.
  ///
  /// Defaults to formatting values as a percentage.
  ///
  /// This is used by accessibility frameworks like TalkBack on Android to
  /// inform users what the currently selected value is with more context.
  ///
311
  /// {@tool sample}
312 313 314 315 316
  ///
  /// In the example below, a slider for currency values is configured to
  /// announce a value with a currency label.
  ///
  /// ```dart
317
  /// Slider(
318 319 320 321 322 323 324 325 326 327 328 329 330 331
  ///   value: _dollars.toDouble(),
  ///   min: 20.0,
  ///   max: 330.0,
  ///   label: '$_dollars dollars',
  ///   onChanged: (double newValue) {
  ///     setState(() {
  ///       _dollars = newValue.round();
  ///     });
  ///   },
  ///   semanticFormatterCallback: (double newValue) {
  ///     return '${newValue.round()} dollars';
  ///   }
  ///  )
  /// ```
332
  /// {@end-tool}
333 334
  final SemanticFormatterCallback semanticFormatterCallback;

335
  @override
336
  _SliderState createState() => _SliderState();
337 338

  @override
339 340
  void debugFillProperties(DiagnosticPropertiesBuilder properties) {
    super.debugFillProperties(properties);
341 342 343
    properties.add(DoubleProperty('value', value));
    properties.add(DoubleProperty('min', min));
    properties.add(DoubleProperty('max', max));
344
  }
345 346 347
}

class _SliderState extends State<Slider> with TickerProviderStateMixin {
348 349
  static const Duration enableAnimationDuration = Duration(milliseconds: 75);
  static const Duration valueIndicatorAnimationDuration = Duration(milliseconds: 100);
350 351 352 353 354 355 356

  // Animation controller that is run when the overlay (a.k.a radial reaction)
  // is shown in response to user interaction.
  AnimationController overlayController;
  // Animation controller that is run when the value indicator is being shown
  // or hidden.
  AnimationController valueIndicatorController;
357
  // Animation controller that is run when enabling/disabling the slider.
358
  AnimationController enableController;
359 360
  // Animation controller that is run when transitioning between one value
  // and the next on a discrete slider.
361
  AnimationController positionController;
362
  Timer interactionTimer;
363 364 365 366

  @override
  void initState() {
    super.initState();
367
    overlayController = AnimationController(
368 369 370
      duration: kRadialReactionDuration,
      vsync: this,
    );
371
    valueIndicatorController = AnimationController(
372 373 374
      duration: valueIndicatorAnimationDuration,
      vsync: this,
    );
375
    enableController = AnimationController(
376 377 378
      duration: enableAnimationDuration,
      vsync: this,
    );
379
    positionController = AnimationController(
380
      duration: Duration.zero,
381 382
      vsync: this,
    );
383
    enableController.value = widget.onChanged != null ? 1.0 : 0.0;
384
    positionController.value = _unlerp(widget.value);
385 386 387 388
  }

  @override
  void dispose() {
389
    interactionTimer?.cancel();
390 391
    overlayController.dispose();
    valueIndicatorController.dispose();
392 393 394
    enableController.dispose();
    positionController.dispose();
    super.dispose();
395 396
  }

Hixie's avatar
Hixie committed
397
  void _handleChanged(double value) {
398
    assert(widget.onChanged != null);
399 400 401 402
    final double lerpValue = _lerp(value);
    if (lerpValue != widget.value) {
      widget.onChanged(lerpValue);
    }
Hixie's avatar
Hixie committed
403 404
  }

405 406 407 408 409 410 411 412 413 414
  void _handleDragStart(double value) {
    assert(widget.onChangeStart != null);
    widget.onChangeStart(_lerp(value));
  }

  void _handleDragEnd(double value) {
    assert(widget.onChangeEnd != null);
    widget.onChangeEnd(_lerp(value));
  }

415 416 417 418 419 420
  // Returns a number between min and max, proportional to value, which must
  // be between 0.0 and 1.0.
  double _lerp(double value) {
    assert(value >= 0.0);
    assert(value <= 1.0);
    return value * (widget.max - widget.min) + widget.min;
421 422
  }

423 424 425 426
  // Returns a number between 0.0 and 1.0, given a value between min and max.
  double _unlerp(double value) {
    assert(value <= widget.max);
    assert(value >= widget.min);
427
    return widget.max > widget.min ? (value - widget.min) / (widget.max - widget.min) : 0.0;
428
  }
429

430
  @override
431
  Widget build(BuildContext context) {
432
    assert(debugCheckHasMaterial(context));
433
    assert(debugCheckHasMediaQuery(context));
434 435 436 437 438 439 440 441

    SliderThemeData sliderTheme = SliderTheme.of(context);

    // If the widget has active or inactive colors specified, then we plug them
    // in to the slider theme as best we can. If the developer wants more
    // control than that, then they need to use a SliderTheme.
    if (widget.activeColor != null || widget.inactiveColor != null) {
      sliderTheme = sliderTheme.copyWith(
442 443
        activeTrackColor: widget.activeColor,
        inactiveTrackColor: widget.inactiveColor,
444 445 446 447 448 449 450 451
        activeTickMarkColor: widget.inactiveColor,
        inactiveTickMarkColor: widget.activeColor,
        thumbColor: widget.activeColor,
        valueIndicatorColor: widget.activeColor,
        overlayColor: widget.activeColor?.withAlpha(0x29),
      );
    }

452
    return _SliderRenderObjectWidget(
453
      value: _unlerp(widget.value),
454 455
      divisions: widget.divisions,
      label: widget.label,
456
      sliderTheme: sliderTheme,
457
      mediaQueryData: MediaQuery.of(context),
458
      onChanged: (widget.onChanged != null) && (widget.max > widget.min) ? _handleChanged : null,
459 460
      onChangeStart: widget.onChangeStart != null ? _handleDragStart : null,
      onChangeEnd: widget.onChangeEnd != null ? _handleDragEnd : null,
461
      state: this,
462
      semanticFormatterCallback: widget.semanticFormatterCallback,
463 464 465 466 467
    );
  }
}

class _SliderRenderObjectWidget extends LeafRenderObjectWidget {
468
  const _SliderRenderObjectWidget({
469 470 471 472
    Key key,
    this.value,
    this.divisions,
    this.label,
473
    this.sliderTheme,
474
    this.mediaQueryData,
475
    this.onChanged,
476 477
    this.onChangeStart,
    this.onChangeEnd,
478
    this.state,
479
    this.semanticFormatterCallback,
480
  }) : super(key: key);
481 482

  final double value;
483 484
  final int divisions;
  final String label;
485
  final SliderThemeData sliderTheme;
486
  final MediaQueryData mediaQueryData;
487
  final ValueChanged<double> onChanged;
488 489
  final ValueChanged<double> onChangeStart;
  final ValueChanged<double> onChangeEnd;
490
  final SemanticFormatterCallback semanticFormatterCallback;
491
  final _SliderState state;
492

493
  @override
494
  _RenderSlider createRenderObject(BuildContext context) {
495
    return _RenderSlider(
496 497 498
      value: value,
      divisions: divisions,
      label: label,
499 500
      sliderTheme: sliderTheme,
      theme: Theme.of(context),
501
      mediaQueryData: mediaQueryData,
502
      onChanged: onChanged,
503 504
      onChangeStart: onChangeStart,
      onChangeEnd: onChangeEnd,
505
      state: state,
506
      textDirection: Directionality.of(context),
507 508
      semanticFormatterCallback: semanticFormatterCallback,
      platform: Theme.of(context).platform,
509 510
    );
  }
511

512
  @override
513
  void updateRenderObject(BuildContext context, _RenderSlider renderObject) {
514 515
    renderObject
      ..value = value
516 517
      ..divisions = divisions
      ..label = label
518 519
      ..sliderTheme = sliderTheme
      ..theme = Theme.of(context)
520
      ..mediaQueryData = mediaQueryData
521
      ..onChanged = onChanged
522 523
      ..onChangeStart = onChangeStart
      ..onChangeEnd = onChangeEnd
524 525 526
      ..textDirection = Directionality.of(context)
      ..semanticFormatterCallback = semanticFormatterCallback
      ..platform = Theme.of(context).platform;
527 528
    // Ticker provider cannot change since there's a 1:1 relationship between
    // the _SliderRenderObjectWidget object and the _SliderState object.
529 530 531
  }
}

532
class _RenderSlider extends RenderBox {
533
  _RenderSlider({
534
    @required double value,
535 536
    int divisions,
    String label,
537 538
    SliderThemeData sliderTheme,
    ThemeData theme,
539
    MediaQueryData mediaQueryData,
540
    TargetPlatform platform,
541
    ValueChanged<double> onChanged,
542
    SemanticFormatterCallback semanticFormatterCallback,
543 544
    this.onChangeStart,
    this.onChangeEnd,
545
    @required _SliderState state,
546
    @required TextDirection textDirection,
547
  }) : assert(value != null && value >= 0.0 && value <= 1.0),
548
       assert(state != null),
549
       assert(textDirection != null),
550 551
       _platform = platform,
       _semanticFormatterCallback = semanticFormatterCallback,
Ian Hickson's avatar
Ian Hickson committed
552
       _label = label,
553
       _value = value,
554
       _divisions = divisions,
555 556
       _sliderTheme = sliderTheme,
       _theme = theme,
557
       _mediaQueryData = mediaQueryData,
558
       _onChanged = onChanged,
559
       _state = state,
560
       _textDirection = textDirection {
Ian Hickson's avatar
Ian Hickson committed
561
    _updateLabelPainter();
562 563
    final GestureArenaTeam team = GestureArenaTeam();
    _drag = HorizontalDragGestureRecognizer()
564
      ..team = team
565 566
      ..onStart = _handleDragStart
      ..onUpdate = _handleDragUpdate
567 568
      ..onEnd = _handleDragEnd
      ..onCancel = _endInteraction;
569
    _tap = TapGestureRecognizer()
570
      ..team = team
571
      ..onTapDown = _handleTapDown
572 573
      ..onTapUp = _handleTapUp
      ..onTapCancel = _endInteraction;
574
    _overlayAnimation = CurvedAnimation(
575 576 577
      parent: _state.overlayController,
      curve: Curves.fastOutSlowIn,
    );
578
    _valueIndicatorAnimation = CurvedAnimation(
579
      parent: _state.valueIndicatorController,
580 581
      curve: Curves.fastOutSlowIn,
    );
582
    _enableAnimation = CurvedAnimation(
583 584 585
      parent: _state.enableController,
      curve: Curves.easeInOut,
    );
586 587
  }

588
  static const Duration _positionAnimationDuration = Duration(milliseconds: 75);
589 590
  static const double _overlayRadius = 16.0;
  static const double _overlayDiameter = _overlayRadius * 2.0;
591 592 593
  static const double _trackHeight = 2.0;
  static const double _preferredTrackWidth = 144.0;
  static const double _preferredTotalWidth = _preferredTrackWidth + _overlayDiameter;
594
  static const Duration _minimumInteractionTime = Duration(milliseconds: 500);
595
  static final Animatable<double> _overlayRadiusTween = Tween<double>(begin: 0.0, end: _overlayRadius);
596 597

  _SliderState _state;
598 599 600
  Animation<double> _overlayAnimation;
  Animation<double> _valueIndicatorAnimation;
  Animation<double> _enableAnimation;
601
  final TextPainter _labelPainter = TextPainter();
602 603 604 605 606
  HorizontalDragGestureRecognizer _drag;
  TapGestureRecognizer _tap;
  bool _active = false;
  double _currentDragValue = 0.0;

607
  double get _trackLength => size.width - _overlayDiameter;
608 609 610 611

  bool get isInteractive => onChanged != null;

  bool get isDiscrete => divisions != null && divisions > 0;
612

613 614
  double get value => _value;
  double _value;
615
  set value(double newValue) {
616
    assert(newValue != null && newValue >= 0.0 && newValue <= 1.0);
617 618
    final double convertedValue = isDiscrete ? _discretize(newValue) : newValue;
    if (convertedValue == _value) {
619
      return;
620 621 622
    }
    _value = convertedValue;
    if (isDiscrete) {
623 624 625 626 627 628 629
      // Reset the duration to match the distance that we're traveling, so that
      // whatever the distance, we still do it in _positionAnimationDuration,
      // and if we get re-targeted in the middle, it still takes that long to
      // get to the new location.
      final double distance = (_value - _state.positionController.value).abs();
      _state.positionController.duration = distance != 0.0
        ? _positionAnimationDuration * (1.0 / distance)
630
        : Duration.zero;
631 632 633 634
      _state.positionController.animateTo(convertedValue, curve: Curves.easeInOut);
    } else {
      _state.positionController.value = convertedValue;
    }
635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653
    markNeedsSemanticsUpdate();
  }

  TargetPlatform _platform;
  TargetPlatform get platform => _platform;
  set platform(TargetPlatform value) {
    if (_platform == value)
      return;
    _platform = value;
    markNeedsSemanticsUpdate();
  }

  SemanticFormatterCallback _semanticFormatterCallback;
  SemanticFormatterCallback get semanticFormatterCallback => _semanticFormatterCallback;
  set semanticFormatterCallback(SemanticFormatterCallback value) {
    if (_semanticFormatterCallback == value)
      return;
    _semanticFormatterCallback = value;
    markNeedsSemanticsUpdate();
654 655 656 657
  }

  int get divisions => _divisions;
  int _divisions;
658
  set divisions(int value) {
659
    if (value == _divisions) {
660
      return;
661
    }
662
    _divisions = value;
663 664 665 666 667
    markNeedsPaint();
  }

  String get label => _label;
  String _label;
668
  set label(String value) {
669
    if (value == _label) {
670
      return;
671
    }
672
    _label = value;
Ian Hickson's avatar
Ian Hickson committed
673
    _updateLabelPainter();
674 675
  }

676 677 678 679
  SliderThemeData get sliderTheme => _sliderTheme;
  SliderThemeData _sliderTheme;
  set sliderTheme(SliderThemeData value) {
    if (value == _sliderTheme) {
680
      return;
681 682
    }
    _sliderTheme = value;
683 684 685
    markNeedsPaint();
  }

686 687 688 689
  ThemeData get theme => _theme;
  ThemeData _theme;
  set theme(ThemeData value) {
    if (value == _theme) {
690
      return;
691 692
    }
    _theme = value;
693 694 695
    markNeedsPaint();
  }

696 697 698 699
  MediaQueryData get mediaQueryData => _mediaQueryData;
  MediaQueryData _mediaQueryData;
  set mediaQueryData(MediaQueryData value) {
    if (value == _mediaQueryData) {
700
      return;
701
    }
702 703 704
    _mediaQueryData = value;
    // Media query data includes the textScaleFactor, so we need to update the
    // label painter.
705 706 707
    _updateLabelPainter();
  }

708 709 710
  ValueChanged<double> get onChanged => _onChanged;
  ValueChanged<double> _onChanged;
  set onChanged(ValueChanged<double> value) {
711
    if (value == _onChanged) {
712
      return;
713
    }
714 715 716
    final bool wasInteractive = isInteractive;
    _onChanged = value;
    if (wasInteractive != isInteractive) {
717 718 719 720 721
      if (isInteractive) {
        _state.enableController.forward();
      } else {
        _state.enableController.reverse();
      }
722
      markNeedsPaint();
723
      markNeedsSemanticsUpdate();
724 725
    }
  }
726

727 728 729
  ValueChanged<double> onChangeStart;
  ValueChanged<double> onChangeEnd;

730 731 732 733
  TextDirection get textDirection => _textDirection;
  TextDirection _textDirection;
  set textDirection(TextDirection value) {
    assert(value != null);
734
    if (value == _textDirection) {
735
      return;
736
    }
737
    _textDirection = value;
Ian Hickson's avatar
Ian Hickson committed
738 739 740
    _updateLabelPainter();
  }

741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759
  bool get showValueIndicator {
    bool showValueIndicator;
    switch (_sliderTheme.showValueIndicator) {
      case ShowValueIndicator.onlyForDiscrete:
        showValueIndicator = isDiscrete;
        break;
      case ShowValueIndicator.onlyForContinuous:
        showValueIndicator = !isDiscrete;
        break;
      case ShowValueIndicator.always:
        showValueIndicator = true;
        break;
      case ShowValueIndicator.never:
        showValueIndicator = false;
        break;
    }
    return showValueIndicator;
  }

760 761 762 763 764 765 766 767 768 769 770 771 772
  double get _adjustmentUnit {
    switch (_platform) {
      case TargetPlatform.iOS:
      // Matches iOS implementation of material slider.
        return 0.1;
      case TargetPlatform.android:
      case TargetPlatform.fuchsia:
      default:
      // Matches Android implementation of material slider.
        return 0.05;
    }
  }

Ian Hickson's avatar
Ian Hickson committed
773 774 775
  void _updateLabelPainter() {
    if (label != null) {
      _labelPainter
776
        ..text = TextSpan(
777 778 779
          style: _sliderTheme.valueIndicatorTextStyle,
          text: label,
        )
Ian Hickson's avatar
Ian Hickson committed
780
        ..textDirection = textDirection
781
        ..textScaleFactor = _mediaQueryData.textScaleFactor
Ian Hickson's avatar
Ian Hickson committed
782 783 784 785 786 787 788 789
        ..layout();
    } else {
      _labelPainter.text = null;
    }
    // Changing the textDirection can result in the layout changing, because the
    // bidi algorithm might line up the glyphs differently which can result in
    // different ligatures, different shapes, etc. So we always markNeedsLayout.
    markNeedsLayout();
790 791
  }

792 793 794
  @override
  void attach(PipelineOwner owner) {
    super.attach(owner);
795 796
    _overlayAnimation.addListener(markNeedsPaint);
    _valueIndicatorAnimation.addListener(markNeedsPaint);
797 798 799 800 801 802
    _enableAnimation.addListener(markNeedsPaint);
    _state.positionController.addListener(markNeedsPaint);
  }

  @override
  void detach() {
803 804
    _overlayAnimation.removeListener(markNeedsPaint);
    _valueIndicatorAnimation.removeListener(markNeedsPaint);
805 806 807 808 809
    _enableAnimation.removeListener(markNeedsPaint);
    _state.positionController.removeListener(markNeedsPaint);
    super.detach();
  }

810 811 812 813 814 815 816 817 818 819
  double _getValueFromVisualPosition(double visualPosition) {
    switch (textDirection) {
      case TextDirection.rtl:
        return 1.0 - visualPosition;
      case TextDirection.ltr:
        return visualPosition;
    }
    return null;
  }

820
  double _getValueFromGlobalPosition(Offset globalPosition) {
821
    final double visualPosition = (globalToLocal(globalPosition).dx - _overlayRadius) / _trackLength;
822
    return _getValueFromVisualPosition(visualPosition);
823 824
  }

825 826
  double _discretize(double value) {
    double result = value.clamp(0.0, 1.0);
827
    if (isDiscrete) {
828
      result = (result * divisions).round() / divisions;
829
    }
830 831
    return result;
  }
832

833
  void _startInteraction(Offset globalPosition) {
834
    if (isInteractive) {
835
      _active = true;
836 837 838 839 840 841
      // We supply the *current* value as the start location, so that if we have
      // a tap, it consists of a call to onChangeStart with the previous value and
      // a call to onChangeEnd with the new value.
      if (onChangeStart != null) {
        onChangeStart(_discretize(value));
      }
842
      _currentDragValue = _getValueFromGlobalPosition(globalPosition);
843
      onChanged(_discretize(_currentDragValue));
844 845 846
      _state.overlayController.forward();
      if (showValueIndicator) {
        _state.valueIndicatorController.forward();
847
        _state.interactionTimer?.cancel();
848
        _state.interactionTimer = Timer(_minimumInteractionTime * timeDilation, () {
849 850 851
          _state.interactionTimer = null;
          if (!_active &&
              _state.valueIndicatorController.status == AnimationStatus.completed) {
852 853 854 855
            _state.valueIndicatorController.reverse();
          }
        });
      }
856 857 858 859
    }
  }

  void _endInteraction() {
860
    if (_active && _state.mounted) {
861 862 863
      if (onChangeEnd != null) {
        onChangeEnd(_discretize(_currentDragValue));
      }
864 865
      _active = false;
      _currentDragValue = 0.0;
866
      _state.overlayController.reverse();
867
      if (showValueIndicator && _state.interactionTimer == null) {
868 869
        _state.valueIndicatorController.reverse();
      }
870 871 872
    }
  }

873 874
  void _handleDragStart(DragStartDetails details) => _startInteraction(details.globalPosition);

875
  void _handleDragUpdate(DragUpdateDetails details) {
876
    if (isInteractive) {
877
      final double valueDelta = details.primaryDelta / _trackLength;
878 879 880 881 882 883 884 885
      switch (textDirection) {
        case TextDirection.rtl:
          _currentDragValue -= valueDelta;
          break;
        case TextDirection.ltr:
          _currentDragValue += valueDelta;
          break;
      }
886
      onChanged(_discretize(_currentDragValue));
887 888 889
    }
  }

890
  void _handleDragEnd(DragEndDetails details) => _endInteraction();
891

892 893 894
  void _handleTapDown(TapDownDetails details) => _startInteraction(details.globalPosition);

  void _handleTapUp(TapUpDetails details) => _endInteraction();
895

896
  @override
897
  bool hitTestSelf(Offset position) => true;
898

899
  @override
Ian Hickson's avatar
Ian Hickson committed
900
  void handleEvent(PointerEvent event, BoxHitTestEntry entry) {
901
    assert(debugHandleEvent(event, entry));
902 903
    if (event is PointerDownEvent && isInteractive) {
      // We need to add the drag first so that it has priority.
904
      _drag.addPointer(event);
905 906
      _tap.addPointer(event);
    }
907 908
  }

909 910
  @override
  double computeMinIntrinsicWidth(double height) {
911 912 913 914
    return math.max(
      _overlayDiameter,
      _sliderTheme.thumbShape.getPreferredSize(isInteractive, isDiscrete).width,
    );
915 916 917 918 919 920
  }

  @override
  double computeMaxIntrinsicWidth(double height) {
    // This doesn't quite match the definition of computeMaxIntrinsicWidth,
    // but it seems within the spirit...
921
    return _preferredTotalWidth;
922 923 924
  }

  @override
925
  double computeMinIntrinsicHeight(double width) => _overlayDiameter;
926 927

  @override
928
  double computeMaxIntrinsicHeight(double width) => _overlayDiameter;
929 930 931 932 933 934

  @override
  bool get sizedByParent => true;

  @override
  void performResize() {
935
    size = Size(
936 937
      constraints.hasBoundedWidth ? constraints.maxWidth : _preferredTotalWidth,
      constraints.hasBoundedHeight ? constraints.maxHeight : _overlayDiameter,
938 939 940
    );
  }

941
  void _paintTickMarks(
942
    Canvas canvas,
943 944
    Rect trackLeft,
    Rect trackRight,
945 946 947
    Paint leftPaint,
    Paint rightPaint,
  ) {
948
    if (isDiscrete) {
949 950 951 952
      // The ticks are tiny circles that are the same height as the track.
      const double tickRadius = _trackHeight / 2.0;
      final double trackWidth = trackRight.right - trackLeft.left;
      final double dx = (trackWidth - _trackHeight) / divisions;
953
      // If the ticks would be too dense, don't bother painting them.
954
      if (dx >= 3.0 * _trackHeight) {
955
        for (int i = 0; i <= divisions; i += 1) {
956
          final double left = trackLeft.left + i * dx;
957
          final Offset center = Offset(left + tickRadius, trackLeft.top + tickRadius);
958
          if (trackLeft.contains(center)) {
959
            canvas.drawCircle(center, tickRadius, leftPaint);
960
          } else if (trackRight.contains(center)) {
961 962 963 964 965 966 967 968
            canvas.drawCircle(center, tickRadius, rightPaint);
          }
        }
      }
    }
  }

  void _paintOverlay(Canvas canvas, Offset center) {
969
    if (!_overlayAnimation.isDismissed) {
970
      // TODO(gspencer): We don't really follow the spec here for overlays.
971 972 973 974
      // The spec says to use 16% opacity for drawing over light material,
      // and 32% for colored material, but we don't really have a way to
      // know what the underlying color is, so there's no easy way to
      // implement this. Choosing the "light" version for now.
975
      final Paint overlayPaint = Paint()..color = _sliderTheme.overlayColor;
976 977
      final double radius = _overlayRadiusTween.evaluate(_overlayAnimation);
      canvas.drawCircle(center, radius, overlayPaint);
978 979 980
    }
  }

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

985
    final double trackLength = size.width - 2 * _overlayRadius;
986
    final double value = _state.positionController.value;
987 988 989 990
    final ColorTween activeTrackEnableColor = ColorTween(begin: _sliderTheme.disabledActiveTrackColor, end: _sliderTheme.activeTrackColor);
    final ColorTween inactiveTrackEnableColor = ColorTween(begin: _sliderTheme.disabledInactiveTrackColor, end: _sliderTheme.inactiveTrackColor);
    final ColorTween activeTickMarkEnableColor = ColorTween(begin: _sliderTheme.disabledActiveTickMarkColor, end: _sliderTheme.activeTickMarkColor);
    final ColorTween inactiveTickMarkEnableColor = ColorTween(begin: _sliderTheme.disabledInactiveTickMarkColor, end: _sliderTheme.inactiveTickMarkColor);
991

992 993 994 995
    final Paint activeTrackPaint = Paint()..color = activeTrackEnableColor.evaluate(_enableAnimation);
    final Paint inactiveTrackPaint = Paint()..color = inactiveTrackEnableColor.evaluate(_enableAnimation);
    final Paint activeTickMarkPaint = Paint()..color = activeTickMarkEnableColor.evaluate(_enableAnimation);
    final Paint inactiveTickMarkPaint = Paint()..color = inactiveTickMarkEnableColor.evaluate(_enableAnimation);
996 997

    double visualPosition;
998 999
    Paint leftTrackPaint;
    Paint rightTrackPaint;
1000 1001
    Paint leftTickMarkPaint;
    Paint rightTickMarkPaint;
1002 1003 1004
    switch (textDirection) {
      case TextDirection.rtl:
        visualPosition = 1.0 - value;
1005 1006
        leftTrackPaint = inactiveTrackPaint;
        rightTrackPaint = activeTrackPaint;
1007 1008
        leftTickMarkPaint = inactiveTickMarkPaint;
        rightTickMarkPaint = activeTickMarkPaint;
1009 1010 1011
        break;
      case TextDirection.ltr:
        visualPosition = value;
1012 1013
        leftTrackPaint = activeTrackPaint;
        rightTrackPaint = inactiveTrackPaint;
1014 1015
        leftTickMarkPaint = activeTickMarkPaint;
        rightTickMarkPaint = inactiveTickMarkPaint;
1016 1017
        break;
    }
1018

1019
    const double trackRadius = _trackHeight / 2.0;
1020 1021
    const double thumbGap = 2.0;

1022 1023 1024 1025 1026 1027
    final double trackVerticalCenter = offset.dy + (size.height) / 2.0;
    final double trackLeft = offset.dx + _overlayRadius;
    final double trackTop = trackVerticalCenter - trackRadius;
    final double trackBottom = trackVerticalCenter + trackRadius;
    final double trackRight = trackLeft + trackLength;
    final double trackActive = trackLeft + trackLength * visualPosition;
1028
    final double thumbRadius = _sliderTheme.thumbShape.getPreferredSize(isInteractive, isDiscrete).width / 2.0;
1029 1030
    final double trackActiveLeft = math.max(0.0, trackActive - thumbRadius - thumbGap * (1.0 - _enableAnimation.value));
    final double trackActiveRight = math.min(trackActive + thumbRadius + thumbGap * (1.0 - _enableAnimation.value), trackRight);
1031 1032
    final Rect trackLeftRect = Rect.fromLTRB(trackLeft, trackTop, trackActiveLeft, trackBottom);
    final Rect trackRightRect = Rect.fromLTRB(trackActiveRight, trackTop, trackRight, trackBottom);
1033

1034
    final Offset thumbCenter = Offset(trackActive, trackVerticalCenter);
1035

1036
    // Paint the track.
1037
    if (visualPosition > 0.0) {
1038
      canvas.drawRect(trackLeftRect, leftTrackPaint);
1039 1040
    }
    if (visualPosition < 1.0) {
1041
      canvas.drawRect(trackRightRect, rightTrackPaint);
1042
    }
1043

1044
    _paintOverlay(canvas, thumbCenter);
1045

1046 1047
    _paintTickMarks(
      canvas,
1048 1049
      trackLeftRect,
      trackRightRect,
1050 1051 1052 1053
      leftTickMarkPaint,
      rightTickMarkPaint,
    );

1054 1055
    if (isInteractive && label != null &&
        _valueIndicatorAnimation.status != AnimationStatus.dismissed) {
1056 1057 1058 1059
      if (showValueIndicator) {
        _sliderTheme.valueIndicatorShape.paint(
          context,
          thumbCenter,
1060 1061 1062 1063 1064 1065 1066 1067
          activationAnimation: _valueIndicatorAnimation,
          enableAnimation: _enableAnimation,
          isDiscrete: isDiscrete,
          labelPainter: _labelPainter,
          parentBox: this,
          sliderTheme: _sliderTheme,
          textDirection: _textDirection,
          value: _value,
1068 1069
        );
      }
1070
    }
Adam Barth's avatar
Adam Barth committed
1071

1072 1073 1074
    _sliderTheme.thumbShape.paint(
      context,
      thumbCenter,
1075 1076 1077 1078 1079 1080 1081 1082
      activationAnimation: _valueIndicatorAnimation,
      enableAnimation: _enableAnimation,
      isDiscrete: isDiscrete,
      labelPainter: _labelPainter,
      parentBox: this,
      sliderTheme: _sliderTheme,
      textDirection: _textDirection,
      value: _value,
1083
    );
1084
  }
1085 1086

  @override
1087 1088
  void describeSemanticsConfiguration(SemanticsConfiguration config) {
    super.describeSemanticsConfiguration(config);
1089

1090 1091
    config.isSemanticBoundary = isInteractive;
    if (isInteractive) {
1092
      config.textDirection = textDirection;
1093 1094
      config.onIncrease = _increaseAction;
      config.onDecrease = _decreaseAction;
1095 1096 1097 1098 1099 1100 1101 1102 1103
      if (semanticFormatterCallback != null) {
        config.value = semanticFormatterCallback(_state._lerp(value));
        config.increasedValue = semanticFormatterCallback(_state._lerp((value + _semanticActionUnit).clamp(0.0, 1.0)));
        config.decreasedValue = semanticFormatterCallback(_state._lerp((value - _semanticActionUnit).clamp(0.0, 1.0)));
      } else {
        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()}%';
      }
1104 1105 1106
    }
  }

1107
  double get _semanticActionUnit => divisions != null ? 1.0 / divisions : _adjustmentUnit;
1108

1109
  void _increaseAction() {
1110
    if (isInteractive) {
1111
      onChanged((value + _semanticActionUnit).clamp(0.0, 1.0));
1112
    }
1113 1114
  }

1115
  void _decreaseAction() {
1116
    if (isInteractive) {
1117
      onChanged((value - _semanticActionUnit).clamp(0.0, 1.0));
1118
    }
1119
  }
1120
}