| // 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/foundation.dart'; |
| import 'package:flutter/gestures.dart'; |
| import 'package:flutter/rendering.dart'; |
| import 'package:flutter/services.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. |
| /// |
| /// {@tool dartpad} |
| /// This example shows how to show the current slider value as it changes. |
| /// |
| /// ** See code in examples/api/lib/cupertino/slider/cupertino_slider.0.dart ** |
| /// {@end-tool} |
| /// |
| /// 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({ |
| super.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 >= 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; |
| |
| /// 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 |
| State<CupertinoSlider> 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({ |
| required this.value, |
| this.divisions, |
| required this.activeColor, |
| required this.thumbColor, |
| this.onChanged, |
| this.onChangeStart, |
| this.onChangeEnd, |
| required this.vsync, |
| }); |
| |
| 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), |
| cursor: kIsWeb ? SystemMouseCursors.click : MouseCursor.defer, |
| ); |
| } |
| |
| @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 implements MouseTrackerAnnotation { |
| _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, |
| MouseCursor cursor = MouseCursor.defer, |
| }) : assert(value >= 0.0 && value <= 1.0), |
| _cursor = cursor, |
| _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 >= 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) { |
| if (_textDirection == value) { |
| return; |
| } |
| _textDirection = value; |
| markNeedsPaint(); |
| } |
| |
| late AnimationController _position; |
| |
| late HorizontalDragGestureRecognizer _drag; |
| double _currentDragValue = 0.0; |
| |
| double get _discretizedCurrentDragValue { |
| double dragValue = clampDouble(_currentDragValue, 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; |
| case TextDirection.ltr: |
| visualPosition = _value; |
| } |
| 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; |
| case TextDirection.ltr: |
| _currentDragValue += valueDelta; |
| } |
| onChanged!(_discretizedCurrentDragValue); |
| } |
| } |
| |
| void _handleDragEnd(DragEndDetails details) => _endInteraction(); |
| |
| void _startInteraction(Offset globalPosition) { |
| if (isInteractive) { |
| onChangeStart?.call(_discretizedCurrentDragValue); |
| _currentDragValue = _value; |
| onChanged!(_discretizedCurrentDragValue); |
| } |
| } |
| |
| void _endInteraction() { |
| onChangeEnd?.call(_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; |
| case TextDirection.ltr: |
| visualPosition = _position.value; |
| leftColor = trackColor; |
| rightColor = _activeColor; |
| } |
| |
| 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 = '${(clampDouble(value + _semanticActionUnit, 0.0, 1.0) * 100).round()}%'; |
| config.decreasedValue = '${(clampDouble(value - _semanticActionUnit, 0.0, 1.0) * 100).round()}%'; |
| } |
| } |
| |
| double get _semanticActionUnit => divisions != null ? 1.0 / divisions! : _kAdjustmentUnit; |
| |
| void _increaseAction() { |
| if (isInteractive) { |
| onChanged!(clampDouble(value + _semanticActionUnit, 0.0, 1.0)); |
| } |
| } |
| |
| void _decreaseAction() { |
| if (isInteractive) { |
| onChanged!(clampDouble(value - _semanticActionUnit, 0.0, 1.0)); |
| } |
| } |
| |
| @override |
| MouseCursor get cursor => _cursor; |
| MouseCursor _cursor; |
| set cursor(MouseCursor value) { |
| if (_cursor != value) { |
| _cursor = value; |
| // A repaint is needed in order to trigger a device update of |
| // [MouseTracker] so that this new value can be found. |
| markNeedsPaint(); |
| } |
| } |
| |
| @override |
| PointerEnterEventListener? onEnter; |
| |
| PointerHoverEventListener? onHover; |
| |
| @override |
| PointerExitEventListener? onExit; |
| |
| @override |
| bool get validForMouseTracker => false; |
| } |