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));
+    });
+  });
+}