| // 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:async'; |
| import 'dart:math' as math; |
| |
| import 'package:flutter/cupertino.dart'; |
| import 'package:flutter/foundation.dart'; |
| import 'package:flutter/gestures.dart'; |
| import 'package:flutter/rendering.dart'; |
| import 'package:flutter/scheduler.dart' show timeDilation; |
| import 'package:flutter/services.dart'; |
| |
| import 'color_scheme.dart'; |
| import 'colors.dart'; |
| import 'constants.dart'; |
| import 'debug.dart'; |
| import 'material.dart'; |
| import 'material_state.dart'; |
| import 'slider_theme.dart'; |
| import 'theme.dart'; |
| |
| // Examples can assume: |
| // int _dollars = 0; |
| // int _duelCommandment = 1; |
| // void setState(VoidCallback fn) { } |
| |
| /// [Slider] uses this callback to paint the value indicator on the overlay. |
| /// |
| /// Since the value indicator is painted on the Overlay; this method paints the |
| /// value indicator in a [RenderBox] that appears in the [Overlay]. |
| typedef PaintValueIndicator = void Function(PaintingContext context, Offset offset); |
| |
| enum _SliderType { material, adaptive } |
| |
| /// A Material Design slider. |
| /// |
| /// Used to select from a range of values. |
| /// |
| /// {@youtube 560 315 https://www.youtube.com/watch?v=ufb4gIPDmEs} |
| /// |
| /// {@tool dartpad} |
| /// ![A legacy slider widget, consisting of 5 divisions and showing the default value |
| /// indicator.](https://flutter.github.io/assets-for-api-docs/assets/material/slider.png) |
| /// |
| /// The Sliders value is part of the Stateful widget subclass to change the value |
| /// setState was called. |
| /// |
| /// ** See code in examples/api/lib/material/slider/slider.0.dart ** |
| /// {@end-tool} |
| /// |
| /// {@tool dartpad} |
| /// This sample shows the creation of a [Slider] using [ThemeData.useMaterial3] flag, |
| /// as described in: https://m3.material.io/components/sliders/overview. |
| /// |
| /// ** See code in examples/api/lib/material/slider/slider.1.dart ** |
| /// {@end-tool} |
| /// |
| /// {@tool dartpad} |
| /// This example shows a [Slider] widget using the [Slider.secondaryTrackValue] |
| /// to show a secondary track in the slider. |
| /// |
| /// ** See code in examples/api/lib/material/slider/slider.2.dart ** |
| /// {@end-tool} |
| /// |
| /// A slider can be used to select from either a continuous or a discrete set of |
| /// values. The default is to 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 |
| /// discrete values 0.0, 10.0, 20.0, 30.0, 40.0, and 50.0. |
| /// |
| /// The terms for the parts of a slider are: |
| /// |
| /// * The "thumb", which is a shape that slides horizontally when the user |
| /// drags it. |
| /// * The "track", which is the line that the slider thumb slides along. |
| /// * The "value indicator", which is a shape that pops up when the user |
| /// is dragging the thumb to indicate the value being selected. |
| /// * The "active" side of the slider is the side between the thumb and the |
| /// minimum value. |
| /// * The "inactive" side of the slider is the side between the thumb and the |
| /// maximum value. |
| /// |
| /// The slider will be disabled if [onChanged] is null or if the range given by |
| /// [min]..[max] is empty (i.e. if [min] is equal to [max]). |
| /// |
| /// The slider widget 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. To know when the value starts to change, or when it is done |
| /// changing, set the optional callbacks [onChangeStart] and/or [onChangeEnd]. |
| /// |
| /// By default, a slider will be as wide as possible, centered vertically. When |
| /// given unbounded constraints, it will attempt to make the track 144 pixels |
| /// wide (with margins on each side) and will shrink-wrap vertically. |
| /// |
| /// Requires one of its ancestors to be a [Material] widget. |
| /// |
| /// Requires one of its ancestors to be a [MediaQuery] widget. Typically, these |
| /// are introduced by the [MaterialApp] or [WidgetsApp] widget at the top of |
| /// your application widget tree. |
| /// |
| /// To determine how it should be displayed (e.g. colors, thumb shape, etc.), |
| /// a slider uses the [SliderThemeData] available from either a [SliderTheme] |
| /// widget or the [ThemeData.sliderTheme] a [Theme] widget above it in the |
| /// widget tree. You can also override some of the colors with the [activeColor] |
| /// and [inactiveColor] properties, although more fine-grained control of the |
| /// look is achieved using a [SliderThemeData]. |
| /// |
| /// See also: |
| /// |
| /// * [SliderTheme] and [SliderThemeData] for information about controlling |
| /// the visual appearance of the slider. |
| /// * [Radio], for selecting among a set of explicit values. |
| /// * [Checkbox] and [Switch], for toggling a particular value on or off. |
| /// * <https://material.io/design/components/sliders.html> |
| /// * [MediaQuery], from which the text scale factor is obtained. |
| class Slider extends StatefulWidget { |
| /// 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 while the user is selecting 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. |
| /// |
| /// You can override some of the colors with the [activeColor] and |
| /// [inactiveColor] properties, although more fine-grained control of the |
| /// appearance is achieved using a [SliderThemeData]. |
| const Slider({ |
| super.key, |
| required this.value, |
| this.secondaryTrackValue, |
| required this.onChanged, |
| this.onChangeStart, |
| this.onChangeEnd, |
| this.min = 0.0, |
| this.max = 1.0, |
| this.divisions, |
| this.label, |
| this.activeColor, |
| this.inactiveColor, |
| this.secondaryActiveColor, |
| this.thumbColor, |
| this.overlayColor, |
| this.mouseCursor, |
| this.semanticFormatterCallback, |
| this.focusNode, |
| this.autofocus = false, |
| }) : _sliderType = _SliderType.material, |
| assert(min <= max), |
| assert(value >= min && value <= max, |
| 'Value $value is not between minimum $min and maximum $max'), |
| assert(secondaryTrackValue == null || (secondaryTrackValue >= min && secondaryTrackValue <= max), |
| 'SecondaryValue $secondaryTrackValue is not between $min and $max'), |
| assert(divisions == null || divisions > 0); |
| |
| /// Creates an adaptive [Slider] based on the target platform, following |
| /// Material design's |
| /// [Cross-platform guidelines](https://material.io/design/platform-guidance/cross-platform-adaptation.html). |
| /// |
| /// Creates a [CupertinoSlider] if the target platform is iOS or macOS, creates a |
| /// Material Design slider otherwise. |
| /// |
| /// If a [CupertinoSlider] is created, the following parameters are ignored: |
| /// [secondaryTrackValue], [label], [inactiveColor], [secondaryActiveColor], |
| /// [semanticFormatterCallback]. |
| /// |
| /// The target platform is based on the current [Theme]: [ThemeData.platform]. |
| const Slider.adaptive({ |
| super.key, |
| required this.value, |
| this.secondaryTrackValue, |
| required this.onChanged, |
| this.onChangeStart, |
| this.onChangeEnd, |
| this.min = 0.0, |
| this.max = 1.0, |
| this.divisions, |
| this.label, |
| this.mouseCursor, |
| this.activeColor, |
| this.inactiveColor, |
| this.secondaryActiveColor, |
| this.thumbColor, |
| this.overlayColor, |
| this.semanticFormatterCallback, |
| this.focusNode, |
| this.autofocus = false, |
| }) : _sliderType = _SliderType.adaptive, |
| assert(min <= max), |
| assert(value >= min && value <= max, |
| 'Value $value is not between minimum $min and maximum $max'), |
| assert(secondaryTrackValue == null || (secondaryTrackValue >= min && secondaryTrackValue <= max), |
| 'SecondaryValue $secondaryTrackValue is not between $min and $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 secondary track value for this slider. |
| /// |
| /// If not null, a secondary track using [Slider.secondaryActiveColor] color |
| /// is drawn between the thumb and this value, over the inactive track. |
| /// |
| /// If less than [Slider.value], then the secondary track is not shown. |
| /// |
| /// It can be ideal for media scenarios such as showing the buffering progress |
| /// while the [Slider.value] shows the play progress. |
| final double? secondaryTrackValue; |
| |
| /// Called during a drag when the user is selecting a new value for the slider |
| /// by dragging. |
| /// |
| /// 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: |
| /// |
| /// {@tool snippet} |
| /// |
| /// ```dart |
| /// Slider( |
| /// value: _duelCommandment.toDouble(), |
| /// min: 1.0, |
| /// max: 10.0, |
| /// divisions: 10, |
| /// label: '$_duelCommandment', |
| /// onChanged: (double newValue) { |
| /// setState(() { |
| /// _duelCommandment = newValue.round(); |
| /// }); |
| /// }, |
| /// ) |
| /// ``` |
| /// {@end-tool} |
| /// |
| /// 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 or with a tap. |
| /// |
| /// The value passed will be the last [value] that the slider had before the |
| /// change began. |
| /// |
| /// {@tool snippet} |
| /// |
| /// ```dart |
| /// Slider( |
| /// value: _duelCommandment.toDouble(), |
| /// min: 1.0, |
| /// max: 10.0, |
| /// divisions: 10, |
| /// label: '$_duelCommandment', |
| /// onChanged: (double newValue) { |
| /// setState(() { |
| /// _duelCommandment = 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 or a click. |
| /// |
| /// {@tool snippet} |
| /// |
| /// ```dart |
| /// Slider( |
| /// value: _duelCommandment.toDouble(), |
| /// min: 1.0, |
| /// max: 10.0, |
| /// divisions: 10, |
| /// label: '$_duelCommandment', |
| /// onChanged: (double newValue) { |
| /// setState(() { |
| /// _duelCommandment = 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. Must be less than or equal to [max]. |
| /// |
| /// If the [max] is equal to the [min], then the slider is disabled. |
| final double min; |
| |
| /// The maximum value the user can select. |
| /// |
| /// Defaults to 1.0. Must be greater than or equal to [min]. |
| /// |
| /// If the [max] is equal to the [min], then the slider is disabled. |
| 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 and |
| /// [SliderThemeData.showValueIndicator] is satisfied. |
| /// |
| /// It is used to display the value of a discrete slider, and it is displayed |
| /// as part of the value indicator shape. |
| /// |
| /// The label is rendered using the active [ThemeData]'s [TextTheme.bodyLarge] |
| /// text style, with the theme data's [ColorScheme.onPrimary] color. The |
| /// label's text style can be overridden with |
| /// [SliderThemeData.valueIndicatorTextStyle]. |
| /// |
| /// If null, then the value indicator will not be displayed. |
| /// |
| /// Ignored if this slider is created with [Slider.adaptive]. |
| /// |
| /// See also: |
| /// |
| /// * [SliderComponentShape] for how to create a custom value indicator |
| /// shape. |
| final String? label; |
| |
| /// The color to use for the portion of the slider track that is active. |
| /// |
| /// The "active" side of the slider is the side between the thumb and the |
| /// minimum value. |
| /// |
| /// If null, [SliderThemeData.activeTrackColor] of the ambient |
| /// [SliderTheme] is used. If that is null, [ColorScheme.primary] of the |
| /// surrounding [ThemeData] is used. |
| /// |
| /// Using a [SliderTheme] gives much more fine-grained control over the |
| /// appearance of various components of the slider. |
| final Color? activeColor; |
| |
| /// The color for the inactive portion of the slider track. |
| /// |
| /// The "inactive" side of the slider is the side between the thumb and the |
| /// maximum value. |
| /// |
| /// If null, [SliderThemeData.inactiveTrackColor] of the ambient [SliderTheme] |
| /// is used. If that is null and [ThemeData.useMaterial3] is true, |
| /// [ColorScheme.surfaceVariant] will be used, otherwise [ColorScheme.primary] |
| /// with an opacity of 0.24 will be used. |
| /// |
| /// Using a [SliderTheme] gives much more fine-grained control over the |
| /// appearance of various components of the slider. |
| /// |
| /// Ignored if this slider is created with [Slider.adaptive]. |
| final Color? inactiveColor; |
| |
| /// The color to use for the portion of the slider track between the thumb and |
| /// the [Slider.secondaryTrackValue]. |
| /// |
| /// Defaults to the [SliderThemeData.secondaryActiveTrackColor] of the current |
| /// [SliderTheme]. |
| /// |
| /// If that is also null, defaults to [ColorScheme.primary] with an |
| /// opacity of 0.54. |
| /// |
| /// Using a [SliderTheme] gives much more fine-grained control over the |
| /// appearance of various components of the slider. |
| /// |
| /// Ignored if this slider is created with [Slider.adaptive]. |
| final Color? secondaryActiveColor; |
| |
| /// The color of the thumb. |
| /// |
| /// If this color is null, [Slider] will use [activeColor], If [activeColor] |
| /// is also null, [Slider] will use [SliderThemeData.thumbColor]. |
| /// |
| /// If that is also null, defaults to [ColorScheme.primary]. |
| /// |
| /// * [CupertinoSlider] will have a white thumb |
| /// (like the native default iOS slider). |
| final Color? thumbColor; |
| |
| /// The highlight color that's typically used to indicate that |
| /// the slider thumb is focused, hovered, or dragged. |
| /// |
| /// If this property is null, [Slider] will use [activeColor] with |
| /// an opacity of 0.12, If null, [SliderThemeData.overlayColor] |
| /// will be used. |
| /// |
| /// If that is also null, If [ThemeData.useMaterial3] is true, |
| /// Slider will use [ColorScheme.primary] with an opacity of 0.08 when |
| /// slider thumb is hovered and with an opacity of 0.12 when slider thumb |
| /// is focused or dragged, If [ThemeData.useMaterial3] is false, defaults |
| /// to [ColorScheme.primary] with an opacity of 0.12. |
| final MaterialStateProperty<Color?>? overlayColor; |
| |
| /// {@template flutter.material.slider.mouseCursor} |
| /// The cursor for a mouse pointer when it enters or is hovering over the |
| /// widget. |
| /// |
| /// If [mouseCursor] is a [MaterialStateProperty<MouseCursor>], |
| /// [MaterialStateProperty.resolve] is used for the following [MaterialState]s: |
| /// |
| /// * [MaterialState.dragged]. |
| /// * [MaterialState.hovered]. |
| /// * [MaterialState.focused]. |
| /// * [MaterialState.disabled]. |
| /// {@endtemplate} |
| /// |
| /// If null, then the value of [SliderThemeData.mouseCursor] is used. If that |
| /// is also null, then [MaterialStateMouseCursor.clickable] is used. |
| /// |
| /// See also: |
| /// |
| /// * [MaterialStateMouseCursor], which can be used to create a [MouseCursor] |
| /// that is also a [MaterialStateProperty<MouseCursor>]. |
| final MouseCursor? mouseCursor; |
| |
| /// The callback used to create a semantic value from a slider value. |
| /// |
| /// Defaults to formatting values as a percentage. |
| /// |
| /// This is used by accessibility frameworks like TalkBack on Android to |
| /// inform users what the currently selected value is with more context. |
| /// |
| /// {@tool snippet} |
| /// |
| /// In the example below, a slider for currency values is configured to |
| /// announce a value with a currency label. |
| /// |
| /// ```dart |
| /// Slider( |
| /// value: _dollars.toDouble(), |
| /// min: 20.0, |
| /// max: 330.0, |
| /// label: '$_dollars dollars', |
| /// onChanged: (double newValue) { |
| /// setState(() { |
| /// _dollars = newValue.round(); |
| /// }); |
| /// }, |
| /// semanticFormatterCallback: (double newValue) { |
| /// return '${newValue.round()} dollars'; |
| /// } |
| /// ) |
| /// ``` |
| /// {@end-tool} |
| /// |
| /// Ignored if this slider is created with [Slider.adaptive] |
| final SemanticFormatterCallback? semanticFormatterCallback; |
| |
| /// {@macro flutter.widgets.Focus.focusNode} |
| final FocusNode? focusNode; |
| |
| /// {@macro flutter.widgets.Focus.autofocus} |
| final bool autofocus; |
| |
| final _SliderType _sliderType ; |
| |
| @override |
| State<Slider> createState() => _SliderState(); |
| |
| @override |
| void debugFillProperties(DiagnosticPropertiesBuilder properties) { |
| super.debugFillProperties(properties); |
| properties.add(DoubleProperty('value', value)); |
| properties.add(DoubleProperty('secondaryTrackValue', secondaryTrackValue)); |
| properties.add(ObjectFlagProperty<ValueChanged<double>>('onChanged', onChanged, ifNull: 'disabled')); |
| properties.add(ObjectFlagProperty<ValueChanged<double>>.has('onChangeStart', onChangeStart)); |
| properties.add(ObjectFlagProperty<ValueChanged<double>>.has('onChangeEnd', onChangeEnd)); |
| properties.add(DoubleProperty('min', min)); |
| properties.add(DoubleProperty('max', max)); |
| properties.add(IntProperty('divisions', divisions)); |
| properties.add(StringProperty('label', label)); |
| properties.add(ColorProperty('activeColor', activeColor)); |
| properties.add(ColorProperty('inactiveColor', inactiveColor)); |
| properties.add(ColorProperty('secondaryActiveColor', secondaryActiveColor)); |
| properties.add(ObjectFlagProperty<ValueChanged<double>>.has('semanticFormatterCallback', semanticFormatterCallback)); |
| properties.add(ObjectFlagProperty<FocusNode>.has('focusNode', focusNode)); |
| properties.add(FlagProperty('autofocus', value: autofocus, ifTrue: 'autofocus')); |
| } |
| } |
| |
| class _SliderState extends State<Slider> with TickerProviderStateMixin { |
| static const Duration enableAnimationDuration = Duration(milliseconds: 75); |
| static const Duration valueIndicatorAnimationDuration = Duration(milliseconds: 100); |
| |
| // Animation controller that is run when the overlay (a.k.a radial reaction) |
| // is shown in response to user interaction. |
| late AnimationController overlayController; |
| // Animation controller that is run when the value indicator is being shown |
| // or hidden. |
| late AnimationController valueIndicatorController; |
| // Animation controller that is run when enabling/disabling the slider. |
| late AnimationController enableController; |
| // Animation controller that is run when transitioning between one value |
| // and the next on a discrete slider. |
| late AnimationController positionController; |
| Timer? interactionTimer; |
| |
| final GlobalKey _renderObjectKey = GlobalKey(); |
| |
| // Keyboard mapping for a focused slider. |
| static const Map<ShortcutActivator, Intent> _traditionalNavShortcutMap = <ShortcutActivator, Intent>{ |
| SingleActivator(LogicalKeyboardKey.arrowUp): _AdjustSliderIntent.up(), |
| SingleActivator(LogicalKeyboardKey.arrowDown): _AdjustSliderIntent.down(), |
| SingleActivator(LogicalKeyboardKey.arrowLeft): _AdjustSliderIntent.left(), |
| SingleActivator(LogicalKeyboardKey.arrowRight): _AdjustSliderIntent.right(), |
| }; |
| |
| // Keyboard mapping for a focused slider when using directional navigation. |
| // The vertical inputs are not handled to allow navigating out of the slider. |
| static const Map<ShortcutActivator, Intent> _directionalNavShortcutMap = <ShortcutActivator, Intent>{ |
| SingleActivator(LogicalKeyboardKey.arrowLeft): _AdjustSliderIntent.left(), |
| SingleActivator(LogicalKeyboardKey.arrowRight): _AdjustSliderIntent.right(), |
| }; |
| |
| // Action mapping for a focused slider. |
| late Map<Type, Action<Intent>> _actionMap; |
| |
| bool get _enabled => widget.onChanged != null; |
| // Value Indicator Animation that appears on the Overlay. |
| PaintValueIndicator? paintValueIndicator; |
| |
| bool _dragging = false; |
| |
| FocusNode? _focusNode; |
| FocusNode get focusNode => widget.focusNode ?? _focusNode!; |
| |
| @override |
| void initState() { |
| super.initState(); |
| overlayController = AnimationController( |
| duration: kRadialReactionDuration, |
| vsync: this, |
| ); |
| valueIndicatorController = AnimationController( |
| duration: valueIndicatorAnimationDuration, |
| vsync: this, |
| ); |
| enableController = AnimationController( |
| duration: enableAnimationDuration, |
| vsync: this, |
| ); |
| positionController = AnimationController( |
| duration: Duration.zero, |
| vsync: this, |
| ); |
| enableController.value = widget.onChanged != null ? 1.0 : 0.0; |
| positionController.value = _convert(widget.value); |
| _actionMap = <Type, Action<Intent>>{ |
| _AdjustSliderIntent: CallbackAction<_AdjustSliderIntent>( |
| onInvoke: _actionHandler, |
| ), |
| }; |
| if (widget.focusNode == null) { |
| // Only create a new node if the widget doesn't have one. |
| _focusNode ??= FocusNode(); |
| } |
| } |
| |
| @override |
| void dispose() { |
| interactionTimer?.cancel(); |
| overlayController.dispose(); |
| valueIndicatorController.dispose(); |
| enableController.dispose(); |
| positionController.dispose(); |
| if (overlayEntry != null) { |
| overlayEntry!.remove(); |
| overlayEntry = null; |
| } |
| _focusNode?.dispose(); |
| super.dispose(); |
| } |
| |
| void _handleChanged(double value) { |
| assert(widget.onChanged != null); |
| final double lerpValue = _lerp(value); |
| if (lerpValue != widget.value) { |
| widget.onChanged!(lerpValue); |
| _focusNode?.requestFocus(); |
| } |
| } |
| |
| void _handleDragStart(double value) { |
| _dragging = true; |
| widget.onChangeStart?.call(_lerp(value)); |
| } |
| |
| void _handleDragEnd(double value) { |
| _dragging = false; |
| widget.onChangeEnd?.call(_lerp(value)); |
| } |
| |
| void _actionHandler(_AdjustSliderIntent intent) { |
| final _RenderSlider renderSlider = _renderObjectKey.currentContext!.findRenderObject()! as _RenderSlider; |
| final TextDirection textDirection = Directionality.of(_renderObjectKey.currentContext!); |
| switch (intent.type) { |
| case _SliderAdjustmentType.right: |
| switch (textDirection) { |
| case TextDirection.rtl: |
| renderSlider.decreaseAction(); |
| break; |
| case TextDirection.ltr: |
| renderSlider.increaseAction(); |
| break; |
| } |
| break; |
| case _SliderAdjustmentType.left: |
| switch (textDirection) { |
| case TextDirection.rtl: |
| renderSlider.increaseAction(); |
| break; |
| case TextDirection.ltr: |
| renderSlider.decreaseAction(); |
| break; |
| } |
| break; |
| case _SliderAdjustmentType.up: |
| renderSlider.increaseAction(); |
| break; |
| case _SliderAdjustmentType.down: |
| renderSlider.decreaseAction(); |
| break; |
| } |
| } |
| |
| bool _focused = false; |
| void _handleFocusHighlightChanged(bool focused) { |
| if (focused != _focused) { |
| setState(() { _focused = focused; }); |
| } |
| } |
| |
| bool _hovering = false; |
| void _handleHoverChanged(bool hovering) { |
| if (hovering != _hovering) { |
| setState(() { _hovering = hovering; }); |
| } |
| } |
| |
| // Returns a number between min and max, proportional to value, which must |
| // be between 0.0 and 1.0. |
| double _lerp(double value) { |
| assert(value >= 0.0); |
| assert(value <= 1.0); |
| return value * (widget.max - widget.min) + widget.min; |
| } |
| |
| double _discretize(double value) { |
| assert(widget.divisions != null); |
| assert(value >= 0.0 && value <= 1.0); |
| |
| final int divisions = widget.divisions!; |
| return (value * divisions).round() / divisions; |
| } |
| |
| double _convert(double value) { |
| double ret = _unlerp(value); |
| if (widget.divisions != null) { |
| ret = _discretize(ret); |
| } |
| return ret; |
| } |
| |
| // Returns a number between 0.0 and 1.0, given a value between min and max. |
| double _unlerp(double value) { |
| assert(value <= widget.max); |
| assert(value >= widget.min); |
| return widget.max > widget.min ? (value - widget.min) / (widget.max - widget.min) : 0.0; |
| } |
| |
| @override |
| Widget build(BuildContext context) { |
| assert(debugCheckHasMaterial(context)); |
| assert(debugCheckHasMediaQuery(context)); |
| |
| switch (widget._sliderType) { |
| case _SliderType.material: |
| return _buildMaterialSlider(context); |
| |
| case _SliderType.adaptive: { |
| final ThemeData theme = Theme.of(context); |
| switch (theme.platform) { |
| case TargetPlatform.android: |
| case TargetPlatform.fuchsia: |
| case TargetPlatform.linux: |
| case TargetPlatform.windows: |
| return _buildMaterialSlider(context); |
| case TargetPlatform.iOS: |
| case TargetPlatform.macOS: |
| return _buildCupertinoSlider(context); |
| } |
| } |
| } |
| } |
| |
| Widget _buildMaterialSlider(BuildContext context) { |
| final ThemeData theme = Theme.of(context); |
| SliderThemeData sliderTheme = SliderTheme.of(context); |
| final SliderThemeData defaults = theme.useMaterial3 ? _SliderDefaultsM3(context) : _SliderDefaultsM2(context); |
| |
| // If the widget has active or inactive colors specified, then we plug them |
| // in to the slider theme as best we can. If the developer wants more |
| // control than that, then they need to use a SliderTheme. The default |
| // colors come from the ThemeData.colorScheme. These colors, along with |
| // the default shapes and text styles are aligned to the Material |
| // Guidelines. |
| |
| const SliderTrackShape defaultTrackShape = RoundedRectSliderTrackShape(); |
| const SliderTickMarkShape defaultTickMarkShape = RoundSliderTickMarkShape(); |
| const SliderComponentShape defaultOverlayShape = RoundSliderOverlayShape(); |
| const SliderComponentShape defaultThumbShape = RoundSliderThumbShape(); |
| final SliderComponentShape defaultValueIndicatorShape = defaults.valueIndicatorShape!; |
| const ShowValueIndicator defaultShowValueIndicator = ShowValueIndicator.onlyForDiscrete; |
| |
| final Set<MaterialState> states = <MaterialState>{ |
| if (!_enabled) MaterialState.disabled, |
| if (_hovering) MaterialState.hovered, |
| if (_focused) MaterialState.focused, |
| if (_dragging) MaterialState.dragged, |
| }; |
| |
| // The value indicator's color is not the same as the thumb and active track |
| // (which can be defined by activeColor) if the |
| // RectangularSliderValueIndicatorShape is used. In all other cases, the |
| // value indicator is assumed to be the same as the active color. |
| final SliderComponentShape valueIndicatorShape = sliderTheme.valueIndicatorShape ?? defaultValueIndicatorShape; |
| final Color valueIndicatorColor; |
| if (valueIndicatorShape is RectangularSliderValueIndicatorShape) { |
| valueIndicatorColor = sliderTheme.valueIndicatorColor ?? Color.alphaBlend(theme.colorScheme.onSurface.withOpacity(0.60), theme.colorScheme.surface.withOpacity(0.90)); |
| } else { |
| valueIndicatorColor = widget.activeColor ?? sliderTheme.valueIndicatorColor ?? theme.colorScheme.primary; |
| } |
| |
| Color? effectiveOverlayColor() { |
| return widget.overlayColor?.resolve(states) |
| ?? widget.activeColor?.withOpacity(0.12) |
| ?? MaterialStateProperty.resolveAs<Color?>(sliderTheme.overlayColor, states) |
| ?? MaterialStateProperty.resolveAs<Color?>(defaults.overlayColor, states); |
| } |
| |
| sliderTheme = sliderTheme.copyWith( |
| trackHeight: sliderTheme.trackHeight ?? defaults.trackHeight, |
| activeTrackColor: widget.activeColor ?? sliderTheme.activeTrackColor ?? defaults.activeTrackColor, |
| inactiveTrackColor: widget.inactiveColor ?? sliderTheme.inactiveTrackColor ?? defaults.inactiveTrackColor, |
| secondaryActiveTrackColor: widget.secondaryActiveColor ?? sliderTheme.secondaryActiveTrackColor ?? defaults.secondaryActiveTrackColor, |
| disabledActiveTrackColor: sliderTheme.disabledActiveTrackColor ?? defaults.disabledActiveTrackColor, |
| disabledInactiveTrackColor: sliderTheme.disabledInactiveTrackColor ?? defaults.disabledInactiveTrackColor, |
| disabledSecondaryActiveTrackColor: sliderTheme.disabledSecondaryActiveTrackColor ?? defaults.disabledSecondaryActiveTrackColor, |
| activeTickMarkColor: widget.inactiveColor ?? sliderTheme.activeTickMarkColor ?? defaults.activeTickMarkColor, |
| inactiveTickMarkColor: widget.activeColor ?? sliderTheme.inactiveTickMarkColor ?? defaults.inactiveTickMarkColor, |
| disabledActiveTickMarkColor: sliderTheme.disabledActiveTickMarkColor ?? defaults.disabledActiveTickMarkColor, |
| disabledInactiveTickMarkColor: sliderTheme.disabledInactiveTickMarkColor ?? defaults.disabledInactiveTickMarkColor, |
| thumbColor: widget.thumbColor ?? widget.activeColor ?? sliderTheme.thumbColor ?? defaults.thumbColor, |
| disabledThumbColor: sliderTheme.disabledThumbColor ?? defaults.disabledThumbColor, |
| overlayColor: effectiveOverlayColor(), |
| valueIndicatorColor: valueIndicatorColor, |
| trackShape: sliderTheme.trackShape ?? defaultTrackShape, |
| tickMarkShape: sliderTheme.tickMarkShape ?? defaultTickMarkShape, |
| thumbShape: sliderTheme.thumbShape ?? defaultThumbShape, |
| overlayShape: sliderTheme.overlayShape ?? defaultOverlayShape, |
| valueIndicatorShape: valueIndicatorShape, |
| showValueIndicator: sliderTheme.showValueIndicator ?? defaultShowValueIndicator, |
| valueIndicatorTextStyle: sliderTheme.valueIndicatorTextStyle ?? defaults.valueIndicatorTextStyle, |
| ); |
| final MouseCursor effectiveMouseCursor = MaterialStateProperty.resolveAs<MouseCursor?>(widget.mouseCursor, states) |
| ?? sliderTheme.mouseCursor?.resolve(states) |
| ?? MaterialStateMouseCursor.clickable.resolve(states); |
| |
| // This size is used as the max bounds for the painting of the value |
| // indicators It must be kept in sync with the function with the same name |
| // in range_slider.dart. |
| Size screenSize() => MediaQuery.sizeOf(context); |
| |
| VoidCallback? handleDidGainAccessibilityFocus; |
| switch (theme.platform) { |
| case TargetPlatform.android: |
| case TargetPlatform.fuchsia: |
| case TargetPlatform.iOS: |
| case TargetPlatform.linux: |
| case TargetPlatform.macOS: |
| break; |
| case TargetPlatform.windows: |
| handleDidGainAccessibilityFocus = () { |
| // Automatically activate the slider when it receives a11y focus. |
| if (!focusNode.hasFocus && focusNode.canRequestFocus) { |
| focusNode.requestFocus(); |
| } |
| }; |
| break; |
| } |
| |
| final Map<ShortcutActivator, Intent> shortcutMap; |
| switch (MediaQuery.navigationModeOf(context)) { |
| case NavigationMode.directional: |
| shortcutMap = _directionalNavShortcutMap; |
| break; |
| case NavigationMode.traditional: |
| shortcutMap = _traditionalNavShortcutMap; |
| break; |
| } |
| |
| final double textScaleFactor = theme.useMaterial3 |
| // TODO(tahatesser): This is an eye-balled value. |
| // This needs to be updated when accessibility |
| // guidelines are available on the material specs page |
| // https://m3.material.io/components/sliders/accessibility. |
| ? math.min(MediaQuery.textScaleFactorOf(context), 1.3) |
| : MediaQuery.textScaleFactorOf(context); |
| |
| return Semantics( |
| container: true, |
| slider: true, |
| onDidGainAccessibilityFocus: handleDidGainAccessibilityFocus, |
| child: FocusableActionDetector( |
| actions: _actionMap, |
| shortcuts: shortcutMap, |
| focusNode: focusNode, |
| autofocus: widget.autofocus, |
| enabled: _enabled, |
| onShowFocusHighlight: _handleFocusHighlightChanged, |
| onShowHoverHighlight: _handleHoverChanged, |
| mouseCursor: effectiveMouseCursor, |
| child: CompositedTransformTarget( |
| link: _layerLink, |
| child: _SliderRenderObjectWidget( |
| key: _renderObjectKey, |
| value: _convert(widget.value), |
| secondaryTrackValue: (widget.secondaryTrackValue != null) ? _convert(widget.secondaryTrackValue!) : null, |
| divisions: widget.divisions, |
| label: widget.label, |
| sliderTheme: sliderTheme, |
| textScaleFactor: textScaleFactor, |
| screenSize: screenSize(), |
| onChanged: (widget.onChanged != null) && (widget.max > widget.min) ? _handleChanged : null, |
| onChangeStart: _handleDragStart, |
| onChangeEnd: _handleDragEnd, |
| state: this, |
| semanticFormatterCallback: widget.semanticFormatterCallback, |
| hasFocus: _focused, |
| hovering: _hovering, |
| ), |
| ), |
| ), |
| ); |
| } |
| |
| Widget _buildCupertinoSlider(BuildContext context) { |
| // The render box of a slider has a fixed height but takes up the available |
| // width. Wrapping the [CupertinoSlider] in this manner will help maintain |
| // the same size. |
| return SizedBox( |
| width: double.infinity, |
| child: CupertinoSlider( |
| value: widget.value, |
| onChanged: widget.onChanged, |
| onChangeStart: widget.onChangeStart, |
| onChangeEnd: widget.onChangeEnd, |
| min: widget.min, |
| max: widget.max, |
| divisions: widget.divisions, |
| activeColor: widget.activeColor, |
| thumbColor: widget.thumbColor ?? CupertinoColors.white, |
| ), |
| ); |
| } |
| final LayerLink _layerLink = LayerLink(); |
| |
| OverlayEntry? overlayEntry; |
| |
| void showValueIndicator() { |
| if (overlayEntry == null) { |
| overlayEntry = OverlayEntry( |
| builder: (BuildContext context) { |
| return CompositedTransformFollower( |
| link: _layerLink, |
| child: _ValueIndicatorRenderObjectWidget( |
| state: this, |
| ), |
| ); |
| }, |
| ); |
| Overlay.of(context, debugRequiredFor: widget).insert(overlayEntry!); |
| } |
| } |
| } |
| |
| class _SliderRenderObjectWidget extends LeafRenderObjectWidget { |
| const _SliderRenderObjectWidget({ |
| super.key, |
| required this.value, |
| required this.secondaryTrackValue, |
| required this.divisions, |
| required this.label, |
| required this.sliderTheme, |
| required this.textScaleFactor, |
| required this.screenSize, |
| required this.onChanged, |
| required this.onChangeStart, |
| required this.onChangeEnd, |
| required this.state, |
| required this.semanticFormatterCallback, |
| required this.hasFocus, |
| required this.hovering, |
| }); |
| |
| final double value; |
| final double? secondaryTrackValue; |
| final int? divisions; |
| final String? label; |
| final SliderThemeData sliderTheme; |
| final double textScaleFactor; |
| final Size screenSize; |
| final ValueChanged<double>? onChanged; |
| final ValueChanged<double>? onChangeStart; |
| final ValueChanged<double>? onChangeEnd; |
| final SemanticFormatterCallback? semanticFormatterCallback; |
| final _SliderState state; |
| final bool hasFocus; |
| final bool hovering; |
| |
| @override |
| _RenderSlider createRenderObject(BuildContext context) { |
| return _RenderSlider( |
| value: value, |
| secondaryTrackValue: secondaryTrackValue, |
| divisions: divisions, |
| label: label, |
| sliderTheme: sliderTheme, |
| textScaleFactor: textScaleFactor, |
| screenSize: screenSize, |
| onChanged: onChanged, |
| onChangeStart: onChangeStart, |
| onChangeEnd: onChangeEnd, |
| state: state, |
| textDirection: Directionality.of(context), |
| semanticFormatterCallback: semanticFormatterCallback, |
| platform: Theme.of(context).platform, |
| hasFocus: hasFocus, |
| hovering: hovering, |
| gestureSettings: MediaQuery.gestureSettingsOf(context), |
| ); |
| } |
| |
| @override |
| void updateRenderObject(BuildContext context, _RenderSlider renderObject) { |
| renderObject |
| // We should update the `divisions` ahead of `value`, because the `value` |
| // setter dependent on the `divisions`. |
| ..divisions = divisions |
| ..value = value |
| ..secondaryTrackValue = secondaryTrackValue |
| ..label = label |
| ..sliderTheme = sliderTheme |
| ..textScaleFactor = textScaleFactor |
| ..screenSize = screenSize |
| ..onChanged = onChanged |
| ..onChangeStart = onChangeStart |
| ..onChangeEnd = onChangeEnd |
| ..textDirection = Directionality.of(context) |
| ..semanticFormatterCallback = semanticFormatterCallback |
| ..platform = Theme.of(context).platform |
| ..hasFocus = hasFocus |
| ..hovering = hovering |
| ..gestureSettings = MediaQuery.gestureSettingsOf(context); |
| // Ticker provider cannot change since there's a 1:1 relationship between |
| // the _SliderRenderObjectWidget object and the _SliderState object. |
| } |
| } |
| |
| class _RenderSlider extends RenderBox with RelayoutWhenSystemFontsChangeMixin { |
| _RenderSlider({ |
| required double value, |
| required double? secondaryTrackValue, |
| required int? divisions, |
| required String? label, |
| required SliderThemeData sliderTheme, |
| required double textScaleFactor, |
| required Size screenSize, |
| required TargetPlatform platform, |
| required ValueChanged<double>? onChanged, |
| required SemanticFormatterCallback? semanticFormatterCallback, |
| required this.onChangeStart, |
| required this.onChangeEnd, |
| required _SliderState state, |
| required TextDirection textDirection, |
| required bool hasFocus, |
| required bool hovering, |
| required DeviceGestureSettings gestureSettings, |
| }) : assert(value >= 0.0 && value <= 1.0), |
| assert(secondaryTrackValue == null || (secondaryTrackValue >= 0.0 && secondaryTrackValue <= 1.0)), |
| _platform = platform, |
| _semanticFormatterCallback = semanticFormatterCallback, |
| _label = label, |
| _value = value, |
| _secondaryTrackValue = secondaryTrackValue, |
| _divisions = divisions, |
| _sliderTheme = sliderTheme, |
| _textScaleFactor = textScaleFactor, |
| _screenSize = screenSize, |
| _onChanged = onChanged, |
| _state = state, |
| _textDirection = textDirection, |
| _hasFocus = hasFocus, |
| _hovering = hovering { |
| _updateLabelPainter(); |
| final GestureArenaTeam team = GestureArenaTeam(); |
| _drag = HorizontalDragGestureRecognizer() |
| ..team = team |
| ..onStart = _handleDragStart |
| ..onUpdate = _handleDragUpdate |
| ..onEnd = _handleDragEnd |
| ..onCancel = _endInteraction |
| ..gestureSettings = gestureSettings; |
| _tap = TapGestureRecognizer() |
| ..team = team |
| ..onTapDown = _handleTapDown |
| ..onTapUp = _handleTapUp |
| ..gestureSettings = gestureSettings; |
| _overlayAnimation = CurvedAnimation( |
| parent: _state.overlayController, |
| curve: Curves.fastOutSlowIn, |
| ); |
| _valueIndicatorAnimation = CurvedAnimation( |
| parent: _state.valueIndicatorController, |
| curve: Curves.fastOutSlowIn, |
| )..addStatusListener((AnimationStatus status) { |
| if (status == AnimationStatus.dismissed && _state.overlayEntry != null) { |
| _state.overlayEntry!.remove(); |
| _state.overlayEntry = null; |
| } |
| }); |
| _enableAnimation = CurvedAnimation( |
| parent: _state.enableController, |
| curve: Curves.easeInOut, |
| ); |
| } |
| static const Duration _positionAnimationDuration = Duration(milliseconds: 75); |
| static const Duration _minimumInteractionTime = Duration(milliseconds: 500); |
| |
| // This value is the touch target, 48, multiplied by 3. |
| static const double _minPreferredTrackWidth = 144.0; |
| |
| // Compute the largest width and height needed to paint the slider shapes, |
| // other than the track shape. It is assumed that these shapes are vertically |
| // centered on the track. |
| double get _maxSliderPartWidth => _sliderPartSizes.map((Size size) => size.width).reduce(math.max); |
| double get _maxSliderPartHeight => _sliderPartSizes.map((Size size) => size.height).reduce(math.max); |
| List<Size> get _sliderPartSizes => <Size>[ |
| _sliderTheme.overlayShape!.getPreferredSize(isInteractive, isDiscrete), |
| _sliderTheme.thumbShape!.getPreferredSize(isInteractive, isDiscrete), |
| _sliderTheme.tickMarkShape!.getPreferredSize(isEnabled: isInteractive, sliderTheme: sliderTheme), |
| ]; |
| double get _minPreferredTrackHeight => _sliderTheme.trackHeight!; |
| |
| final _SliderState _state; |
| late Animation<double> _overlayAnimation; |
| late Animation<double> _valueIndicatorAnimation; |
| late Animation<double> _enableAnimation; |
| final TextPainter _labelPainter = TextPainter(); |
| late HorizontalDragGestureRecognizer _drag; |
| late TapGestureRecognizer _tap; |
| bool _active = false; |
| double _currentDragValue = 0.0; |
| Rect? overlayRect; |
| |
| // This rect is used in gesture calculations, where the gesture coordinates |
| // are relative to the sliders origin. Therefore, the offset is passed as |
| // (0,0). |
| Rect get _trackRect => _sliderTheme.trackShape!.getPreferredRect( |
| parentBox: this, |
| sliderTheme: _sliderTheme, |
| isDiscrete: false, |
| ); |
| |
| bool get isInteractive => onChanged != null; |
| |
| bool get isDiscrete => divisions != null && divisions! > 0; |
| |
| double get value => _value; |
| double _value; |
| set value(double newValue) { |
| assert(newValue >= 0.0 && newValue <= 1.0); |
| final double convertedValue = isDiscrete ? _discretize(newValue) : newValue; |
| if (convertedValue == _value) { |
| return; |
| } |
| _value = convertedValue; |
| if (isDiscrete) { |
| // Reset the duration to match the distance that we're traveling, so that |
| // whatever the distance, we still do it in _positionAnimationDuration, |
| // and if we get re-targeted in the middle, it still takes that long to |
| // get to the new location. |
| final double distance = (_value - _state.positionController.value).abs(); |
| _state.positionController.duration = distance != 0.0 |
| ? _positionAnimationDuration * (1.0 / distance) |
| : Duration.zero; |
| _state.positionController.animateTo(convertedValue, curve: Curves.easeInOut); |
| } else { |
| _state.positionController.value = convertedValue; |
| } |
| markNeedsSemanticsUpdate(); |
| } |
| |
| double? get secondaryTrackValue => _secondaryTrackValue; |
| double? _secondaryTrackValue; |
| set secondaryTrackValue(double? newValue) { |
| assert(newValue == null || (newValue >= 0.0 && newValue <= 1.0)); |
| if (newValue == _secondaryTrackValue) { |
| return; |
| } |
| _secondaryTrackValue = newValue; |
| markNeedsSemanticsUpdate(); |
| } |
| |
| DeviceGestureSettings? get gestureSettings => _drag.gestureSettings; |
| set gestureSettings(DeviceGestureSettings? gestureSettings) { |
| _drag.gestureSettings = gestureSettings; |
| _tap.gestureSettings = gestureSettings; |
| } |
| |
| TargetPlatform _platform; |
| TargetPlatform get platform => _platform; |
| set platform(TargetPlatform value) { |
| if (_platform == value) { |
| return; |
| } |
| _platform = value; |
| markNeedsSemanticsUpdate(); |
| } |
| |
| SemanticFormatterCallback? _semanticFormatterCallback; |
| SemanticFormatterCallback? get semanticFormatterCallback => _semanticFormatterCallback; |
| set semanticFormatterCallback(SemanticFormatterCallback? value) { |
| if (_semanticFormatterCallback == value) { |
| return; |
| } |
| _semanticFormatterCallback = value; |
| markNeedsSemanticsUpdate(); |
| } |
| |
| int? get divisions => _divisions; |
| int? _divisions; |
| set divisions(int? value) { |
| if (value == _divisions) { |
| return; |
| } |
| _divisions = value; |
| markNeedsPaint(); |
| } |
| |
| String? get label => _label; |
| String? _label; |
| set label(String? value) { |
| if (value == _label) { |
| return; |
| } |
| _label = value; |
| _updateLabelPainter(); |
| } |
| |
| SliderThemeData get sliderTheme => _sliderTheme; |
| SliderThemeData _sliderTheme; |
| set sliderTheme(SliderThemeData value) { |
| if (value == _sliderTheme) { |
| return; |
| } |
| _sliderTheme = value; |
| markNeedsPaint(); |
| } |
| |
| double get textScaleFactor => _textScaleFactor; |
| double _textScaleFactor; |
| set textScaleFactor(double value) { |
| if (value == _textScaleFactor) { |
| return; |
| } |
| _textScaleFactor = value; |
| _updateLabelPainter(); |
| } |
| |
| Size get screenSize => _screenSize; |
| Size _screenSize; |
| set screenSize(Size value) { |
| if (value == _screenSize) { |
| return; |
| } |
| _screenSize = 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) { |
| if (isInteractive) { |
| _state.enableController.forward(); |
| } else { |
| _state.enableController.reverse(); |
| } |
| markNeedsPaint(); |
| markNeedsSemanticsUpdate(); |
| } |
| } |
| |
| ValueChanged<double>? onChangeStart; |
| ValueChanged<double>? onChangeEnd; |
| |
| TextDirection get textDirection => _textDirection; |
| TextDirection _textDirection; |
| set textDirection(TextDirection value) { |
| if (value == _textDirection) { |
| return; |
| } |
| _textDirection = value; |
| _updateLabelPainter(); |
| } |
| |
| /// True if this slider has the input focus. |
| bool get hasFocus => _hasFocus; |
| bool _hasFocus; |
| set hasFocus(bool value) { |
| if (value == _hasFocus) { |
| return; |
| } |
| _hasFocus = value; |
| _updateForFocus(_hasFocus); |
| markNeedsSemanticsUpdate(); |
| } |
| |
| /// True if this slider is being hovered over by a pointer. |
| bool get hovering => _hovering; |
| bool _hovering; |
| set hovering(bool value) { |
| if (value == _hovering) { |
| return; |
| } |
| _hovering = value; |
| _updateForHover(_hovering); |
| } |
| |
| /// True if the slider is interactive and the slider thumb is being |
| /// hovered over by a pointer. |
| bool _hoveringThumb = false; |
| bool get hoveringThumb => _hoveringThumb; |
| set hoveringThumb(bool value) { |
| if (value == _hoveringThumb) { |
| return; |
| } |
| _hoveringThumb = value; |
| _updateForHover(_hovering); |
| } |
| |
| void _updateForFocus(bool focused) { |
| if (focused) { |
| _state.overlayController.forward(); |
| if (showValueIndicator) { |
| _state.valueIndicatorController.forward(); |
| } |
| } else { |
| _state.overlayController.reverse(); |
| if (showValueIndicator) { |
| _state.valueIndicatorController.reverse(); |
| } |
| } |
| } |
| |
| void _updateForHover(bool hovered) { |
| // Only show overlay when pointer is hovering the thumb. |
| if (hovered && hoveringThumb) { |
| _state.overlayController.forward(); |
| } else { |
| // Only remove overlay when Slider is unfocused. |
| if (!hasFocus) { |
| _state.overlayController.reverse(); |
| } |
| } |
| } |
| |
| bool get showValueIndicator { |
| switch (_sliderTheme.showValueIndicator!) { |
| case ShowValueIndicator.onlyForDiscrete: |
| return isDiscrete; |
| case ShowValueIndicator.onlyForContinuous: |
| return !isDiscrete; |
| case ShowValueIndicator.always: |
| return true; |
| case ShowValueIndicator.never: |
| return false; |
| } |
| } |
| |
| double get _adjustmentUnit { |
| switch (_platform) { |
| case TargetPlatform.iOS: |
| case TargetPlatform.macOS: |
| // Matches iOS implementation of material slider. |
| return 0.1; |
| case TargetPlatform.android: |
| case TargetPlatform.fuchsia: |
| case TargetPlatform.linux: |
| case TargetPlatform.windows: |
| // Matches Android implementation of material slider. |
| return 0.05; |
| } |
| } |
| |
| void _updateLabelPainter() { |
| if (label != null) { |
| _labelPainter |
| ..text = TextSpan( |
| style: _sliderTheme.valueIndicatorTextStyle, |
| text: label, |
| ) |
| ..textDirection = textDirection |
| ..textScaleFactor = textScaleFactor |
| ..layout(); |
| } else { |
| _labelPainter.text = null; |
| } |
| // Changing the textDirection can result in the layout changing, because the |
| // bidi algorithm might line up the glyphs differently which can result in |
| // different ligatures, different shapes, etc. So we always markNeedsLayout. |
| markNeedsLayout(); |
| } |
| |
| @override |
| void systemFontsDidChange() { |
| super.systemFontsDidChange(); |
| _labelPainter.markNeedsLayout(); |
| _updateLabelPainter(); |
| } |
| |
| @override |
| void attach(PipelineOwner owner) { |
| super.attach(owner); |
| _overlayAnimation.addListener(markNeedsPaint); |
| _valueIndicatorAnimation.addListener(markNeedsPaint); |
| _enableAnimation.addListener(markNeedsPaint); |
| _state.positionController.addListener(markNeedsPaint); |
| } |
| |
| @override |
| void detach() { |
| _overlayAnimation.removeListener(markNeedsPaint); |
| _valueIndicatorAnimation.removeListener(markNeedsPaint); |
| _enableAnimation.removeListener(markNeedsPaint); |
| _state.positionController.removeListener(markNeedsPaint); |
| super.detach(); |
| } |
| |
| @override |
| void dispose() { |
| _labelPainter.dispose(); |
| super.dispose(); |
| } |
| |
| double _getValueFromVisualPosition(double visualPosition) { |
| switch (textDirection) { |
| case TextDirection.rtl: |
| return 1.0 - visualPosition; |
| case TextDirection.ltr: |
| return visualPosition; |
| } |
| } |
| |
| double _getValueFromGlobalPosition(Offset globalPosition) { |
| final double visualPosition = (globalToLocal(globalPosition).dx - _trackRect.left) / _trackRect.width; |
| return _getValueFromVisualPosition(visualPosition); |
| } |
| |
| double _discretize(double value) { |
| double result = clampDouble(value, 0.0, 1.0); |
| if (isDiscrete) { |
| result = (result * divisions!).round() / divisions!; |
| } |
| return result; |
| } |
| |
| void _startInteraction(Offset globalPosition) { |
| _state.showValueIndicator(); |
| if (!_active && isInteractive) { |
| _active = true; |
| // We supply the *current* value as the start location, so that if we have |
| // a tap, it consists of a call to onChangeStart with the previous value and |
| // a call to onChangeEnd with the new value. |
| onChangeStart?.call(_discretize(value)); |
| _currentDragValue = _getValueFromGlobalPosition(globalPosition); |
| onChanged!(_discretize(_currentDragValue)); |
| _state.overlayController.forward(); |
| if (showValueIndicator) { |
| _state.valueIndicatorController.forward(); |
| _state.interactionTimer?.cancel(); |
| _state.interactionTimer = Timer(_minimumInteractionTime * timeDilation, () { |
| _state.interactionTimer = null; |
| if (!_active && !hasFocus && |
| _state.valueIndicatorController.status == AnimationStatus.completed) { |
| _state.valueIndicatorController.reverse(); |
| } |
| }); |
| } |
| } |
| } |
| |
| void _endInteraction() { |
| if (!_state.mounted) { |
| return; |
| } |
| |
| if (_active && _state.mounted) { |
| onChangeEnd?.call(_discretize(_currentDragValue)); |
| _active = false; |
| _currentDragValue = 0.0; |
| if (!hasFocus) { |
| _state.overlayController.reverse(); |
| } |
| |
| if (showValueIndicator && _state.interactionTimer == null) { |
| _state.valueIndicatorController.reverse(); |
| } |
| } |
| } |
| |
| void _handleDragStart(DragStartDetails details) { |
| _startInteraction(details.globalPosition); |
| } |
| |
| void _handleDragUpdate(DragUpdateDetails details) { |
| if (!_state.mounted) { |
| return; |
| } |
| |
| if (isInteractive) { |
| final double valueDelta = details.primaryDelta! / _trackRect.width; |
| switch (textDirection) { |
| case TextDirection.rtl: |
| _currentDragValue -= valueDelta; |
| break; |
| case TextDirection.ltr: |
| _currentDragValue += valueDelta; |
| break; |
| } |
| onChanged!(_discretize(_currentDragValue)); |
| } |
| } |
| |
| void _handleDragEnd(DragEndDetails details) { |
| _endInteraction(); |
| } |
| |
| void _handleTapDown(TapDownDetails details) { |
| _startInteraction(details.globalPosition); |
| } |
| |
| void _handleTapUp(TapUpDetails details) { |
| _endInteraction(); |
| } |
| |
| @override |
| bool hitTestSelf(Offset position) => true; |
| |
| @override |
| void handleEvent(PointerEvent event, BoxHitTestEntry entry) { |
| if (!_state.mounted) { |
| return; |
| } |
| assert(debugHandleEvent(event, entry)); |
| if (event is PointerDownEvent && isInteractive) { |
| // We need to add the drag first so that it has priority. |
| _drag.addPointer(event); |
| _tap.addPointer(event); |
| } |
| if (isInteractive && overlayRect != null) { |
| hoveringThumb = overlayRect!.contains(event.localPosition); |
| } |
| } |
| |
| @override |
| double computeMinIntrinsicWidth(double height) => _minPreferredTrackWidth + _maxSliderPartWidth; |
| |
| @override |
| double computeMaxIntrinsicWidth(double height) => _minPreferredTrackWidth + _maxSliderPartWidth; |
| |
| @override |
| double computeMinIntrinsicHeight(double width) => math.max(_minPreferredTrackHeight, _maxSliderPartHeight); |
| |
| @override |
| double computeMaxIntrinsicHeight(double width) => math.max(_minPreferredTrackHeight, _maxSliderPartHeight); |
| |
| @override |
| bool get sizedByParent => true; |
| |
| @override |
| Size computeDryLayout(BoxConstraints constraints) { |
| return Size( |
| constraints.hasBoundedWidth ? constraints.maxWidth : _minPreferredTrackWidth + _maxSliderPartWidth, |
| constraints.hasBoundedHeight ? constraints.maxHeight : math.max(_minPreferredTrackHeight, _maxSliderPartHeight), |
| ); |
| } |
| |
| @override |
| void paint(PaintingContext context, Offset offset) { |
| final double value = _state.positionController.value; |
| final double? secondaryValue = _secondaryTrackValue; |
| |
| // The visual position is the position of the thumb from 0 to 1 from left |
| // to right. In left to right, this is the same as the value, but it is |
| // reversed for right to left text. |
| final double visualPosition; |
| final double? secondaryVisualPosition; |
| switch (textDirection) { |
| case TextDirection.rtl: |
| visualPosition = 1.0 - value; |
| secondaryVisualPosition = (secondaryValue != null) ? (1.0 - secondaryValue) : null; |
| break; |
| case TextDirection.ltr: |
| visualPosition = value; |
| secondaryVisualPosition = (secondaryValue != null) ? secondaryValue : null; |
| break; |
| } |
| |
| final Rect trackRect = _sliderTheme.trackShape!.getPreferredRect( |
| parentBox: this, |
| offset: offset, |
| sliderTheme: _sliderTheme, |
| isDiscrete: isDiscrete, |
| ); |
| final Offset thumbCenter = Offset(trackRect.left + visualPosition * trackRect.width, trackRect.center.dy); |
| if (isInteractive) { |
| final Size overlaySize = sliderTheme.overlayShape!.getPreferredSize(isInteractive, false); |
| overlayRect = Rect.fromCircle(center: thumbCenter, radius: overlaySize.width / 2.0); |
| } |
| final Offset? secondaryOffset = (secondaryVisualPosition != null) ? Offset(trackRect.left + secondaryVisualPosition * trackRect.width, trackRect.center.dy) : null; |
| |
| _sliderTheme.trackShape!.paint( |
| context, |
| offset, |
| parentBox: this, |
| sliderTheme: _sliderTheme, |
| enableAnimation: _enableAnimation, |
| textDirection: _textDirection, |
| thumbCenter: thumbCenter, |
| secondaryOffset: secondaryOffset, |
| isDiscrete: isDiscrete, |
| isEnabled: isInteractive, |
| ); |
| |
| if (!_overlayAnimation.isDismissed) { |
| _sliderTheme.overlayShape!.paint( |
| context, |
| thumbCenter, |
| activationAnimation: _overlayAnimation, |
| enableAnimation: _enableAnimation, |
| isDiscrete: isDiscrete, |
| labelPainter: _labelPainter, |
| parentBox: this, |
| sliderTheme: _sliderTheme, |
| textDirection: _textDirection, |
| value: _value, |
| textScaleFactor: _textScaleFactor, |
| sizeWithOverflow: screenSize.isEmpty ? size : screenSize, |
| ); |
| } |
| |
| if (isDiscrete) { |
| final double tickMarkWidth = _sliderTheme.tickMarkShape!.getPreferredSize( |
| isEnabled: isInteractive, |
| sliderTheme: _sliderTheme, |
| ).width; |
| final double padding = trackRect.height; |
| final double adjustedTrackWidth = trackRect.width - padding; |
| // If the tick marks would be too dense, don't bother painting them. |
| if (adjustedTrackWidth / divisions! >= 3.0 * tickMarkWidth) { |
| final double dy = trackRect.center.dy; |
| for (int i = 0; i <= divisions!; i++) { |
| final double value = i / divisions!; |
| // The ticks are mapped to be within the track, so the tick mark width |
| // must be subtracted from the track width. |
| final double dx = trackRect.left + value * adjustedTrackWidth + padding / 2; |
| final Offset tickMarkOffset = Offset(dx, dy); |
| _sliderTheme.tickMarkShape!.paint( |
| context, |
| tickMarkOffset, |
| parentBox: this, |
| sliderTheme: _sliderTheme, |
| enableAnimation: _enableAnimation, |
| textDirection: _textDirection, |
| thumbCenter: thumbCenter, |
| isEnabled: isInteractive, |
| ); |
| } |
| } |
| } |
| |
| if (isInteractive && label != null && !_valueIndicatorAnimation.isDismissed) { |
| if (showValueIndicator) { |
| _state.paintValueIndicator = (PaintingContext context, Offset offset) { |
| if (attached) { |
| _sliderTheme.valueIndicatorShape!.paint( |
| context, |
| offset + thumbCenter, |
| activationAnimation: _valueIndicatorAnimation, |
| enableAnimation: _enableAnimation, |
| isDiscrete: isDiscrete, |
| labelPainter: _labelPainter, |
| parentBox: this, |
| sliderTheme: _sliderTheme, |
| textDirection: _textDirection, |
| value: _value, |
| textScaleFactor: textScaleFactor, |
| sizeWithOverflow: screenSize.isEmpty ? size : screenSize, |
| ); |
| } |
| }; |
| } |
| } |
| |
| _sliderTheme.thumbShape!.paint( |
| context, |
| thumbCenter, |
| activationAnimation: _overlayAnimation, |
| enableAnimation: _enableAnimation, |
| isDiscrete: isDiscrete, |
| labelPainter: _labelPainter, |
| parentBox: this, |
| sliderTheme: _sliderTheme, |
| textDirection: _textDirection, |
| value: _value, |
| textScaleFactor: textScaleFactor, |
| sizeWithOverflow: screenSize.isEmpty ? size : screenSize, |
| ); |
| } |
| |
| @override |
| void describeSemanticsConfiguration(SemanticsConfiguration config) { |
| super.describeSemanticsConfiguration(config); |
| |
| // The Slider widget has its own Focus widget with semantics information, |
| // and we want that semantics node to collect the semantics information here |
| // so that it's all in the same node: otherwise Talkback sees that the node |
| // has focusable children, and it won't focus the Slider's Focus widget |
| // because it thinks the Focus widget's node doesn't have anything to say |
| // (which it doesn't, but this child does). Aggregating the semantic |
| // information into one node means that Talkback will recognize that it has |
| // something to say and focus it when it receives keyboard focus. |
| // (See https://github.com/flutter/flutter/issues/57038 for context). |
| config.isSemanticBoundary = false; |
| |
| config.isEnabled = isInteractive; |
| config.textDirection = textDirection; |
| if (isInteractive) { |
| config.onIncrease = increaseAction; |
| config.onDecrease = decreaseAction; |
| } |
| |
| if (semanticFormatterCallback != null) { |
| config.value = semanticFormatterCallback!(_state._lerp(value)); |
| config.increasedValue = semanticFormatterCallback!(_state._lerp(clampDouble(value + _semanticActionUnit, 0.0, 1.0))); |
| config.decreasedValue = semanticFormatterCallback!(_state._lerp(clampDouble(value - _semanticActionUnit, 0.0, 1.0))); |
| } else { |
| 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! : _adjustmentUnit; |
| |
| void increaseAction() { |
| if (isInteractive) { |
| onChanged!(clampDouble(value + _semanticActionUnit, 0.0, 1.0)); |
| } |
| } |
| |
| void decreaseAction() { |
| if (isInteractive) { |
| onChanged!(clampDouble(value - _semanticActionUnit, 0.0, 1.0)); |
| } |
| } |
| } |
| |
| class _AdjustSliderIntent extends Intent { |
| const _AdjustSliderIntent({ |
| required this.type, |
| }); |
| |
| const _AdjustSliderIntent.right() : type = _SliderAdjustmentType.right; |
| |
| const _AdjustSliderIntent.left() : type = _SliderAdjustmentType.left; |
| |
| const _AdjustSliderIntent.up() : type = _SliderAdjustmentType.up; |
| |
| const _AdjustSliderIntent.down() : type = _SliderAdjustmentType.down; |
| |
| final _SliderAdjustmentType type; |
| } |
| |
| enum _SliderAdjustmentType { |
| right, |
| left, |
| up, |
| down, |
| } |
| |
| class _ValueIndicatorRenderObjectWidget extends LeafRenderObjectWidget { |
| const _ValueIndicatorRenderObjectWidget({ |
| required this.state, |
| }); |
| |
| final _SliderState state; |
| |
| @override |
| _RenderValueIndicator createRenderObject(BuildContext context) { |
| return _RenderValueIndicator( |
| state: state, |
| ); |
| } |
| @override |
| void updateRenderObject(BuildContext context, _RenderValueIndicator renderObject) { |
| renderObject._state = state; |
| } |
| } |
| |
| class _RenderValueIndicator extends RenderBox with RelayoutWhenSystemFontsChangeMixin { |
| _RenderValueIndicator({ |
| required _SliderState state, |
| }) : _state = state { |
| _valueIndicatorAnimation = CurvedAnimation( |
| parent: _state.valueIndicatorController, |
| curve: Curves.fastOutSlowIn, |
| ); |
| } |
| late Animation<double> _valueIndicatorAnimation; |
| _SliderState _state; |
| |
| @override |
| bool get sizedByParent => true; |
| |
| @override |
| void attach(PipelineOwner owner) { |
| super.attach(owner); |
| _valueIndicatorAnimation.addListener(markNeedsPaint); |
| _state.positionController.addListener(markNeedsPaint); |
| } |
| |
| @override |
| void detach() { |
| _valueIndicatorAnimation.removeListener(markNeedsPaint); |
| _state.positionController.removeListener(markNeedsPaint); |
| super.detach(); |
| } |
| |
| @override |
| void paint(PaintingContext context, Offset offset) { |
| _state.paintValueIndicator?.call(context, offset); |
| } |
| |
| @override |
| Size computeDryLayout(BoxConstraints constraints) { |
| return constraints.smallest; |
| } |
| } |
| |
| class _SliderDefaultsM2 extends SliderThemeData { |
| _SliderDefaultsM2(this.context) |
| : _colors = Theme.of(context).colorScheme, |
| super(trackHeight: 4.0); |
| |
| final BuildContext context; |
| final ColorScheme _colors; |
| |
| @override |
| Color? get activeTrackColor => _colors.primary; |
| |
| @override |
| Color? get inactiveTrackColor => _colors.primary.withOpacity(0.24); |
| |
| @override |
| Color? get secondaryActiveTrackColor => _colors.primary.withOpacity(0.54); |
| |
| @override |
| Color? get disabledActiveTrackColor => _colors.onSurface.withOpacity(0.32); |
| |
| @override |
| Color? get disabledInactiveTrackColor => _colors.onSurface.withOpacity(0.12); |
| |
| @override |
| Color? get disabledSecondaryActiveTrackColor => _colors.onSurface.withOpacity(0.12); |
| |
| @override |
| Color? get activeTickMarkColor => _colors.onPrimary.withOpacity(0.54); |
| |
| @override |
| Color? get inactiveTickMarkColor => _colors.primary.withOpacity(0.54); |
| |
| @override |
| Color? get disabledActiveTickMarkColor => _colors.onPrimary.withOpacity(0.12); |
| |
| @override |
| Color? get disabledInactiveTickMarkColor => _colors.onSurface.withOpacity(0.12); |
| |
| @override |
| Color? get thumbColor => _colors.primary; |
| |
| @override |
| Color? get disabledThumbColor => Color.alphaBlend(_colors.onSurface.withOpacity(.38), _colors.surface); |
| |
| @override |
| Color? get overlayColor => _colors.primary.withOpacity(0.12); |
| |
| @override |
| TextStyle? get valueIndicatorTextStyle => Theme.of(context).textTheme.bodyLarge!.copyWith( |
| color: _colors.onPrimary, |
| ); |
| |
| @override |
| SliderComponentShape? get valueIndicatorShape => const RectangularSliderValueIndicatorShape(); |
| } |
| |
| // BEGIN GENERATED TOKEN PROPERTIES - Slider |
| |
| // Do not edit by hand. The code between the "BEGIN GENERATED" and |
| // "END GENERATED" comments are generated from data in the Material |
| // Design token database by the script: |
| // dev/tools/gen_defaults/bin/gen_defaults.dart. |
| |
| // Token database version: v0_152 |
| |
| class _SliderDefaultsM3 extends SliderThemeData { |
| _SliderDefaultsM3(this.context) |
| : _colors = Theme.of(context).colorScheme, |
| super(trackHeight: 4.0); |
| |
| final BuildContext context; |
| final ColorScheme _colors; |
| |
| @override |
| Color? get activeTrackColor => _colors.primary; |
| |
| @override |
| Color? get inactiveTrackColor => _colors.surfaceVariant; |
| |
| @override |
| Color? get secondaryActiveTrackColor => _colors.primary.withOpacity(0.54); |
| |
| @override |
| Color? get disabledActiveTrackColor => _colors.onSurface.withOpacity(0.38); |
| |
| @override |
| Color? get disabledInactiveTrackColor => _colors.onSurface.withOpacity(0.12); |
| |
| @override |
| Color? get disabledSecondaryActiveTrackColor => _colors.onSurface.withOpacity(0.12); |
| |
| @override |
| Color? get activeTickMarkColor => _colors.onPrimary.withOpacity(0.38); |
| |
| @override |
| Color? get inactiveTickMarkColor => _colors.onSurfaceVariant.withOpacity(0.38); |
| |
| @override |
| Color? get disabledActiveTickMarkColor => _colors.onSurface.withOpacity(0.38); |
| |
| @override |
| Color? get disabledInactiveTickMarkColor => _colors.onSurface.withOpacity(0.38); |
| |
| @override |
| Color? get thumbColor => _colors.primary; |
| |
| @override |
| Color? get disabledThumbColor => Color.alphaBlend(_colors.onSurface.withOpacity(0.38), _colors.surface); |
| |
| @override |
| Color? get overlayColor => MaterialStateColor.resolveWith((Set<MaterialState> states) { |
| if (states.contains(MaterialState.hovered)) { |
| return _colors.primary.withOpacity(0.08); |
| } |
| if (states.contains(MaterialState.focused)) { |
| return _colors.primary.withOpacity(0.12); |
| } |
| if (states.contains(MaterialState.dragged)) { |
| return _colors.primary.withOpacity(0.12); |
| } |
| |
| return Colors.transparent; |
| }); |
| |
| @override |
| TextStyle? get valueIndicatorTextStyle => Theme.of(context).textTheme.labelMedium!.copyWith( |
| color: _colors.onPrimary, |
| ); |
| |
| @override |
| SliderComponentShape? get valueIndicatorShape => const DropSliderValueIndicatorShape(); |
| } |
| |
| // END GENERATED TOKEN PROPERTIES - Slider |