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

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

import 'colors.dart';
13
import 'constants.dart';
14
import 'debug.dart';
15
import 'theme.dart';
16
import 'typography.dart';
17

18 19
/// A material design slider.
///
20 21 22 23 24 25 26 27
/// 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.
28 29 30 31 32 33 34 35 36
///
/// 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.
///
/// Requires one of its ancestors to be a [Material] widget.
///
/// See also:
37
///
38 39 40
///  * [CheckBox]
///  * [Radio]
///  * [Switch]
41
///  * <https://material.google.com/components/sliders.html>
42
class Slider extends StatefulWidget {
43 44 45 46 47 48 49 50 51
  /// Creates a material design 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.
Hixie's avatar
Hixie committed
52 53
  Slider({
    Key key,
54 55
    @required this.value,
    @required this.onChanged,
Hixie's avatar
Hixie committed
56 57
    this.min: 0.0,
    this.max: 1.0,
58 59
    this.divisions,
    this.label,
60 61
    this.activeColor,
    this.thumbOpenAtMin: false,
Hixie's avatar
Hixie committed
62 63 64 65 66
  }) : super(key: key) {
    assert(value != null);
    assert(min != null);
    assert(max != null);
    assert(value >= min && value <= max);
67
    assert(divisions == null || divisions > 0);
68
    assert(thumbOpenAtMin != null);
Hixie's avatar
Hixie committed
69
  }
70

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

76 77 78 79 80 81 82
  /// 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.
83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101
  ///
  /// 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
  /// new Slider(
  ///   value: _duelCommandment.toDouble(),
  ///   min: 1.0,
  ///   max: 10.0,
  ///   divisions: 10,
  ///   label: '$_duelCommandment',
  ///   onChanged: (double newValue) {
  ///     setState(() {
  ///       _duelCommandment = newValue.round();
  ///     });
  ///   },
  /// ),
  /// ```
102 103
  final ValueChanged<double> onChanged;

104
  /// The minimum value the user can select.
105 106
  ///
  /// Defaults to 0.0.
Hixie's avatar
Hixie committed
107
  final double min;
108 109 110 111

  /// The maximum value the user can select.
  ///
  /// Defaults to 1.0.
Hixie's avatar
Hixie committed
112
  final double max;
113

114 115 116 117 118 119 120 121 122 123 124 125
  /// 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.
  ///
  /// Typically used to display the value of a discrete slider.
  final String label;

126 127
  /// The color to use for the portion of the slider that has been selected.
  ///
128
  /// Defaults to accent color of the current [Theme].
129
  final Color activeColor;
130

131 132 133 134 135 136 137 138 139 140 141 142 143
  /// Whether the thumb should be an open circle when the slider is at its minimum position.
  ///
  /// When this property is false, the thumb does not change when it the slider
  /// reaches its minimum position.
  ///
  /// This property is useful, for example, when the minimum value represents a
  /// qualitatively different state. For a slider that controls the volume of
  /// a sound, for example, the minimum value represents "no sound at all,"
  /// which is qualitatively different from even a very soft sound.
  ///
  /// Defaults to false.
  final bool thumbOpenAtMin;

144 145 146 147 148
  @override
  _SliderState createState() => new _SliderState();
}

class _SliderState extends State<Slider> with TickerProviderStateMixin {
Hixie's avatar
Hixie committed
149
  void _handleChanged(double value) {
150 151
    assert(config.onChanged != null);
    config.onChanged(value * (config.max - config.min) + config.min);
Hixie's avatar
Hixie committed
152 153
  }

154
  @override
155
  Widget build(BuildContext context) {
156
    assert(debugCheckHasMaterial(context));
157
    ThemeData theme = Theme.of(context);
158
    return new _SliderRenderObjectWidget(
159 160 161
      value: (config.value - config.min) / (config.max - config.min),
      divisions: config.divisions,
      label: config.label,
162
      activeColor: config.activeColor ?? theme.accentColor,
163
      thumbOpenAtMin: config.thumbOpenAtMin,
164
      textTheme: theme.accentTextTheme,
165 166
      onChanged: config.onChanged != null ? _handleChanged : null,
      vsync: this,
167 168 169 170 171
    );
  }
}

class _SliderRenderObjectWidget extends LeafRenderObjectWidget {
172 173 174 175 176 177
  _SliderRenderObjectWidget({
    Key key,
    this.value,
    this.divisions,
    this.label,
    this.activeColor,
178
    this.thumbOpenAtMin,
179
    this.textTheme,
180 181
    this.onChanged,
    this.vsync,
182
  }) : super(key: key);
183 184

  final double value;
185 186
  final int divisions;
  final String label;
187
  final Color activeColor;
188
  final bool thumbOpenAtMin;
189
  final TextTheme textTheme;
190
  final ValueChanged<double> onChanged;
191
  final TickerProvider vsync;
192

193
  @override
194 195 196 197 198 199 200 201 202 203 204 205
  _RenderSlider createRenderObject(BuildContext context) {
    return new _RenderSlider(
      value: value,
      divisions: divisions,
      label: label,
      activeColor: activeColor,
      thumbOpenAtMin: thumbOpenAtMin,
      textTheme: textTheme,
      onChanged: onChanged,
      vsync: vsync,
    );
  }
206

207
  @override
208
  void updateRenderObject(BuildContext context, _RenderSlider renderObject) {
209 210
    renderObject
      ..value = value
211 212
      ..divisions = divisions
      ..label = label
213
      ..activeColor = activeColor
214
      ..thumbOpenAtMin = thumbOpenAtMin
215
      ..textTheme = textTheme
216
      ..onChanged = onChanged;
217 218
      // Ticker provider cannot change since there's a 1:1 relationship between
      // the _SliderRenderObjectWidget object and the _SliderState object.
219 220 221
  }
}

222
const double _kThumbRadius = 6.0;
223 224
const double _kActiveThumbRadius = 9.0;
const double _kDisabledThumbRadius = 4.0;
225 226
const double _kReactionRadius = 16.0;
const double _kTrackWidth = 144.0;
227 228
final Color _kInactiveTrackColor = Colors.grey[400];
final Color _kActiveTrackColor = Colors.grey[500];
229 230 231
final Tween<double> _kReactionRadiusTween = new Tween<double>(begin: _kThumbRadius, end: _kReactionRadius);
final Tween<double> _kThumbRadiusTween = new Tween<double>(begin: _kThumbRadius, end: _kActiveThumbRadius);
final ColorTween _kTrackColorTween = new ColorTween(begin: _kInactiveTrackColor, end: _kActiveTrackColor);
Adam Barth's avatar
Adam Barth committed
232
final ColorTween _kTickColorTween = new ColorTween(begin: Colors.transparent, end: Colors.black54);
233 234 235 236 237 238 239 240
final Duration _kDiscreteTransitionDuration = const Duration(milliseconds: 500);

const double _kLabelBalloonRadius = 14.0;
final Tween<double> _kLabelBalloonCenterTween = new Tween<double>(begin: 0.0, end: -_kLabelBalloonRadius * 2.0);
final Tween<double> _kLabelBalloonRadiusTween = new Tween<double>(begin: _kThumbRadius, end: _kLabelBalloonRadius);
final Tween<double> _kLabelBalloonTipTween = new Tween<double>(begin: 0.0, end: -8.0);
final double _kLabelBalloonTipAttachmentRatio = math.sin(math.PI / 4.0);

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

243 244 245 246 247 248 249 250 251 252
double _getAdditionalHeightForLabel(String label) {
  return label == null ? 0.0 : _kLabelBalloonRadius * 2.0;
}

BoxConstraints _getAdditionalConstraints(String label) {
  return new BoxConstraints.tightFor(
    width: _kTrackWidth + 2 * _kReactionRadius,
    height: 2 * _kReactionRadius + _getAdditionalHeightForLabel(label)
  );
}
253

254
class _RenderSlider extends RenderConstrainedBox implements SemanticsActionHandler {
255 256
  _RenderSlider({
    double value,
257 258
    int divisions,
    String label,
259
    Color activeColor,
260
    bool thumbOpenAtMin,
261
    TextTheme textTheme,
262 263
    this.onChanged,
    TickerProvider vsync,
264
  }) : _value = value,
265
       _divisions = divisions,
266
       _activeColor = activeColor,
267
       _thumbOpenAtMin = thumbOpenAtMin,
268
       _textTheme = textTheme,
269
        super(additionalConstraints: _getAdditionalConstraints(label)) {
270
    assert(value != null && value >= 0.0 && value <= 1.0);
271
    this.label = label;
272
    GestureArenaTeam team = new GestureArenaTeam();
273
    _drag = new HorizontalDragGestureRecognizer()
274
      ..team = team
275 276 277
      ..onStart = _handleDragStart
      ..onUpdate = _handleDragUpdate
      ..onEnd = _handleDragEnd;
278 279 280
    _tap = new TapGestureRecognizer()
      ..team = team
      ..onTapUp = _handleTapUp;
281 282 283 284
    _reactionController = new AnimationController(
      duration: kRadialReactionDuration,
      vsync: vsync,
    );
285
    _reaction = new CurvedAnimation(
286
      parent: _reactionController,
287
      curve: Curves.fastOutSlowIn
288
    )..addListener(markNeedsPaint);
289 290
    _position = new AnimationController(
      value: value,
291 292
      duration: _kDiscreteTransitionDuration,
      vsync: vsync,
293
    )..addListener(markNeedsPaint);
294 295 296 297
  }

  double get value => _value;
  double _value;
298
  set value(double newValue) {
299 300 301 302
    assert(newValue != null && newValue >= 0.0 && newValue <= 1.0);
    if (newValue == _value)
      return;
    _value = newValue;
303
    if (divisions != null)
304
      _position.animateTo(newValue, curve: Curves.fastOutSlowIn);
305 306 307 308 309 310
    else
      _position.value = newValue;
  }

  int get divisions => _divisions;
  int _divisions;
311
  set divisions(int newDivisions) {
312 313 314 315 316 317 318 319
    if (newDivisions == _divisions)
      return;
    _divisions = newDivisions;
    markNeedsPaint();
  }

  String get label => _label;
  String _label;
320
  set label(String newLabel) {
321 322 323 324 325
    if (newLabel == _label)
      return;
    _label = newLabel;
    additionalConstraints = _getAdditionalConstraints(_label);
    if (newLabel != null) {
326 327
      // TODO(abarth): Handle textScaleFactor.
      // https://github.com/flutter/flutter/issues/5938
328 329
      _labelPainter
        ..text = new TextSpan(
330
          style: _textTheme.body1.copyWith(fontSize: 10.0),
331 332
          text: newLabel
        )
333
        ..layout();
334 335 336
    } else {
      _labelPainter.text = null;
    }
337 338 339
    markNeedsPaint();
  }

340 341
  Color get activeColor => _activeColor;
  Color _activeColor;
342
  set activeColor(Color value) {
343
    if (value == _activeColor)
344
      return;
345
    _activeColor = value;
346 347 348
    markNeedsPaint();
  }

349 350 351 352 353 354 355 356 357
  bool get thumbOpenAtMin => _thumbOpenAtMin;
  bool _thumbOpenAtMin;
  set thumbOpenAtMin(bool value) {
    if (value == _thumbOpenAtMin)
      return;
    _thumbOpenAtMin = value;
    markNeedsPaint();
  }

358 359 360 361 362 363 364 365 366
  TextTheme get textTheme => _textTheme;
  TextTheme _textTheme;
  set textTheme(TextTheme value) {
    if (value == _textTheme)
      return;
    _textTheme = value;
    markNeedsPaint();
  }

367 368 369
  ValueChanged<double> onChanged;

  double get _trackLength => size.width - 2.0 * _kReactionRadius;
370

371
  Animation<double> _reaction;
372
  AnimationController _reactionController;
373

374 375 376
  AnimationController _position;
  final TextPainter _labelPainter = new TextPainter();

377
  HorizontalDragGestureRecognizer _drag;
378
  TapGestureRecognizer _tap;
379 380 381
  bool _active = false;
  double _currentDragValue = 0.0;

382 383 384 385
  bool get isInteractive => onChanged != null;

  double _getValueFromGlobalPosition(Point globalPosition) {
    return (globalToLocal(globalPosition).x - _kReactionRadius) / _trackLength;
386 387
  }

388 389 390 391 392 393
  double _discretize(double value) {
    double result = value.clamp(0.0, 1.0);
    if (divisions != null)
      result = (result * divisions).round() / divisions;
    return result;
  }
394

395
  void _handleDragStart(DragStartDetails details) {
396
    if (isInteractive) {
397
      _active = true;
398 399
      _currentDragValue = _getValueFromGlobalPosition(details.globalPosition);
      onChanged(_discretize(_currentDragValue));
400
      _reactionController.forward();
401 402 403
    }
  }

404
  void _handleDragUpdate(DragUpdateDetails details) {
405
    if (isInteractive) {
406
      _currentDragValue += details.primaryDelta / _trackLength;
407
      onChanged(_discretize(_currentDragValue));
408 409 410
    }
  }

411
  void _handleDragEnd(DragEndDetails details) {
412 413 414
    if (_active) {
      _active = false;
      _currentDragValue = 0.0;
415
      _reactionController.reverse();
416 417 418
    }
  }

419 420 421 422 423
  void _handleTapUp(TapUpDetails details) {
    if (isInteractive && !_active)
      onChanged(_discretize(_getValueFromGlobalPosition(details.globalPosition)));
  }

424
  @override
425 426
  bool hitTestSelf(Point position) => true;

427
  @override
Ian Hickson's avatar
Ian Hickson committed
428
  void handleEvent(PointerEvent event, BoxHitTestEntry entry) {
429
    assert(debugHandleEvent(event, entry));
430 431
    if (event is PointerDownEvent && isInteractive) {
      // We need to add the drag first so that it has priority.
432
      _drag.addPointer(event);
433 434
      _tap.addPointer(event);
    }
435 436
  }

437
  @override
438 439 440 441
  void paint(PaintingContext context, Offset offset) {
    final Canvas canvas = context.canvas;

    final double trackLength = _trackLength;
442
    final bool enabled = isInteractive;
443
    final double value = _position.value;
444

445 446 447 448 449 450 451
    final double additionalHeightForLabel = _getAdditionalHeightForLabel(label);
    final double trackCenter = offset.dy + (size.height - additionalHeightForLabel) / 2.0 + additionalHeightForLabel;
    final double trackLeft = offset.dx + _kReactionRadius;
    final double trackTop = trackCenter - 1.0;
    final double trackBottom = trackCenter + 1.0;
    final double trackRight = trackLeft + trackLength;
    final double trackActive = trackLeft + trackLength * value;
452

453 454
    final Paint primaryPaint = new Paint()..color = enabled ? _activeColor : _kInactiveTrackColor;
    final Paint trackPaint = new Paint()..color = _kTrackColorTween.evaluate(_reaction);
455

456 457
    final Point thumbCenter = new Point(trackActive, trackCenter);
    final double thumbRadius = enabled ? _kThumbRadiusTween.evaluate(_reaction) : _kDisabledThumbRadius;
458

459
    if (enabled) {
460
      if (value > 0.0)
461
        canvas.drawRect(new Rect.fromLTRB(trackLeft, trackTop, trackActive, trackBottom), primaryPaint);
Adam Barth's avatar
Adam Barth committed
462 463 464 465 466
      if (value < 1.0) {
        final bool hasBalloon = _reaction.status != AnimationStatus.dismissed && label != null;
        final double trackActiveDelta = hasBalloon ? 0.0 : thumbRadius - 1.0;
        canvas.drawRect(new Rect.fromLTRB(trackActive + trackActiveDelta, trackTop, trackRight, trackBottom), trackPaint);
      }
467
    } else {
468 469 470 471
      if (value > 0.0)
        canvas.drawRect(new Rect.fromLTRB(trackLeft, trackTop, trackActive - _kDisabledThumbRadius - 2, trackBottom), trackPaint);
      if (value < 1.0)
        canvas.drawRect(new Rect.fromLTRB(trackActive + _kDisabledThumbRadius + 2, trackTop, trackRight, trackBottom), trackPaint);
472
    }
473

474
    if (_reaction.status != AnimationStatus.dismissed) {
475 476 477 478 479 480 481 482
      final int divisions = this.divisions;
      if (divisions != null) {
        const double tickWidth = 2.0;
        final double dx = (trackLength - tickWidth) / divisions;
        // If the ticks would be too dense, don't bother painting them.
        if (dx >= 3 * tickWidth) {
          final Paint tickPaint = new Paint()..color = _kTickColorTween.evaluate(_reaction);
          for (int i = 0; i <= divisions; i += 1) {
Adam Barth's avatar
Adam Barth committed
483
            final double left = trackLeft + i * dx;
484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509
            canvas.drawRect(new Rect.fromLTRB(left, trackTop, left + tickWidth, trackBottom), tickPaint);
          }
        }
      }

      if (label != null) {
        final Point center = new Point(trackActive, _kLabelBalloonCenterTween.evaluate(_reaction) + trackCenter);
        final double radius = _kLabelBalloonRadiusTween.evaluate(_reaction);
        final Point tip = new Point(trackActive, _kLabelBalloonTipTween.evaluate(_reaction) + trackCenter);
        final double tipAttachment = _kLabelBalloonTipAttachmentRatio * radius;

        canvas.drawCircle(center, radius, primaryPaint);
        Path path = new Path()
          ..moveTo(tip.x, tip.y)
          ..lineTo(center.x - tipAttachment, center.y + tipAttachment)
          ..lineTo(center.x + tipAttachment, center.y + tipAttachment)
          ..close();
        canvas.drawPath(path, primaryPaint);
        _labelPainter.layout();
        Offset labelOffset = new Offset(
          center.x - _labelPainter.width / 2.0,
          center.y - _labelPainter.height / 2.0
        );
        _labelPainter.paint(canvas, labelOffset);
        return;
      } else {
Adam Barth's avatar
Adam Barth committed
510 511
        final Color reactionBaseColor = value == 0.0 ? _kActiveTrackColor : _activeColor;
        final Paint reactionPaint = new Paint()..color = reactionBaseColor.withAlpha(kRadialReactionAlpha);
512 513
        canvas.drawCircle(thumbCenter, _kReactionRadiusTween.evaluate(_reaction), reactionPaint);
      }
514
    }
Adam Barth's avatar
Adam Barth committed
515 516 517

    Paint thumbPaint = primaryPaint;
    double thumbRadiusDelta = 0.0;
518
    if (value == 0.0 && thumbOpenAtMin) {
Adam Barth's avatar
Adam Barth committed
519 520 521 522 523 524 525 526
      thumbPaint = trackPaint;
      // This is destructive to trackPaint.
      thumbPaint
        ..style = PaintingStyle.stroke
        ..strokeWidth = 2.0;
      thumbRadiusDelta = -1.0;
    }
    canvas.drawCircle(thumbCenter, thumbRadius + thumbRadiusDelta, thumbPaint);
527
  }
528 529

  @override
530
  bool get isSemanticBoundary => isInteractive;
531 532

  @override
533
  SemanticsAnnotator get semanticsAnnotator => _annotate;
534 535 536 537

  void _annotate(SemanticsNode semantics) {
    if (isInteractive)
      semantics.addAdjustmentActions();
538 539 540
  }

  @override
541
  void performAction(SemanticsAction action) {
542
    final double unit = divisions != null ? 1.0 / divisions : _kAdjustmentUnit;
543
    switch (action) {
544
      case SemanticsAction.increase:
545
        if (isInteractive)
546
          onChanged((value + unit).clamp(0.0, 1.0));
547
        break;
548
      case SemanticsAction.decrease:
549
        if (isInteractive)
550
          onChanged((value - unit).clamp(0.0, 1.0));
551 552 553 554 555 556
        break;
      default:
        assert(false);
        break;
    }
  }
557
}