Add support for discrete material sliders
Fixes #1541
diff --git a/examples/material_gallery/lib/demo/slider_demo.dart b/examples/material_gallery/lib/demo/slider_demo.dart
index 0475476..7ce44cf 100644
--- a/examples/material_gallery/lib/demo/slider_demo.dart
+++ b/examples/material_gallery/lib/demo/slider_demo.dart
@@ -11,53 +11,44 @@
class _SliderDemoState extends State<SliderDemo> {
double _value = 25.0;
+ double _discreteValue = 20.0;
@override
Widget build(BuildContext context) {
return new Scaffold(
- appBar: new AppBar(title: new Text("Sliders")),
- body: new Block(children: <Widget>[
- new Container(
- height: 100.0,
- child: new Center(
- child: new Row(
- children: <Widget>[
- new Slider(
- value: _value,
- min: 0.0,
- max: 100.0,
- onChanged: (double value) {
- setState(() {
- _value = value;
- });
- }
- ),
- new Container(
- padding: const EdgeInsets.symmetric(horizontal: 16.0),
- child: new Text(_value.round().toString().padLeft(3, '0'))
- ),
- ],
- mainAxisAlignment: MainAxisAlignment.collapse
+ appBar: new AppBar(title: new Text('Sliders')),
+ body: new Column(
+ mainAxisAlignment: MainAxisAlignment.spaceAround,
+ children: <Widget>[
+ new Center(
+ child: new Slider(
+ value: _value,
+ min: 0.0,
+ max: 100.0,
+ onChanged: (double value) {
+ setState(() {
+ _value = value;
+ });
+ }
)
- )
- ),
- new Container(
- height: 100.0,
- child: new Center(
- child: new Row(
- children: <Widget>[
- // Disabled, but tracking the slider above.
- new Slider(value: _value / 100.0),
- new Container(
- padding: const EdgeInsets.symmetric(horizontal: 16.0),
- child: new Text((_value / 100.0).toStringAsFixed(2))
- ),
- ],
- mainAxisAlignment: MainAxisAlignment.collapse
+ ),
+ new Center(child: new Slider(value: _value / 100.0)),
+ new Center(
+ child: new Slider(
+ value: _discreteValue,
+ min: 0.0,
+ max: 100.0,
+ divisions: 5,
+ label: '${_discreteValue.round()}',
+ onChanged: (double value) {
+ setState(() {
+ _discreteValue = value;
+ });
+ }
)
- )
- )
- ])
+ ),
+ ]
+ )
);
}
}
diff --git a/packages/flutter/lib/src/material/slider.dart b/packages/flutter/lib/src/material/slider.dart
index 1db4b75..e4291ee 100644
--- a/packages/flutter/lib/src/material/slider.dart
+++ b/packages/flutter/lib/src/material/slider.dart
@@ -2,6 +2,8 @@
// 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 'package:flutter/gestures.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
@@ -10,10 +12,18 @@
import 'constants.dart';
import 'debug.dart';
import 'theme.dart';
+import 'typography.dart';
/// A material design slider.
///
-/// Used to select from a continuous range of values.
+/// 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
@@ -34,6 +44,8 @@
this.value,
this.min: 0.0,
this.max: 1.0,
+ this.divisions,
+ this.label,
this.activeColor,
this.onChanged
}) : super(key: key) {
@@ -41,6 +53,7 @@
assert(min != null);
assert(max != null);
assert(value >= min && value <= max);
+ assert(divisions == null || divisions > 0);
}
/// The currently selected value for this slider.
@@ -58,6 +71,18 @@
/// Defaults to 1.0.
final double max;
+ /// 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;
+
/// The color to use for the portion of the slider that has been selected.
///
/// Defaults to accent color of the current [Theme].
@@ -82,6 +107,8 @@
assert(debugCheckHasMaterial(context));
return new _SliderRenderObjectWidget(
value: (value - min) / (max - min),
+ divisions: divisions,
+ label: label,
activeColor: activeColor ?? Theme.of(context).accentColor,
onChanged: onChanged != null ? _handleChanged : null
);
@@ -89,16 +116,26 @@
}
class _SliderRenderObjectWidget extends LeafRenderObjectWidget {
- _SliderRenderObjectWidget({ Key key, this.value, this.activeColor, this.onChanged })
- : super(key: key);
+ _SliderRenderObjectWidget({
+ Key key,
+ this.value,
+ this.divisions,
+ this.label,
+ this.activeColor,
+ this.onChanged
+ }) : super(key: key);
final double value;
+ final int divisions;
+ final String label;
final Color activeColor;
final ValueChanged<double> onChanged;
@override
_RenderSlider createRenderObject(BuildContext context) => new _RenderSlider(
value: value,
+ divisions: divisions,
+ label: label,
activeColor: activeColor,
onChanged: onChanged
);
@@ -107,6 +144,8 @@
void updateRenderObject(BuildContext context, _RenderSlider renderObject) {
renderObject
..value = value
+ ..divisions = divisions
+ ..label = label
..activeColor = activeColor
..onChanged = onChanged;
}
@@ -122,16 +161,39 @@
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);
+final ColorTween _kTickColorTween = new ColorTween(begin: _kInactiveTrackColor, end: Colors.black54);
+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);
+
+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)
+ );
+}
class _RenderSlider extends RenderConstrainedBox {
_RenderSlider({
double value,
+ int divisions,
+ String label,
Color activeColor,
this.onChanged
}) : _value = value,
+ _divisions = divisions,
_activeColor = activeColor,
- super(additionalConstraints: const BoxConstraints.tightFor(width: _kTrackWidth + 2 * _kReactionRadius, height: 2 * _kReactionRadius)) {
+ super(additionalConstraints: _getAdditionalConstraints(label)) {
assert(value != null && value >= 0.0 && value <= 1.0);
+ this.label = label;
_drag = new HorizontalDragGestureRecognizer()
..onStart = _handleDragStart
..onUpdate = _handleDragUpdate
@@ -141,6 +203,10 @@
parent: _reactionController,
curve: Curves.ease
)..addListener(markNeedsPaint);
+ _position = new AnimationController(
+ value: value,
+ duration: _kDiscreteTransitionDuration
+ )..addListener(markNeedsPaint);
}
double get value => _value;
@@ -150,6 +216,38 @@
if (newValue == _value)
return;
_value = newValue;
+ if (divisions != null)
+ _position.animateTo(newValue, curve: Curves.ease);
+ else
+ _position.value = newValue;
+ }
+
+ int get divisions => _divisions;
+ int _divisions;
+ void set divisions(int newDivisions) {
+ if (newDivisions == _divisions)
+ return;
+ _divisions = newDivisions;
+ markNeedsPaint();
+ }
+
+ String get label => _label;
+ String _label;
+ void set label(String newLabel) {
+ if (newLabel == _label)
+ return;
+ _label = newLabel;
+ additionalConstraints = _getAdditionalConstraints(_label);
+ if (newLabel != null) {
+ _labelPainter
+ ..text = new TextSpan(
+ style: Typography.white.body1.copyWith(fontSize: 10.0),
+ text: newLabel
+ )
+ ..layoutToMaxIntrinsicWidth();
+ } else {
+ _labelPainter.text = null;
+ }
markNeedsPaint();
}
@@ -169,15 +267,25 @@
Animation<double> _reaction;
AnimationController _reactionController;
+ AnimationController _position;
+ final TextPainter _labelPainter = new TextPainter();
+
HorizontalDragGestureRecognizer _drag;
bool _active = false;
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;
+ }
+
void _handleDragStart(Point globalPosition) {
if (onChanged != null) {
_active = true;
_currentDragValue = (globalToLocal(globalPosition).x - _kReactionRadius) / _trackLength;
- onChanged(_currentDragValue.clamp(0.0, 1.0));
+ onChanged(_discretizedCurrentDragValue);
_reactionController.forward();
markNeedsPaint();
}
@@ -186,7 +294,7 @@
void _handleDragUpdate(double delta) {
if (onChanged != null) {
_currentDragValue += delta / _trackLength;
- onChanged(_currentDragValue.clamp(0.0, 1.0));
+ onChanged(_discretizedCurrentDragValue);
}
}
@@ -214,33 +322,74 @@
final double trackLength = _trackLength;
final bool enabled = onChanged != null;
+ final double value = _position.value;
- double trackCenter = offset.dy + size.height / 2.0;
- double trackLeft = offset.dx + _kReactionRadius;
- double trackTop = trackCenter - 1.0;
- double trackBottom = trackCenter + 1.0;
- double trackRight = trackLeft + trackLength;
- double trackActive = trackLeft + trackLength * value;
+ 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;
- Paint primaryPaint = new Paint()..color = enabled ? _activeColor : _kInactiveTrackColor;
- Paint trackPaint = new Paint()..color = _kTrackColorTween.evaluate(_reaction);
+ final Paint primaryPaint = new Paint()..color = enabled ? _activeColor : _kInactiveTrackColor;
+ final Paint trackPaint = new Paint()..color = _kTrackColorTween.evaluate(_reaction);
- double thumbRadius = enabled ? _kThumbRadiusTween.evaluate(_reaction) : _kDisabledThumbRadius;
- Point activeLocation = new Point(trackActive, trackCenter);
+ final Point thumbCenter = new Point(trackActive, trackCenter);
+ final double thumbRadius = enabled ? _kThumbRadiusTween.evaluate(_reaction) : _kDisabledThumbRadius;
if (enabled) {
- canvas.drawRect(new Rect.fromLTRB(trackLeft, trackTop, trackRight, trackBottom), trackPaint);
- if (_value > 0.0)
+ if (value > 0.0)
canvas.drawRect(new Rect.fromLTRB(trackLeft, trackTop, trackActive, trackBottom), primaryPaint);
+ if (value < 1.0)
+ canvas.drawRect(new Rect.fromLTRB(trackActive, trackTop, trackRight, trackBottom), trackPaint);
} else {
- canvas.drawRect(new Rect.fromLTRB(trackLeft, trackTop, activeLocation.x - _kDisabledThumbRadius - 2, trackBottom), trackPaint);
- canvas.drawRect(new Rect.fromLTRB(activeLocation.x + _kDisabledThumbRadius + 2, trackTop, trackRight, trackBottom), trackPaint);
+ 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);
}
if (_reaction.status != AnimationStatus.dismissed) {
- Paint reactionPaint = new Paint()..color = _activeColor.withAlpha(kRadialReactionAlpha);
- canvas.drawCircle(activeLocation, _kReactionRadiusTween.evaluate(_reaction), reactionPaint);
+ 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) {
+ double left = trackLeft + i * dx;
+ 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 {
+ Paint reactionPaint = new Paint()..color = _activeColor.withAlpha(kRadialReactionAlpha);
+ canvas.drawCircle(thumbCenter, _kReactionRadiusTween.evaluate(_reaction), reactionPaint);
+ }
}
- canvas.drawCircle(activeLocation, thumbRadius, primaryPaint);
+ canvas.drawCircle(thumbCenter, thumbRadius, primaryPaint);
}
}
diff --git a/packages/flutter/lib/src/material/time_picker.dart b/packages/flutter/lib/src/material/time_picker.dart
index ca81135..6f38645 100644
--- a/packages/flutter/lib/src/material/time_picker.dart
+++ b/packages/flutter/lib/src/material/time_picker.dart
@@ -259,16 +259,9 @@
List<TextPainter> painters = new List<TextPainter>(labels.length);
for (int i = 0; i < painters.length; ++i) {
String label = labels[i];
- TextPainter painter = new TextPainter(
+ painters[i] = new TextPainter(
new TextSpan(style: style, text: label)
- );
- painter
- ..maxWidth = double.INFINITY
- ..maxHeight = double.INFINITY
- ..layout()
- ..maxWidth = painter.maxIntrinsicWidth
- ..layout();
- painters[i] = painter;
+ )..layoutToMaxIntrinsicWidth();
}
return painters;
}
diff --git a/packages/flutter/lib/src/painting/text_painter.dart b/packages/flutter/lib/src/painting/text_painter.dart
index f22263d..83fde9e 100644
--- a/packages/flutter/lib/src/painting/text_painter.dart
+++ b/packages/flutter/lib/src/painting/text_painter.dart
@@ -243,7 +243,7 @@
}
ui.Paragraph _paragraph;
- bool _needsLayout = true;
+ bool _needsLayout = false;
TextSpan _text;
/// The (potentially styled) text to paint.
@@ -253,10 +253,15 @@
if (_text == value)
return;
_text = value;
- ui.ParagraphBuilder builder = new ui.ParagraphBuilder();
- _text.build(builder);
- _paragraph = builder.build(_text.style?.paragraphStyle ?? new ui.ParagraphStyle());
- _needsLayout = true;
+ if (_text != null) {
+ ui.ParagraphBuilder builder = new ui.ParagraphBuilder();
+ _text.build(builder);
+ _paragraph = builder.build(_text.style?.paragraphStyle ?? new ui.ParagraphStyle());
+ _needsLayout = true;
+ } else {
+ _paragraph = null;
+ _needsLayout = false;
+ }
}
/// The minimum width at which to layout the text.
@@ -343,12 +348,33 @@
}
}
+ bool _lastLayoutWasToMaxIntrinsicWidth = false;
+
/// Computes the visual position of the glyphs for painting the text.
void layout() {
if (!_needsLayout)
return;
_paragraph.layout();
_needsLayout = false;
+ _lastLayoutWasToMaxIntrinsicWidth = false;
+ }
+
+ /// Computes the visual position of the glyphs using the unconstrainted max intrinsic width.
+ void layoutToMaxIntrinsicWidth() {
+ if (!_needsLayout && _lastLayoutWasToMaxIntrinsicWidth && width == maxIntrinsicWidth)
+ return;
+ _needsLayout = false;
+ _lastLayoutWasToMaxIntrinsicWidth = true;
+ _paragraph
+ ..minWidth = 0.0
+ ..maxWidth = double.INFINITY
+ ..layout();
+ final double newMaxIntrinsicWidth = maxIntrinsicWidth;
+ _paragraph
+ ..minWidth = newMaxIntrinsicWidth
+ ..maxWidth = newMaxIntrinsicWidth
+ ..layout();
+ assert(width == maxIntrinsicWidth);
}
/// Paints the text onto the given canvas at the given offset.
diff --git a/packages/flutter/test/material/slider_test.dart b/packages/flutter/test/material/slider_test.dart
new file mode 100644
index 0000000..27abcc7
--- /dev/null
+++ b/packages/flutter/test/material/slider_test.dart
@@ -0,0 +1,90 @@
+// Copyright 2016 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 'package:flutter/material.dart';
+import 'package:flutter/scheduler.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:test/test.dart';
+
+void main() {
+ test('Slider can move when tapped', () {
+ testWidgets((WidgetTester tester) {
+ Key sliderKey = new UniqueKey();
+ double value = 0.0;
+
+ tester.pumpWidget(
+ new StatefulBuilder(
+ builder: (BuildContext context, StateSetter setState) {
+ return new Material(
+ child: new Center(
+ child: new Slider(
+ key: sliderKey,
+ value: value,
+ onChanged: (double newValue) {
+ setState(() {
+ value = newValue;
+ });
+ }
+ )
+ )
+ );
+ }
+ )
+ );
+
+ expect(value, equals(0.0));
+ tester.tap(tester.findElementByKey(sliderKey));
+ expect(value, equals(0.5));
+ tester.pump(); // No animation should start.
+ expect(Scheduler.instance.transientCallbackCount, equals(0));
+ });
+ });
+
+ test('Slider take on discrete values', () {
+ testWidgets((WidgetTester tester) {
+ Key sliderKey = new UniqueKey();
+ double value = 0.0;
+
+ tester.pumpWidget(
+ new StatefulBuilder(
+ builder: (BuildContext context, StateSetter setState) {
+ return new Material(
+ child: new Center(
+ child: new Slider(
+ key: sliderKey,
+ min: 0.0,
+ max: 100.0,
+ divisions: 10,
+ value: value,
+ onChanged: (double newValue) {
+ setState(() {
+ value = newValue;
+ });
+ }
+ )
+ )
+ );
+ }
+ )
+ );
+
+ expect(value, equals(0.0));
+ tester.tap(tester.findElementByKey(sliderKey));
+ expect(value, equals(50.0));
+ tester.scroll(tester.findElementByKey(sliderKey), const Offset(5.0, 0.0));
+ expect(value, equals(50.0));
+ tester.scroll(tester.findElementByKey(sliderKey), const Offset(40.0, 0.0));
+ expect(value, equals(80.0));
+
+ tester.pump(); // Starts animation.
+ expect(Scheduler.instance.transientCallbackCount, greaterThan(0));
+ tester.pump(const Duration(milliseconds: 200));
+ tester.pump(const Duration(milliseconds: 200));
+ tester.pump(const Duration(milliseconds: 200));
+ tester.pump(const Duration(milliseconds: 200));
+ // Animation complete.
+ expect(Scheduler.instance.transientCallbackCount, equals(0));
+ });
+ });
+}