blob: 697f1002ddb899296e3fe1b1dd0b19f561e388f0 [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.
///
/// {@youtube 560 315 https://www.youtube.com/watch?v=ufb4gIPDmEs}
///
/// 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,
required this.value,
this.divisions,
required this.activeColor,
required this.thumbColor,
this.onChanged,
this.onChangeStart,
this.onChangeEnd,
required 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) {
assert(debugCheckHasDirectionality(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) {
assert(debugCheckHasDirectionality(context));
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,
required Color activeColor,
required Color thumbColor,
required Color trackColor,
ValueChanged<double>? onChanged,
this.onChangeStart,
this.onChangeEnd,
required 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();
}
late AnimationController _position;
late HorizontalDragGestureRecognizer _drag;
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;
}
double get _trackLeft => _kPadding;
double get _trackRight => size.width - _kPadding;
double get _thumbCenter {
final 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) {
final double visualPosition;
final Color leftColor;
final 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;
config.isSlider = true;
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));
}
void _decreaseAction() {
if (isInteractive)
onChanged!((value - _semanticActionUnit).clamp(0.0, 1.0));
}
}