blob: 9fc90d1f35f6f18c2f544e635ac340eec94d2cd4 [file] [log] [blame]
// 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.
import 'dart:math' as math;
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
import 'package:meta/meta.dart';
/// A material design slider.
/// Adapted from Flutter's Slider class. Adds the ability to set an image
/// for the slider control.
///
/// TODO(mikejurka): Merge these changes into Flutter's slider class
/// This code is almost identical to that class except for the paint method,
/// and the "thumbImage" parameter.
///
/// 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
/// 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:
///
/// * [Radio]
/// * [Switch]
/// * <https://www.google.com/design/spec/components/sliders.html>
///
class IconSlider extends StatefulWidget {
/// Creates a material design slider with an icon for the picker.
///
/// 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.
const IconSlider({
@required this.value,
@required this.onChanged,
Key key,
this.min = 0.0,
this.max = 1.0,
this.divisions,
this.label,
this.activeColor,
this.thumbImage,
}) : assert(value != null),
assert(min != null),
assert(max != null),
assert(value >= min && value <= max),
assert(divisions == null || divisions > 0),
super(key: key);
/// The currently selected value for this slider.
///
/// The slider's thumb is drawn at a position that corresponds to this value.
final double value;
/// 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.
final ValueChanged<double> onChanged;
/// The minium value the user can select.
///
/// Defaults to 0.0.
final double min;
/// The maximum value the user can select.
///
/// 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].
final Color activeColor;
/// Draw this image inside the thumb (i.e. the draggable circle)
///
/// If null, do not draw any image.
final ImageProvider thumbImage;
@override
_IconSliderState createState() => new _IconSliderState();
}
class _IconSliderState extends State<IconSlider> with TickerProviderStateMixin {
void _handleChanged(double value) {
assert(widget.onChanged != null);
widget.onChanged(value * (widget.max - widget.min) + widget.min);
}
@override
Widget build(BuildContext context) {
ThemeData theme = Theme.of(context);
return new _IconSliderRenderObjectWidget(
value: (widget.value - widget.min) / (widget.max - widget.min),
divisions: widget.divisions,
label: widget.label,
activeColor: widget.activeColor ?? theme.accentColor,
textTheme: theme.primaryTextTheme,
thumbImage: widget.thumbImage,
configuration: createLocalImageConfiguration(context),
onChanged: widget.onChanged != null ? _handleChanged : null,
vsync: this);
}
}
class _IconSliderRenderObjectWidget extends LeafRenderObjectWidget {
const _IconSliderRenderObjectWidget(
{Key key,
this.value,
this.divisions,
this.label,
this.activeColor,
this.textTheme,
this.thumbImage,
this.configuration,
this.onChanged,
this.vsync})
: super(key: key);
final double value;
final int divisions;
final String label;
final Color activeColor;
final TextTheme textTheme;
final ImageProvider thumbImage;
final ImageConfiguration configuration;
final ValueChanged<double> onChanged;
final TickerProvider vsync;
@override
_RenderIconSlider createRenderObject(BuildContext context) =>
new _RenderIconSlider(
value: value,
divisions: divisions,
label: label,
activeColor: activeColor,
textTheme: textTheme,
thumbImage: thumbImage,
configuration: configuration,
onChanged: onChanged,
vsync: vsync,
);
@override
void updateRenderObject(
BuildContext context, _RenderIconSlider renderObject) {
renderObject
..value = value
..divisions = divisions
..label = label
..activeColor = activeColor
..textTheme = textTheme
..thumbImage = thumbImage
..configuration = configuration
..onChanged = onChanged;
// Ticker provider cannot change since there's a 1:1 relationship between
// the _SliderRenderObjectWidget object and the _SliderState object.
}
}
const double _kThumbRadius = 6.0;
const double _kActiveThumbRadius = 9.0;
const double _kDisabledThumbRadius = 4.0;
const double _kReactionRadius = 16.0;
const double _kTrackWidth = 144.0;
final Color _kInactiveTrackColor = Colors.grey[400];
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: Colors.transparent, end: Colors.black54);
const 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);
const double _kAdjustmentUnit =
0.1; // Matches iOS implementation of material slider.
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 _RenderIconSlider extends RenderConstrainedBox {
_RenderIconSlider({
@required double value,
int divisions,
String label,
Color activeColor,
TextTheme textTheme,
ImageProvider thumbImage,
ImageConfiguration configuration,
this.onChanged,
TickerProvider vsync,
}) : _value = value,
_divisions = divisions,
_activeColor = activeColor,
_textTheme = textTheme,
_thumbImage = thumbImage,
_configuration = configuration,
assert(value != null && value >= 0.0 && value <= 1.0),
super(additionalConstraints: _getAdditionalConstraints(label)) {
this.label = label;
_drag = new HorizontalDragGestureRecognizer()
..onStart = _handleDragStart
..onUpdate = _handleDragUpdate
..onEnd = _handleDragEnd;
_reactionController = new AnimationController(
duration: kRadialReactionDuration,
vsync: vsync,
);
_reaction = new CurvedAnimation(
parent: _reactionController, curve: Curves.fastOutSlowIn)
..addListener(markNeedsPaint);
_position = new AnimationController(
value: value, duration: _kDiscreteTransitionDuration, vsync: vsync)
..addListener(markNeedsPaint);
}
double get value => _value;
double _value;
set value(double newValue) {
assert(newValue != null && newValue >= 0.0 && newValue <= 1.0);
if (newValue == _value) {
return;
}
_value = newValue;
if (divisions != null)
_position.animateTo(newValue, curve: Curves.fastOutSlowIn);
else
_position.value = newValue;
}
int get divisions => _divisions;
int _divisions;
set divisions(int newDivisions) {
if (newDivisions == _divisions) {
return;
}
_divisions = newDivisions;
markNeedsPaint();
}
String get label => _label;
String _label;
set label(String newLabel) {
if (newLabel == _label) {
return;
}
_label = newLabel;
additionalConstraints = _getAdditionalConstraints(_label);
if (newLabel != null) {
_labelPainter
..text = new TextSpan(
style: _textTheme.body1.copyWith(fontSize: 10.0),
text: newLabel,
)
..layout();
} else {
_labelPainter.text = null;
}
markNeedsPaint();
}
TextTheme get textTheme => _textTheme;
TextTheme _textTheme;
set textTheme(TextTheme value) {
if (value == _textTheme) {
return;
}
_textTheme = value;
markNeedsPaint();
}
Color get activeColor => _activeColor;
Color _activeColor;
set activeColor(Color value) {
if (value == _activeColor) {
return;
}
_activeColor = value;
markNeedsPaint();
}
ImageProvider get thumbImage => _thumbImage;
ImageProvider _thumbImage;
set thumbImage(ImageProvider value) {
if (value == _thumbImage) {
return;
}
_thumbImage = value;
markNeedsPaint();
}
ImageConfiguration get configuration => _configuration;
ImageConfiguration _configuration;
set configuration(ImageConfiguration value) {
assert(value != null);
if (value == _configuration) {
return;
}
_configuration = value;
markNeedsPaint();
}
@override
void detach() {
_cachedThumbPainter?.dispose();
_cachedThumbPainter = null;
super.detach();
}
ValueChanged<double> onChanged;
double get _trackLength => size.width - 2.0 * _kReactionRadius;
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;
}
bool get isInteractive => onChanged != null;
void _handleDragStart(DragStartDetails details) {
if (isInteractive) {
_active = true;
_currentDragValue =
(globalToLocal(details.globalPosition).dx - _kReactionRadius) /
_trackLength;
onChanged(_discretizedCurrentDragValue);
_reactionController.forward();
markNeedsPaint();
}
}
void _handleDragUpdate(DragUpdateDetails details) {
if (isInteractive) {
_currentDragValue += details.primaryDelta / _trackLength;
onChanged(_discretizedCurrentDragValue);
}
}
void _handleDragEnd(DragEndDetails details) {
if (_active) {
_active = false;
_currentDragValue = 0.0;
_reactionController.reverse();
markNeedsPaint();
}
}
@override
bool hitTestSelf(Offset position) => true;
@override
void handleEvent(PointerEvent event, BoxHitTestEntry entry) {
assert(debugHandleEvent(event, entry));
if (event is PointerDownEvent && isInteractive) {
_drag.addPointer(event);
}
}
ImageProvider _cachedThumbImage;
BoxPainter _cachedThumbPainter;
BoxDecoration _createDefaultThumbDecoration(ImageProvider image) {
return new BoxDecoration(
image: image == null ? null : new DecorationImage(image: image),
shape: BoxShape.circle,
boxShadow: null);
}
bool _isPaintingThumb = false;
void _handleDecorationChanged() {
// If the image decoration is available synchronously, we'll get called here
// during paint. There's no reason to mark ourselves as needing paint if we
// are already in the middle of painting. (In fact, doing so would trigger
// an assert).
if (!_isPaintingThumb) {
markNeedsPaint();
}
}
@override
void paint(PaintingContext context, Offset offset) {
final Canvas canvas = context.canvas;
final double trackLength = _trackLength;
final bool enabled = isInteractive;
final double value = _position.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;
final Paint primaryPaint = new Paint()
..color = enabled ? _activeColor : _kInactiveTrackColor;
final Paint trackPaint = new Paint()
..color = _kTrackColorTween.evaluate(_reaction);
final Offset thumbCenter = new Offset(trackActive, trackCenter);
double thumbRadius = enabled
? _kThumbRadiusTween.evaluate(_reaction)
: _kDisabledThumbRadius;
double thumbStrokeWidth = 0.0; // if 0, we fill the thumb circle
if (thumbImage != null) {
// Don't fill the circle if you're drawing an image inside
thumbStrokeWidth = 2.0;
// Create a bigger circle if you have an image
thumbRadius += 8.0;
}
if (value == 0) {
// Don't fill the circle if you're drawing an image
thumbStrokeWidth = 2.0;
// Make the circle a bit smaller when value == 0.
thumbRadius -= 1.0;
}
if (enabled) {
final bool hasBalloon =
_reaction.status != AnimationStatus.dismissed && label != null;
final double trackActiveDelta = hasBalloon ? 0.0 : thumbRadius - 1.0;
if (value > 0.0)
canvas.drawRect(
new Rect.fromLTRB(trackLeft, trackTop,
trackActive - trackActiveDelta, trackBottom),
primaryPaint);
if (value < 1.0)
canvas.drawRect(
new Rect.fromLTRB(trackActive + trackActiveDelta, trackTop,
trackRight, trackBottom),
trackPaint);
} else {
if (value > 0.0)
canvas.drawRect(
new Rect.fromLTRB(trackLeft, trackTop,
trackActive - thumbRadius - 2, trackBottom),
trackPaint);
if (value < 1.0)
canvas.drawRect(
new Rect.fromLTRB(trackActive + thumbRadius + 2, trackTop,
trackRight, trackBottom),
trackPaint);
}
if (_reaction.status != AnimationStatus.dismissed) {
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) {
final double left = trackLeft + i * dx;
canvas.drawRect(
new Rect.fromLTRB(
left, trackTop, left + tickWidth, trackBottom),
tickPaint);
}
}
}
if (label != null) {
final Offset center = new Offset(trackActive,
_kLabelBalloonCenterTween.evaluate(_reaction) + trackCenter);
final double radius = _kLabelBalloonRadiusTween.evaluate(_reaction);
final Offset tip = new Offset(trackActive,
_kLabelBalloonTipTween.evaluate(_reaction) + trackCenter);
final double tipAttachment = _kLabelBalloonTipAttachmentRatio * radius;
Path path = new Path()
..moveTo(tip.dx, tip.dy)
..lineTo(center.dx - tipAttachment, center.dy + tipAttachment)
..lineTo(center.dx + tipAttachment, center.dy + tipAttachment)
..close();
canvas.drawPath(path, primaryPaint);
_labelPainter.layout();
Offset labelOffset = new Offset(center.dx - _labelPainter.width / 2.0,
center.dy - _labelPainter.height / 2.0);
_labelPainter.paint(canvas, labelOffset);
return;
} else {
// Don't draw a reaction circle if you have an icon
if (thumbImage == null) {
final Color reactionBaseColor =
value == 0.0 ? _kActiveTrackColor : _activeColor;
final Paint reactionPaint = new Paint()
..color = reactionBaseColor.withAlpha(kRadialReactionAlpha);
canvas.drawCircle(thumbCenter,
_kReactionRadiusTween.evaluate(_reaction), reactionPaint);
}
}
}
// Use the neutral color to draw thumb circle
Paint thumbPaint = trackPaint;
if (thumbStrokeWidth != 0.0) {
// Set the style to stroke if we've set a non-zero stroke width
// This destructive to trackingPaint or primaryPaint
thumbPaint
..style = PaintingStyle.stroke
..strokeWidth = thumbStrokeWidth;
}
canvas.drawCircle(thumbCenter, thumbRadius, thumbPaint);
if (thumbImage != null) {
try {
_isPaintingThumb = true;
if (_cachedThumbPainter == null || thumbImage != _cachedThumbImage) {
_cachedThumbImage = thumbImage;
_cachedThumbPainter = _createDefaultThumbDecoration(thumbImage)
.createBoxPainter(_handleDecorationChanged);
}
BoxPainter thumbPainter = _cachedThumbPainter;
double imageRadius = thumbRadius - thumbStrokeWidth;
thumbPainter.paint(
canvas,
thumbCenter - new Offset(imageRadius, imageRadius), // + offset
configuration.copyWith(size: new Size.fromRadius(imageRadius)));
} finally {
_isPaintingThumb = false;
}
}
}
@override
void describeSemanticsConfiguration(SemanticsConfiguration config) {
super.describeSemanticsConfiguration(config);
config.isSemanticBoundary = isInteractive;
if (isInteractive) {
config
..onIncrease = _increaseAction
..onDecrease = _decreaseAction;
}
}
double get _semanticActionUnit =>
divisions != null ? 1.0 / divisions : _kAdjustmentUnit;
void _increaseAction() {
if (isInteractive) {
onChanged((value + _semanticActionUnit).clamp(0.0, 1.0));
}
}
void _decreaseAction() {
if (isInteractive) {
onChanged((value - _semanticActionUnit).clamp(0.0, 1.0));
}
}
}