blob: e1327a9b9fc8e098dafcb3d717b5c1c85829a3c5 [file] [log] [blame]
// Copyright 2014 The Flutter 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 'dart:ui' show lerpDouble;
import 'package:flutter/gestures.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
import 'colors.dart';
import 'theme.dart';
import 'thumb_painter.dart';
// Examples can assume:
// int _cupertinoSliderValue = 1;
// void setState(VoidCallback fn) { }
/// An iOS-style 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.
///
/// See also:
///
/// * <https://developer.apple.com/ios/human-interface-guidelines/controls/sliders/>
class CupertinoSlider extends StatefulWidget {
/// Creates an iOS-style 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.
/// * [onChangeStart] is called when the user starts to select a new value for
/// the slider.
/// * [onChangeEnd] is called when the user is done selecting a new value for
/// the slider.
const CupertinoSlider({
Key key,
@required this.value,
@required this.onChanged,
this.onChangeStart,
this.onChangeEnd,
this.min = 0.0,
this.max = 1.0,
this.divisions,
this.activeColor,
this.thumbColor = CupertinoColors.white,
}) : assert(value != null),
assert(min != null),
assert(max != null),
assert(value >= min && value <= max),
assert(divisions == null || divisions > 0),
assert(thumbColor != null),
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.
///
/// The callback provided to onChanged should update the state of the parent
/// [StatefulWidget] using the [State.setState] method, so that the parent
/// gets rebuilt; for example:
///
/// ```dart
/// CupertinoSlider(
/// value: _cupertinoSliderValue.toDouble(),
/// min: 1.0,
/// max: 10.0,
/// divisions: 10,
/// onChanged: (double newValue) {
/// setState(() {
/// _cupertinoSliderValue = newValue.round();
/// });
/// },
/// )
/// ```
///
/// See also:
///
/// * [onChangeStart] for a callback that is called when the user starts
/// changing the value.
/// * [onChangeEnd] for a callback that is called when the user stops
/// changing the value.
final ValueChanged<double> onChanged;
/// Called when the user starts selecting a new value for the slider.
///
/// This callback shouldn't be used to update the slider [value] (use
/// [onChanged] for that), but rather to be notified when the user has started
/// selecting a new value by starting a drag.
///
/// The value passed will be the last [value] that the slider had before the
/// change began.
///
/// {@tool snippet}
///
/// ```dart
/// CupertinoSlider(
/// value: _cupertinoSliderValue.toDouble(),
/// min: 1.0,
/// max: 10.0,
/// divisions: 10,
/// onChanged: (double newValue) {
/// setState(() {
/// _cupertinoSliderValue = newValue.round();
/// });
/// },
/// onChangeStart: (double startValue) {
/// print('Started change at $startValue');
/// },
/// )
/// ```
/// {@end-tool}
///
/// See also:
///
/// * [onChangeEnd] for a callback that is called when the value change is
/// complete.
final ValueChanged<double> onChangeStart;
/// Called when the user is done selecting a new value for the slider.
///
/// This callback shouldn't be used to update the slider [value] (use
/// [onChanged] for that), but rather to know when the user has completed
/// selecting a new [value] by ending a drag.
///
/// {@tool snippet}
///
/// ```dart
/// CupertinoSlider(
/// value: _cupertinoSliderValue.toDouble(),
/// min: 1.0,
/// max: 10.0,
/// divisions: 10,
/// onChanged: (double newValue) {
/// setState(() {
/// _cupertinoSliderValue = newValue.round();
/// });
/// },
/// onChangeEnd: (double newValue) {
/// print('Ended change on $newValue');
/// },
/// )
/// ```
/// {@end-tool}
///
/// See also:
///
/// * [onChangeStart] for a callback that is called when a value change
/// begins.
final ValueChanged<double> onChangeEnd;
/// The minimum 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.
///
/// If null, the slider is continuous.
final int divisions;
/// The color to use for the portion of the slider that has been selected.
///
/// Defaults to the [CupertinoTheme]'s primary color if null.
final Color activeColor;
/// The color to use for the thumb of the slider.
///
/// Thumb color must not be null.
///
/// Defaults to [CupertinoColors.white].
final Color thumbColor;
@override
_CupertinoSliderState createState() => _CupertinoSliderState();
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DoubleProperty('value', value));
properties.add(DoubleProperty('min', min));
properties.add(DoubleProperty('max', max));
}
}
class _CupertinoSliderState extends State<CupertinoSlider> with TickerProviderStateMixin {
void _handleChanged(double value) {
assert(widget.onChanged != null);
final double lerpValue = lerpDouble(widget.min, widget.max, value);
if (lerpValue != widget.value) {
widget.onChanged(lerpValue);
}
}
void _handleDragStart(double value) {
assert(widget.onChangeStart != null);
widget.onChangeStart(lerpDouble(widget.min, widget.max, value));
}
void _handleDragEnd(double value) {
assert(widget.onChangeEnd != null);
widget.onChangeEnd(lerpDouble(widget.min, widget.max, value));
}
@override
Widget build(BuildContext context) {
return _CupertinoSliderRenderObjectWidget(
value: (widget.value - widget.min) / (widget.max - widget.min),
divisions: widget.divisions,
activeColor: CupertinoDynamicColor.resolve(
widget.activeColor ?? CupertinoTheme.of(context).primaryColor,
context,
),
thumbColor: widget.thumbColor,
onChanged: widget.onChanged != null ? _handleChanged : null,
onChangeStart: widget.onChangeStart != null ? _handleDragStart : null,
onChangeEnd: widget.onChangeEnd != null ? _handleDragEnd : null,
vsync: this,
);
}
}
class _CupertinoSliderRenderObjectWidget extends LeafRenderObjectWidget {
const _CupertinoSliderRenderObjectWidget({
Key key,
this.value,
this.divisions,
this.activeColor,
this.thumbColor,
this.onChanged,
this.onChangeStart,
this.onChangeEnd,
this.vsync,
}) : super(key: key);
final double value;
final int divisions;
final Color activeColor;
final Color thumbColor;
final ValueChanged<double> onChanged;
final ValueChanged<double> onChangeStart;
final ValueChanged<double> onChangeEnd;
final TickerProvider vsync;
@override
_RenderCupertinoSlider createRenderObject(BuildContext context) {
return _RenderCupertinoSlider(
value: value,
divisions: divisions,
activeColor: activeColor,
thumbColor: CupertinoDynamicColor.resolve(thumbColor, context),
trackColor: CupertinoDynamicColor.resolve(CupertinoColors.systemFill, context),
onChanged: onChanged,
onChangeStart: onChangeStart,
onChangeEnd: onChangeEnd,
vsync: vsync,
textDirection: Directionality.of(context),
);
}
@override
void updateRenderObject(BuildContext context, _RenderCupertinoSlider renderObject) {
renderObject
..value = value
..divisions = divisions
..activeColor = activeColor
..thumbColor = CupertinoDynamicColor.resolve(thumbColor, context)
..trackColor = CupertinoDynamicColor.resolve(CupertinoColors.systemFill, context)
..onChanged = onChanged
..onChangeStart = onChangeStart
..onChangeEnd = onChangeEnd
..textDirection = Directionality.of(context);
// Ticker provider cannot change since there's a 1:1 relationship between
// the _SliderRenderObjectWidget object and the _SliderState object.
}
}
const double _kPadding = 8.0;
const double _kSliderHeight = 2.0 * (CupertinoThumbPainter.radius + _kPadding);
const double _kSliderWidth = 176.0; // Matches Material Design slider.
const Duration _kDiscreteTransitionDuration = Duration(milliseconds: 500);
const double _kAdjustmentUnit = 0.1; // Matches iOS implementation of material slider.
class _RenderCupertinoSlider extends RenderConstrainedBox {
_RenderCupertinoSlider({
@required double value,
int divisions,
Color activeColor,
Color thumbColor,
Color trackColor,
ValueChanged<double> onChanged,
this.onChangeStart,
this.onChangeEnd,
TickerProvider vsync,
@required TextDirection textDirection,
}) : assert(value != null && value >= 0.0 && value <= 1.0),
assert(textDirection != null),
_value = value,
_divisions = divisions,
_activeColor = activeColor,
_thumbColor = thumbColor,
_trackColor = trackColor,
_onChanged = onChanged,
_textDirection = textDirection,
super(additionalConstraints: const BoxConstraints.tightFor(width: _kSliderWidth, height: _kSliderHeight)) {
_drag = HorizontalDragGestureRecognizer()
..onStart = _handleDragStart
..onUpdate = _handleDragUpdate
..onEnd = _handleDragEnd;
_position = 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;
markNeedsSemanticsUpdate();
}
int get divisions => _divisions;
int _divisions;
set divisions(int value) {
if (value == _divisions)
return;
_divisions = value;
markNeedsPaint();
}
Color get activeColor => _activeColor;
Color _activeColor;
set activeColor(Color value) {
if (value == _activeColor)
return;
_activeColor = value;
markNeedsPaint();
}
Color get thumbColor => _thumbColor;
Color _thumbColor;
set thumbColor(Color value) {
if (value == _thumbColor)
return;
_thumbColor = value;
markNeedsPaint();
}
Color get trackColor => _trackColor;
Color _trackColor;
set trackColor(Color value) {
if (value == _trackColor)
return;
_trackColor = value;
markNeedsPaint();
}
ValueChanged<double> get onChanged => _onChanged;
ValueChanged<double> _onChanged;
set onChanged(ValueChanged<double> value) {
if (value == _onChanged)
return;
final bool wasInteractive = isInteractive;
_onChanged = value;
if (wasInteractive != isInteractive)
markNeedsSemanticsUpdate();
}
ValueChanged<double> onChangeStart;
ValueChanged<double> onChangeEnd;
TextDirection get textDirection => _textDirection;
TextDirection _textDirection;
set textDirection(TextDirection value) {
assert(value != null);
if (_textDirection == value)
return;
_textDirection = value;
markNeedsPaint();
}
AnimationController _position;
HorizontalDragGestureRecognizer _drag;
double _currentDragValue = 0.0;
double get _discretizedCurrentDragValue {
double dragValue = _currentDragValue.clamp(0.0, 1.0) as double;
if (divisions != null)
dragValue = (dragValue * divisions).round() / divisions;
return dragValue;
}
double get _trackLeft => _kPadding;
double get _trackRight => size.width - _kPadding;
double get _thumbCenter {
double visualPosition;
switch (textDirection) {
case TextDirection.rtl:
visualPosition = 1.0 - _value;
break;
case TextDirection.ltr:
visualPosition = _value;
break;
}
return lerpDouble(_trackLeft + CupertinoThumbPainter.radius, _trackRight - CupertinoThumbPainter.radius, visualPosition);
}
bool get isInteractive => onChanged != null;
void _handleDragStart(DragStartDetails details) => _startInteraction(details.globalPosition);
void _handleDragUpdate(DragUpdateDetails details) {
if (isInteractive) {
final double extent = math.max(_kPadding, size.width - 2.0 * (_kPadding + CupertinoThumbPainter.radius));
final double valueDelta = details.primaryDelta / extent;
switch (textDirection) {
case TextDirection.rtl:
_currentDragValue -= valueDelta;
break;
case TextDirection.ltr:
_currentDragValue += valueDelta;
break;
}
onChanged(_discretizedCurrentDragValue);
}
}
void _handleDragEnd(DragEndDetails details) => _endInteraction();
void _startInteraction(Offset globalPosition) {
if (isInteractive) {
if (onChangeStart != null) {
onChangeStart(_discretizedCurrentDragValue);
}
_currentDragValue = _value;
onChanged(_discretizedCurrentDragValue);
}
}
void _endInteraction() {
if (onChangeEnd != null) {
onChangeEnd(_discretizedCurrentDragValue);
}
_currentDragValue = 0.0;
}
@override
bool hitTestSelf(Offset position) {
return (position.dx - _thumbCenter).abs() < CupertinoThumbPainter.radius + _kPadding;
}
@override
void handleEvent(PointerEvent event, BoxHitTestEntry entry) {
assert(debugHandleEvent(event, entry));
if (event is PointerDownEvent && isInteractive)
_drag.addPointer(event);
}
@override
void paint(PaintingContext context, Offset offset) {
double visualPosition;
Color leftColor;
Color rightColor;
switch (textDirection) {
case TextDirection.rtl:
visualPosition = 1.0 - _position.value;
leftColor = _activeColor;
rightColor = trackColor;
break;
case TextDirection.ltr:
visualPosition = _position.value;
leftColor = trackColor;
rightColor = _activeColor;
break;
}
final double trackCenter = offset.dy + size.height / 2.0;
final double trackLeft = offset.dx + _trackLeft;
final double trackTop = trackCenter - 1.0;
final double trackBottom = trackCenter + 1.0;
final double trackRight = offset.dx + _trackRight;
final double trackActive = offset.dx + _thumbCenter;
final Canvas canvas = context.canvas;
if (visualPosition > 0.0) {
final Paint paint = Paint()..color = rightColor;
canvas.drawRRect(RRect.fromLTRBXY(trackLeft, trackTop, trackActive, trackBottom, 1.0, 1.0), paint);
}
if (visualPosition < 1.0) {
final Paint paint = Paint()..color = leftColor;
canvas.drawRRect(RRect.fromLTRBXY(trackActive, trackTop, trackRight, trackBottom, 1.0, 1.0), paint);
}
final Offset thumbCenter = Offset(trackActive, trackCenter);
CupertinoThumbPainter(color: thumbColor).paint(canvas, Rect.fromCircle(center: thumbCenter, radius: CupertinoThumbPainter.radius));
}
@override
void describeSemanticsConfiguration(SemanticsConfiguration config) {
super.describeSemanticsConfiguration(config);
config.isSemanticBoundary = isInteractive;
if (isInteractive) {
config.textDirection = textDirection;
config.onIncrease = _increaseAction;
config.onDecrease = _decreaseAction;
config.value = '${(value * 100).round()}%';
config.increasedValue = '${((value + _semanticActionUnit).clamp(0.0, 1.0) * 100).round()}%';
config.decreasedValue = '${((value - _semanticActionUnit).clamp(0.0, 1.0) * 100).round()}%';
}
}
double get _semanticActionUnit => divisions != null ? 1.0 / divisions : _kAdjustmentUnit;
void _increaseAction() {
if (isInteractive)
onChanged((value + _semanticActionUnit).clamp(0.0, 1.0) as double);
}
void _decreaseAction() {
if (isInteractive)
onChanged((value - _semanticActionUnit).clamp(0.0, 1.0) as double);
}
}