Adam Barth 2d4acb8041 Convert drag gestures to use details objects (#4343)
Previously we supplied individual parameters to the various drag and pan
callbacks. However, that approach isn't extensible because each new
parameter is a breaking change to the API.

This patch makes a one-time breaking change to the API to provide a
"details" object that we can extend over time as we need to expose more
information. The first planned extension is adding enough information to
accurately produce an overscroll glow on Android.
2016-06-02 23:45:49 -07:00

420 lines
14 KiB
Dart

// 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/rendering.dart';
import 'package:flutter/widgets.dart';
import 'colors.dart';
import 'constants.dart';
import 'debug.dart';
import 'theme.dart';
import 'typography.dart';
/// A material design slider.
///
/// 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:
///
/// * [CheckBox]
/// * [Radio]
/// * [Switch]
/// * <https://www.google.com/design/spec/components/sliders.html>
class Slider extends StatelessWidget {
/// 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.
Slider({
Key key,
this.value,
this.min: 0.0,
this.max: 1.0,
this.divisions,
this.label,
this.activeColor,
this.onChanged
}) : super(key: key) {
assert(value != null);
assert(min != null);
assert(max != null);
assert(value >= min && value <= max);
assert(divisions == null || divisions > 0);
}
/// The currently selected value for this slider.
///
/// The slider's thumb is drawn at a position that corresponds to this value.
final double value;
/// 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;
/// 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;
void _handleChanged(double value) {
assert(onChanged != null);
onChanged(value * (max - min) + min);
}
@override
Widget build(BuildContext context) {
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
);
}
}
class _SliderRenderObjectWidget extends LeafRenderObjectWidget {
_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
);
@override
void updateRenderObject(BuildContext context, _RenderSlider renderObject) {
renderObject
..value = value
..divisions = divisions
..label = label
..activeColor = activeColor
..onChanged = onChanged;
}
}
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);
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: _getAdditionalConstraints(label)) {
assert(value != null && value >= 0.0 && value <= 1.0);
this.label = label;
_drag = new HorizontalDragGestureRecognizer()
..onStart = _handleDragStart
..onUpdate = _handleDragUpdate
..onEnd = _handleDragEnd;
_reactionController = new AnimationController(duration: kRadialReactionDuration);
_reaction = new CurvedAnimation(
parent: _reactionController,
curve: Curves.ease
)..addListener(markNeedsPaint);
_position = new AnimationController(
value: value,
duration: _kDiscreteTransitionDuration
)..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.ease);
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: Typography.white.body1.copyWith(fontSize: 10.0),
text: newLabel
)
..layout();
} else {
_labelPainter.text = null;
}
markNeedsPaint();
}
Color get activeColor => _activeColor;
Color _activeColor;
set activeColor(Color value) {
if (value == _activeColor)
return;
_activeColor = value;
markNeedsPaint();
}
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;
}
void _handleDragStart(DragStartDetails details) {
if (onChanged != null) {
_active = true;
_currentDragValue = (globalToLocal(details.globalPosition).x - _kReactionRadius) / _trackLength;
onChanged(_discretizedCurrentDragValue);
_reactionController.forward();
markNeedsPaint();
}
}
void _handleDragUpdate(DragUpdateDetails details) {
if (onChanged != null) {
_currentDragValue += details.primaryDelta / _trackLength;
onChanged(_discretizedCurrentDragValue);
}
}
void _handleDragEnd(DragEndDetails details) {
if (_active) {
_active = false;
_currentDragValue = 0.0;
_reactionController.reverse();
markNeedsPaint();
}
}
@override
bool hitTestSelf(Point position) => true;
@override
void handleEvent(PointerEvent event, BoxHitTestEntry entry) {
if (event is PointerDownEvent && onChanged != null)
_drag.addPointer(event);
}
@override
void paint(PaintingContext context, Offset offset) {
final Canvas canvas = context.canvas;
final double trackLength = _trackLength;
final bool enabled = onChanged != null;
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 Point thumbCenter = new Point(trackActive, trackCenter);
final double thumbRadius = enabled ? _kThumbRadiusTween.evaluate(_reaction) : _kDisabledThumbRadius;
if (enabled) {
if (value > 0.0)
canvas.drawRect(new Rect.fromLTRB(trackLeft, trackTop, trackActive, trackBottom), primaryPaint);
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);
}
} else {
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) {
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 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 {
final Color reactionBaseColor = value == 0.0 ? _kActiveTrackColor : _activeColor;
final Paint reactionPaint = new Paint()..color = reactionBaseColor.withAlpha(kRadialReactionAlpha);
canvas.drawCircle(thumbCenter, _kReactionRadiusTween.evaluate(_reaction), reactionPaint);
}
}
Paint thumbPaint = primaryPaint;
double thumbRadiusDelta = 0.0;
if (value == 0.0) {
thumbPaint = trackPaint;
// This is destructive to trackPaint.
thumbPaint
..style = PaintingStyle.stroke
..strokeWidth = 2.0;
thumbRadiusDelta = -1.0;
}
canvas.drawCircle(thumbCenter, thumbRadius + thumbRadiusDelta, thumbPaint);
}
}