Commit 767ce826 authored by Adam Barth's avatar Adam Barth

Add support for discrete material sliders

Fixes #1541
parent daa0d2df
......@@ -11,53 +11,44 @@ class SliderDemo extends StatefulWidget {
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;
});
}
)
)
)
])
),
]
)
);
}
}
......@@ -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 'colors.dart';
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 @@ class Slider extends StatelessWidget {
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 @@ class Slider extends StatelessWidget {
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 @@ class Slider extends StatelessWidget {
/// 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 @@ class Slider extends StatelessWidget {
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 Slider extends StatelessWidget {
}
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 @@ class _SliderRenderObjectWidget extends LeafRenderObjectWidget {
void updateRenderObject(BuildContext context, _RenderSlider renderObject) {
renderObject
..value = value
..divisions = divisions
..label = label
..activeColor = activeColor
..onChanged = onChanged;
}
......@@ -122,16 +161,39 @@ final Color _kActiveTrackColor = Colors.grey[500];
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 @@ class _RenderSlider extends RenderConstrainedBox {
parent: _reactionController,
curve: Curves.ease
)..addListener(markNeedsPaint);
_position = new AnimationController(
value: value,
duration: _kDiscreteTransitionDuration
)..addListener(markNeedsPaint);
}
double get value => _value;
......@@ -150,6 +216,38 @@ class _RenderSlider extends RenderConstrainedBox {
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 @@ class _RenderSlider extends RenderConstrainedBox {
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 @@ class _RenderSlider extends RenderConstrainedBox {
void _handleDragUpdate(double delta) {
if (onChanged != null) {
_currentDragValue += delta / _trackLength;
onChanged(_currentDragValue.clamp(0.0, 1.0));
onChanged(_discretizedCurrentDragValue);
}
}
......@@ -214,33 +322,74 @@ class _RenderSlider extends RenderConstrainedBox {
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);
}
}
......@@ -259,16 +259,9 @@ List<TextPainter> _initPainters(List<String> labels) {
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;
}
......
......@@ -243,7 +243,7 @@ class TextPainter {
}
ui.Paragraph _paragraph;
bool _needsLayout = true;
bool _needsLayout = false;
TextSpan _text;
/// The (potentially styled) text to paint.
......@@ -253,10 +253,15 @@ class TextPainter {
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 @@ class TextPainter {
}
}
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.
......
// 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));
});
});
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment