// 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(); case TextDirection.ltr: renderSlider.increaseAction(); } case _SliderAdjustmentType.left: switch (textDirection) { case TextDirection.rtl: renderSlider.increaseAction(); case TextDirection.ltr: renderSlider.decreaseAction(); } case _SliderAdjustmentType.up: renderSlider.increaseAction(); case _SliderAdjustmentType.down: renderSlider.decreaseAction(); } } 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(); } }; } final Map<ShortcutActivator, Intent> shortcutMap; switch (MediaQuery.navigationModeOf(context)) { case NavigationMode.directional: shortcutMap = _directionalNavShortcutMap; case NavigationMode.traditional: shortcutMap = _traditionalNavShortcutMap; } 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; _updateLabelPainter(); } 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; case TextDirection.ltr: _currentDragValue += valueDelta; } 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; case TextDirection.ltr: visualPosition = value; secondaryVisualPosition = (secondaryValue != null) ? secondaryValue : null; } 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_162 class _SliderDefaultsM3 extends SliderThemeData { _SliderDefaultsM3(this.context) : super(trackHeight: 4.0); final BuildContext context; late final ColorScheme _colors = Theme.of(context).colorScheme; @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