// 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 'dart:ui' as ui; 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/widgets.dart'; import 'constants.dart'; import 'debug.dart'; import 'material_state.dart'; import 'slider_theme.dart'; import 'theme.dart'; // Examples can assume: // RangeValues _rangeValues = const RangeValues(0.3, 0.7); // RangeValues _dollarsRange = const RangeValues(50, 100); // void setState(VoidCallback fn) { } /// [RangeSlider] 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 PaintRangeValueIndicator = void Function(PaintingContext context, Offset offset); /// A Material Design range slider. /// /// Used to select a range from a range of values. /// /// {@youtube 560 315 https://www.youtube.com/watch?v=ufb4gIPDmEs} /// /// {@tool dartpad} /// ![A range slider widget, consisting of 5 divisions and showing the default /// value indicator.](https://flutter.github.io/assets-for-api-docs/assets/material/range_slider.png) /// /// This range values are in intervals of 20 because the Range Slider has 5 /// divisions, from 0 to 100. This means are values are split between 0, 20, 40, /// 60, 80, and 100. The range values are initialized with 40 and 80 in this demo. /// /// ** See code in examples/api/lib/material/range_slider/range_slider.0.dart ** /// {@end-tool} /// /// A range 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 "thumbs", which are the shapes that slide horizontally when the user /// drags them to change the selected range. /// * The "track", which is the horizontal line that the thumbs can be dragged /// along. /// * The "tick marks", which mark the discrete values of a discrete slider. /// * The "overlay", which is a highlight that's drawn over a thumb in response /// to a user tap-down gesture. /// * The "value indicators", which are the shapes that pop up when the user /// is dragging a thumb to show the value being selected. /// * The "active" segment of the slider is the segment between the two thumbs. /// * The "inactive" slider segments are the two track intervals outside of the /// slider's thumbs. /// /// The range 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 range 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 range slider will listen for the [onChanged] callback /// and rebuild the slider with new [values] 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 (including margins on each side) and will shrink-wrap vertically. /// /// Requires one of its ancestors to be a [Material] widget. This is typically /// provided by a [Scaffold] widget. /// /// Requires one of its ancestors to be a [MediaQuery] widget. Typically, a /// [MediaQuery] widget is 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] inside 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 colors, and other visual properties is achieved using a /// [SliderThemeData]. /// /// See also: /// /// * [SliderTheme] and [SliderThemeData] for information about controlling /// the visual appearance of the slider. /// * [Slider], for a single-valued slider. /// * [Radio], for selecting among a set of explicit values. /// * [Checkbox] and [Switch], for toggling a particular value on or off. /// * /// * [MediaQuery], from which the text scale factor is obtained. class RangeSlider extends StatefulWidget { /// Creates a Material Design range slider. /// /// The range 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 range slider will listen for the [onChanged] callback /// and rebuild the slider with new [values] 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]. /// /// * [values], which determines currently selected values for this range /// slider. /// * [onChanged], which is called while the user is selecting a new value for /// the range slider. /// * [onChangeStart], which is called when the user starts to select a new /// value for the range slider. /// * [onChangeEnd], which is called when the user is done selecting a new /// value for the range 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]. /// /// The [values], [min], [max] must not be null. The [min] must be less than /// or equal to the [max]. [values].start must be less than or equal to /// [values].end. [values].start and [values].end must be greater than or /// equal to the [min] and less than or equal to the [max]. The [divisions] /// must be null or greater than 0. RangeSlider({ super.key, required this.values, required this.onChanged, this.onChangeStart, this.onChangeEnd, this.min = 0.0, this.max = 1.0, this.divisions, this.labels, this.activeColor, this.inactiveColor, this.overlayColor, this.mouseCursor, this.semanticFormatterCallback, }) : assert(min <= max), assert(values.start <= values.end), assert(values.start >= min && values.start <= max), assert(values.end >= min && values.end <= max), assert(divisions == null || divisions > 0); /// The currently selected values for this range slider. /// /// The slider's thumbs are drawn at horizontal positions that corresponds to /// these values. final RangeValues values; /// Called when the user is selecting a new value for the slider by dragging. /// /// The slider passes the new values to the callback but does not actually /// change state until the parent widget rebuilds the slider with the new /// values. /// /// 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 /// RangeSlider( /// values: _rangeValues, /// min: 1.0, /// max: 10.0, /// onChanged: (RangeValues newValues) { /// setState(() { /// _rangeValues = newValues; /// }); /// }, /// ) /// ``` /// {@end-tool} /// /// See also: /// /// * [onChangeStart], which is called when the user starts changing the /// values. /// * [onChangeEnd], which is called when the user stops changing the values. final ValueChanged? onChanged; /// Called when the user starts selecting new values for the slider. /// /// This callback shouldn't be used to update the slider [values] (use /// [onChanged] for that). Rather, it should be used to be notified when the /// user has started selecting a new value by starting a drag or with a tap. /// /// The values passed will be the last [values] that the slider had before the /// change began. /// /// {@tool snippet} /// /// ```dart /// RangeSlider( /// values: _rangeValues, /// min: 1.0, /// max: 10.0, /// onChanged: (RangeValues newValues) { /// setState(() { /// _rangeValues = newValues; /// }); /// }, /// onChangeStart: (RangeValues startValues) { /// print('Started change at $startValues'); /// }, /// ) /// ``` /// {@end-tool} /// /// See also: /// /// * [onChangeEnd] for a callback that is called when the value change is /// complete. final ValueChanged? onChangeStart; /// Called when the user is done selecting new values for the slider. /// /// This differs from [onChanged] because it is only called once at the end /// of the interaction, while [onChanged] is called as the value is getting /// updated within the interaction. /// /// This callback shouldn't be used to update the slider [values] (use /// [onChanged] for that). Rather, it should be used to know when the user has /// completed selecting a new [values] by ending a drag or a click. /// /// {@tool snippet} /// /// ```dart /// RangeSlider( /// values: _rangeValues, /// min: 1.0, /// max: 10.0, /// onChanged: (RangeValues newValues) { /// setState(() { /// _rangeValues = newValues; /// }); /// }, /// onChangeEnd: (RangeValues endValues) { /// print('Ended change at $endValues'); /// }, /// ) /// ``` /// {@end-tool} /// /// See also: /// /// * [onChangeStart] for a callback that is called when a value change /// begins. final ValueChanged? 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 [labels] to show the current discrete values. /// /// If null, the slider is continuous. final int? divisions; /// Labels to show as text in the [SliderThemeData.rangeValueIndicatorShape] /// when the slider is active and [SliderThemeData.showValueIndicator] /// is satisfied. /// /// There are two labels: one for the start thumb and one for the end thumb. /// /// Each 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. /// /// See also: /// /// * [RangeSliderValueIndicatorShape] for how to create a custom value /// indicator shape. final RangeLabels? labels; /// The color of the track's active segment, i.e. the span of track between /// the thumbs. /// /// Defaults to [ColorScheme.primary]. /// /// Using a [SliderTheme] gives more fine-grained control over the /// appearance of various components of the slider. final Color? activeColor; /// The color of the track's inactive segments, i.e. the span of tracks /// between the min and the start thumb, and the end thumb and the max. /// /// Defaults to [ColorScheme.primary] with 24% opacity. /// /// Using a [SliderTheme] gives more fine-grained control over the /// appearance of various components of the slider. final Color? inactiveColor; /// The highlight color that's typically used to indicate that /// the range slider thumb is hovered or dragged. /// /// If this property is null, [RangeSlider] will use [activeColor] with /// an opacity of 0.12. If null, [SliderThemeData.overlayColor] /// will be used, otherwise defaults to [ColorScheme.primary] with /// an opacity of 0.12. final MaterialStateProperty? overlayColor; /// The cursor for a mouse pointer when it enters or is hovering over the /// widget. /// /// 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]. final MaterialStateProperty? mouseCursor; /// The callback used to create a semantic value from the slider's values. /// /// 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 /// RangeSlider( /// values: _dollarsRange, /// min: 20.0, /// max: 330.0, /// onChanged: (RangeValues newValues) { /// setState(() { /// _dollarsRange = newValues; /// }); /// }, /// semanticFormatterCallback: (double newValue) { /// return '${newValue.round()} dollars'; /// } /// ) /// ``` /// {@end-tool} final SemanticFormatterCallback? semanticFormatterCallback; // Touch width for the tap boundary of the slider thumbs. static const double _minTouchTargetWidth = kMinInteractiveDimension; @override State createState() => _RangeSliderState(); @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); properties.add(DoubleProperty('valueStart', values.start)); properties.add(DoubleProperty('valueEnd', values.end)); properties.add(ObjectFlagProperty>('onChanged', onChanged, ifNull: 'disabled')); properties.add(ObjectFlagProperty>.has('onChangeStart', onChangeStart)); properties.add(ObjectFlagProperty>.has('onChangeEnd', onChangeEnd)); properties.add(DoubleProperty('min', min)); properties.add(DoubleProperty('max', max)); properties.add(IntProperty('divisions', divisions)); properties.add(StringProperty('labelStart', labels?.start)); properties.add(StringProperty('labelEnd', labels?.end)); properties.add(ColorProperty('activeColor', activeColor)); properties.add(ColorProperty('inactiveColor', inactiveColor)); properties.add(ObjectFlagProperty>.has('semanticFormatterCallback', semanticFormatterCallback)); } } class _RangeSliderState extends State 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) // changes visibility in response to user interaction. late AnimationController overlayController; // Animation controller that is run when the value indicators change visibility. late AnimationController valueIndicatorController; // Animation controller that is run when enabling/disabling the slider. late AnimationController enableController; // Animation controllers that are run when transitioning between one value // and the next on a discrete slider. late AnimationController startPositionController; late AnimationController endPositionController; Timer? interactionTimer; // Value Indicator paint Animation that appears on the Overlay. PaintRangeValueIndicator? paintTopValueIndicator; PaintRangeValueIndicator? paintBottomValueIndicator; bool get _enabled => widget.onChanged != null; bool _dragging = false; bool _hovering = false; void _handleHoverChanged(bool hovering) { if (hovering != _hovering) { setState(() { _hovering = hovering; }); } } @override void initState() { super.initState(); overlayController = AnimationController( duration: kRadialReactionDuration, vsync: this, ); valueIndicatorController = AnimationController( duration: valueIndicatorAnimationDuration, vsync: this, ); enableController = AnimationController( duration: enableAnimationDuration, vsync: this, value: _enabled ? 1.0 : 0.0, ); startPositionController = AnimationController( duration: Duration.zero, vsync: this, value: _unlerp(widget.values.start), ); endPositionController = AnimationController( duration: Duration.zero, vsync: this, value: _unlerp(widget.values.end), ); } @override void didUpdateWidget(RangeSlider oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.onChanged == widget.onChanged) { return; } final bool wasEnabled = oldWidget.onChanged != null; final bool isEnabled = _enabled; if (wasEnabled != isEnabled) { if (isEnabled) { enableController.forward(); } else { enableController.reverse(); } } } @override void dispose() { interactionTimer?.cancel(); overlayController.dispose(); valueIndicatorController.dispose(); enableController.dispose(); startPositionController.dispose(); endPositionController.dispose(); if (overlayEntry != null) { overlayEntry!.remove(); overlayEntry = null; } super.dispose(); } void _handleChanged(RangeValues values) { assert(_enabled); final RangeValues lerpValues = _lerpRangeValues(values); if (lerpValues != widget.values) { widget.onChanged!(lerpValues); } } void _handleDragStart(RangeValues values) { assert(widget.onChangeStart != null); _dragging = true; widget.onChangeStart!(_lerpRangeValues(values)); } void _handleDragEnd(RangeValues values) { assert(widget.onChangeEnd != null); _dragging = false; widget.onChangeEnd!(_lerpRangeValues(values)); } // Returns a number between min and max, proportional to value, which must // be between 0.0 and 1.0. double _lerp(double value) => ui.lerpDouble(widget.min, widget.max, value)!; // Returns a new range value with the start and end lerped. RangeValues _lerpRangeValues(RangeValues values) { return RangeValues(_lerp(values.start), _lerp(values.end)); } // 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; } // Returns a new range value with the start and end unlerped. RangeValues _unlerpRangeValues(RangeValues values) { return RangeValues(_unlerp(values.start), _unlerp(values.end)); } // Finds closest thumb. If the thumbs are close to each other, no thumb is // immediately selected while the drag displacement is zero. If the first // non-zero displacement is negative, then the left thumb is selected, and if its // positive, then the right thumb is selected. Thumb? _defaultRangeThumbSelector( TextDirection textDirection, RangeValues values, double tapValue, Size thumbSize, Size trackSize, double dx, // The horizontal delta or displacement of the drag update. ) { final double touchRadius = math.max(thumbSize.width, RangeSlider._minTouchTargetWidth) / 2; final bool inStartTouchTarget = (tapValue - values.start).abs() * trackSize.width < touchRadius; final bool inEndTouchTarget = (tapValue - values.end).abs() * trackSize.width < touchRadius; // Use dx if the thumb touch targets overlap. If dx is 0 and the drag // position is in both touch targets, no thumb is selected because it is // ambiguous to which thumb should be selected. If the dx is non-zero, the // thumb selection is determined by the direction of the dx. The left thumb // is chosen for negative dx, and the right thumb is chosen for positive dx. if (inStartTouchTarget && inEndTouchTarget) { final bool towardsStart; final bool towardsEnd; switch (textDirection) { case TextDirection.ltr: towardsStart = dx < 0; towardsEnd = dx > 0; break; case TextDirection.rtl: towardsStart = dx > 0; towardsEnd = dx < 0; break; } if (towardsStart) { return Thumb.start; } if (towardsEnd) { return Thumb.end; } } else { // Snap position on the track if its in the inactive range. if (tapValue < values.start || inStartTouchTarget) { return Thumb.start; } if (tapValue > values.end || inEndTouchTarget) { return Thumb.end; } } return null; } @override Widget build(BuildContext context) { assert(debugCheckHasMaterial(context)); assert(debugCheckHasMediaQuery(context)); final ThemeData theme = Theme.of(context); SliderThemeData sliderTheme = SliderTheme.of(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 double defaultTrackHeight = 4; const RangeSliderTrackShape defaultTrackShape = RoundedRectRangeSliderTrackShape(); const RangeSliderTickMarkShape defaultTickMarkShape = RoundRangeSliderTickMarkShape(); const SliderComponentShape defaultOverlayShape = RoundSliderOverlayShape(); const RangeSliderThumbShape defaultThumbShape = RoundRangeSliderThumbShape(); const RangeSliderValueIndicatorShape defaultValueIndicatorShape = RectangularRangeSliderValueIndicatorShape(); const ShowValueIndicator defaultShowValueIndicator = ShowValueIndicator.onlyForDiscrete; const double defaultMinThumbSeparation = 8; final Set states = { if (!_enabled) MaterialState.disabled, if (_hovering) MaterialState.hovered, 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 RangeSliderValueIndicatorShape valueIndicatorShape = sliderTheme.rangeValueIndicatorShape ?? defaultValueIndicatorShape; final Color valueIndicatorColor; if (valueIndicatorShape is RectangularRangeSliderValueIndicatorShape) { 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(sliderTheme.overlayColor, states) ?? theme.colorScheme.primary.withOpacity(0.12); } sliderTheme = sliderTheme.copyWith( trackHeight: sliderTheme.trackHeight ?? defaultTrackHeight, activeTrackColor: widget.activeColor ?? sliderTheme.activeTrackColor ?? theme.colorScheme.primary, inactiveTrackColor: widget.inactiveColor ?? sliderTheme.inactiveTrackColor ?? theme.colorScheme.primary.withOpacity(0.24), disabledActiveTrackColor: sliderTheme.disabledActiveTrackColor ?? theme.colorScheme.onSurface.withOpacity(0.32), disabledInactiveTrackColor: sliderTheme.disabledInactiveTrackColor ?? theme.colorScheme.onSurface.withOpacity(0.12), activeTickMarkColor: widget.inactiveColor ?? sliderTheme.activeTickMarkColor ?? theme.colorScheme.onPrimary.withOpacity(0.54), inactiveTickMarkColor: widget.activeColor ?? sliderTheme.inactiveTickMarkColor ?? theme.colorScheme.primary.withOpacity(0.54), disabledActiveTickMarkColor: sliderTheme.disabledActiveTickMarkColor ?? theme.colorScheme.onPrimary.withOpacity(0.12), disabledInactiveTickMarkColor: sliderTheme.disabledInactiveTickMarkColor ?? theme.colorScheme.onSurface.withOpacity(0.12), thumbColor: widget.activeColor ?? sliderTheme.thumbColor ?? theme.colorScheme.primary, overlappingShapeStrokeColor: sliderTheme.overlappingShapeStrokeColor ?? theme.colorScheme.surface, disabledThumbColor: sliderTheme.disabledThumbColor ?? Color.alphaBlend(theme.colorScheme.onSurface.withOpacity(.38), theme.colorScheme.surface), overlayColor: effectiveOverlayColor(), valueIndicatorColor: valueIndicatorColor, rangeTrackShape: sliderTheme.rangeTrackShape ?? defaultTrackShape, rangeTickMarkShape: sliderTheme.rangeTickMarkShape ?? defaultTickMarkShape, rangeThumbShape: sliderTheme.rangeThumbShape ?? defaultThumbShape, overlayShape: sliderTheme.overlayShape ?? defaultOverlayShape, rangeValueIndicatorShape: valueIndicatorShape, showValueIndicator: sliderTheme.showValueIndicator ?? defaultShowValueIndicator, valueIndicatorTextStyle: sliderTheme.valueIndicatorTextStyle ?? theme.textTheme.bodyLarge!.copyWith( color: theme.colorScheme.onPrimary, ), minThumbSeparation: sliderTheme.minThumbSeparation ?? defaultMinThumbSeparation, thumbSelector: sliderTheme.thumbSelector ?? _defaultRangeThumbSelector, ); final MouseCursor effectiveMouseCursor = widget.mouseCursor?.resolve(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 slider.dart. Size screenSize() => MediaQuery.sizeOf(context); return FocusableActionDetector( enabled: _enabled, onShowHoverHighlight: _handleHoverChanged, includeFocusSemantics: false, mouseCursor: effectiveMouseCursor, child: CompositedTransformTarget( link: _layerLink, child: _RangeSliderRenderObjectWidget( values: _unlerpRangeValues(widget.values), divisions: widget.divisions, labels: widget.labels, sliderTheme: sliderTheme, textScaleFactor: MediaQuery.of(context).textScaleFactor, screenSize: screenSize(), onChanged: _enabled && (widget.max > widget.min) ? _handleChanged : null, onChangeStart: widget.onChangeStart != null ? _handleDragStart : null, onChangeEnd: widget.onChangeEnd != null ? _handleDragEnd : null, state: this, semanticFormatterCallback: widget.semanticFormatterCallback, hovering: _hovering, ), ), ); } 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 _RangeSliderRenderObjectWidget extends LeafRenderObjectWidget { const _RangeSliderRenderObjectWidget({ required this.values, required this.divisions, required this.labels, 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.hovering, }); final RangeValues values; final int? divisions; final RangeLabels? labels; final SliderThemeData sliderTheme; final double textScaleFactor; final Size screenSize; final ValueChanged? onChanged; final ValueChanged? onChangeStart; final ValueChanged? onChangeEnd; final SemanticFormatterCallback? semanticFormatterCallback; final _RangeSliderState state; final bool hovering; @override _RenderRangeSlider createRenderObject(BuildContext context) { return _RenderRangeSlider( values: values, divisions: divisions, labels: labels, sliderTheme: sliderTheme, theme: Theme.of(context), textScaleFactor: textScaleFactor, screenSize: screenSize, onChanged: onChanged, onChangeStart: onChangeStart, onChangeEnd: onChangeEnd, state: state, textDirection: Directionality.of(context), semanticFormatterCallback: semanticFormatterCallback, platform: Theme.of(context).platform, hovering: hovering, gestureSettings: MediaQuery.gestureSettingsOf(context), ); } @override void updateRenderObject(BuildContext context, _RenderRangeSlider renderObject) { renderObject // We should update the `divisions` ahead of `values`, because the `values` // setter dependent on the `divisions`. ..divisions = divisions ..values = values ..labels = labels ..sliderTheme = sliderTheme ..theme = Theme.of(context) ..textScaleFactor = textScaleFactor ..screenSize = screenSize ..onChanged = onChanged ..onChangeStart = onChangeStart ..onChangeEnd = onChangeEnd ..textDirection = Directionality.of(context) ..semanticFormatterCallback = semanticFormatterCallback ..platform = Theme.of(context).platform ..hovering = hovering ..gestureSettings = MediaQuery.gestureSettingsOf(context); } } class _RenderRangeSlider extends RenderBox with RelayoutWhenSystemFontsChangeMixin { _RenderRangeSlider({ required RangeValues values, required int? divisions, required RangeLabels? labels, required SliderThemeData sliderTheme, required ThemeData? theme, required double textScaleFactor, required Size screenSize, required TargetPlatform platform, required ValueChanged? onChanged, required SemanticFormatterCallback? semanticFormatterCallback, required this.onChangeStart, required this.onChangeEnd, required _RangeSliderState state, required TextDirection textDirection, required bool hovering, required DeviceGestureSettings gestureSettings, }) : assert(values.start >= 0.0 && values.start <= 1.0), assert(values.end >= 0.0 && values.end <= 1.0), _platform = platform, _semanticFormatterCallback = semanticFormatterCallback, _labels = labels, _values = values, _divisions = divisions, _sliderTheme = sliderTheme, _theme = theme, _textScaleFactor = textScaleFactor, _screenSize = screenSize, _onChanged = onChanged, _state = state, _textDirection = textDirection, _hovering = hovering { _updateLabelPainters(); final GestureArenaTeam team = GestureArenaTeam(); _drag = HorizontalDragGestureRecognizer() ..team = team ..onStart = _handleDragStart ..onUpdate = _handleDragUpdate ..onEnd = _handleDragEnd ..onCancel = _handleDragCancel ..gestureSettings = gestureSettings; _tap = TapGestureRecognizer() ..team = team ..onTapDown = _handleTapDown ..onTapUp = _handleTapUp ..onTapCancel = _handleTapCancel ..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, ); } // Keep track of the last selected thumb so they can be drawn in the // right order. Thumb? _lastThumbSelection; static const Duration _positionAnimationDuration = Duration(milliseconds: 75); // 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 get _sliderPartSizes => [ _sliderTheme.overlayShape!.getPreferredSize(isEnabled, isDiscrete), _sliderTheme.rangeThumbShape!.getPreferredSize(isEnabled, isDiscrete), _sliderTheme.rangeTickMarkShape!.getPreferredSize(isEnabled: isEnabled, sliderTheme: sliderTheme), ]; double? get _minPreferredTrackHeight => _sliderTheme.trackHeight; // 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.rangeTrackShape!.getPreferredRect( parentBox: this, sliderTheme: _sliderTheme, isDiscrete: false, ); static const Duration _minimumInteractionTime = Duration(milliseconds: 500); final _RangeSliderState _state; late Animation _overlayAnimation; late Animation _valueIndicatorAnimation; late Animation _enableAnimation; final TextPainter _startLabelPainter = TextPainter(); final TextPainter _endLabelPainter = TextPainter(); late HorizontalDragGestureRecognizer _drag; late TapGestureRecognizer _tap; bool _active = false; late RangeValues _newValues; late Offset _startThumbCenter; late Offset _endThumbCenter; Rect? overlayStartRect; Rect? overlayEndRect; bool get isEnabled => onChanged != null; bool get isDiscrete => divisions != null && divisions! > 0; double get _minThumbSeparationValue => isDiscrete ? 0 : sliderTheme.minThumbSeparation! / _trackRect.width; RangeValues get values => _values; RangeValues _values; set values(RangeValues newValues) { assert(newValues.start >= 0.0 && newValues.start <= 1.0); assert(newValues.end >= 0.0 && newValues.end <= 1.0); assert(newValues.start <= newValues.end); final RangeValues convertedValues = isDiscrete ? _discretizeRangeValues(newValues) : newValues; if (convertedValues == _values) { return; } _values = convertedValues; 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 startDistance = (_values.start - _state.startPositionController.value).abs(); _state.startPositionController.duration = startDistance != 0.0 ? _positionAnimationDuration * (1.0 / startDistance) : Duration.zero; _state.startPositionController.animateTo(_values.start, curve: Curves.easeInOut); final double endDistance = (_values.end - _state.endPositionController.value).abs(); _state.endPositionController.duration = endDistance != 0.0 ? _positionAnimationDuration * (1.0 / endDistance) : Duration.zero; _state.endPositionController.animateTo(_values.end, curve: Curves.easeInOut); } else { _state.startPositionController.value = convertedValues.start; _state.endPositionController.value = convertedValues.end; } markNeedsSemanticsUpdate(); } TargetPlatform _platform; TargetPlatform get platform => _platform; set platform(TargetPlatform value) { if (_platform == value) { return; } _platform = value; markNeedsSemanticsUpdate(); } DeviceGestureSettings? get gestureSettings => _drag.gestureSettings; set gestureSettings(DeviceGestureSettings? gestureSettings) { _drag.gestureSettings = gestureSettings; _tap.gestureSettings = gestureSettings; } 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(); } RangeLabels? get labels => _labels; RangeLabels? _labels; set labels(RangeLabels? labels) { if (labels == _labels) { return; } _labels = labels; _updateLabelPainters(); } SliderThemeData get sliderTheme => _sliderTheme; SliderThemeData _sliderTheme; set sliderTheme(SliderThemeData value) { if (value == _sliderTheme) { return; } _sliderTheme = value; markNeedsPaint(); } ThemeData? get theme => _theme; ThemeData? _theme; set theme(ThemeData? value) { if (value == _theme) { return; } _theme = value; markNeedsPaint(); } double get textScaleFactor => _textScaleFactor; double _textScaleFactor; set textScaleFactor(double value) { if (value == _textScaleFactor) { return; } _textScaleFactor = value; _updateLabelPainters(); } Size get screenSize => _screenSize; Size _screenSize; set screenSize(Size value) { if (value == screenSize) { return; } _screenSize = value; markNeedsPaint(); } ValueChanged? get onChanged => _onChanged; ValueChanged? _onChanged; set onChanged(ValueChanged? value) { if (value == _onChanged) { return; } final bool wasEnabled = isEnabled; _onChanged = value; if (wasEnabled != isEnabled) { markNeedsPaint(); markNeedsSemanticsUpdate(); } } ValueChanged? onChangeStart; ValueChanged? onChangeEnd; TextDirection get textDirection => _textDirection; TextDirection _textDirection; set textDirection(TextDirection value) { if (value == _textDirection) { return; } _textDirection = value; _updateLabelPainters(); } /// 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 start thumb is being /// hovered over by a pointer. bool _hoveringStartThumb = false; bool get hoveringStartThumb => _hoveringStartThumb; set hoveringStartThumb(bool value) { if (value == _hoveringStartThumb) { return; } _hoveringStartThumb = value; _updateForHover(_hovering); } /// True if the slider is interactive and the end thumb is being /// hovered over by a pointer. bool _hoveringEndThumb = false; bool get hoveringEndThumb => _hoveringEndThumb; set hoveringEndThumb(bool value) { if (value == _hoveringEndThumb) { return; } _hoveringEndThumb = value; _updateForHover(_hovering); } void _updateForHover(bool hovered) { // Only show overlay when pointer is hovering the thumb. if (hovered && (hoveringStartThumb || hoveringEndThumb)) { _state.overlayController.forward(); } else { _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; } } Size get _thumbSize => _sliderTheme.rangeThumbShape!.getPreferredSize(isEnabled, isDiscrete); double get _adjustmentUnit { switch (_platform) { case TargetPlatform.iOS: // Matches iOS implementation of material slider. return 0.1; case TargetPlatform.android: case TargetPlatform.fuchsia: case TargetPlatform.linux: case TargetPlatform.macOS: case TargetPlatform.windows: // Matches Android implementation of material slider. return 0.05; } } void _updateLabelPainters() { _updateLabelPainter(Thumb.start); _updateLabelPainter(Thumb.end); } void _updateLabelPainter(Thumb thumb) { if (labels == null) { return; } final String text; final TextPainter labelPainter; switch (thumb) { case Thumb.start: text = labels!.start; labelPainter = _startLabelPainter; break; case Thumb.end: text = labels!.end; labelPainter = _endLabelPainter; break; } if (labels != null) { labelPainter ..text = TextSpan( style: _sliderTheme.valueIndicatorTextStyle, text: text, ) ..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(); _startLabelPainter.markNeedsLayout(); _endLabelPainter.markNeedsLayout(); _updateLabelPainters(); } @override void attach(PipelineOwner owner) { super.attach(owner); _overlayAnimation.addListener(markNeedsPaint); _valueIndicatorAnimation.addListener(markNeedsPaint); _enableAnimation.addListener(markNeedsPaint); _state.startPositionController.addListener(markNeedsPaint); _state.endPositionController.addListener(markNeedsPaint); } @override void detach() { _overlayAnimation.removeListener(markNeedsPaint); _valueIndicatorAnimation.removeListener(markNeedsPaint); _enableAnimation.removeListener(markNeedsPaint); _state.startPositionController.removeListener(markNeedsPaint); _state.endPositionController.removeListener(markNeedsPaint); super.detach(); } @override void dispose() { _startLabelPainter.dispose(); _endLabelPainter.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; } RangeValues _discretizeRangeValues(RangeValues values) { return RangeValues(_discretize(values.start), _discretize(values.end)); } void _startInteraction(Offset globalPosition) { _state.showValueIndicator(); final double tapValue = clampDouble(_getValueFromGlobalPosition(globalPosition), 0.0, 1.0); _lastThumbSelection = sliderTheme.thumbSelector!(textDirection, values, tapValue, _thumbSize, size, 0); if (_lastThumbSelection != null) { _active = true; // We supply the *current* values as the start locations, 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. final RangeValues currentValues = _discretizeRangeValues(values); if (_lastThumbSelection == Thumb.start) { _newValues = RangeValues(tapValue, currentValues.end); } else if (_lastThumbSelection == Thumb.end) { _newValues = RangeValues(currentValues.start, tapValue); } _updateLabelPainter(_lastThumbSelection!); onChangeStart?.call(currentValues); onChanged!(_discretizeRangeValues(_newValues)); _state.overlayController.forward(); if (showValueIndicator) { _state.valueIndicatorController.forward(); _state.interactionTimer?.cancel(); _state.interactionTimer = Timer(_minimumInteractionTime * timeDilation, () { _state.interactionTimer = null; if (!_active && _state.valueIndicatorController.status == AnimationStatus.completed) { _state.valueIndicatorController.reverse(); } }); } } } void _handleDragUpdate(DragUpdateDetails details) { if (!_state.mounted) { return; } final double dragValue = _getValueFromGlobalPosition(details.globalPosition); // If no selection has been made yet, test for thumb selection again now // that the value of dx can be non-zero. If this is the first selection of // the interaction, then onChangeStart must be called. bool shouldCallOnChangeStart = false; if (_lastThumbSelection == null) { _lastThumbSelection = sliderTheme.thumbSelector!(textDirection, values, dragValue, _thumbSize, size, details.delta.dx); if (_lastThumbSelection != null) { shouldCallOnChangeStart = true; _active = true; _state.overlayController.forward(); if (showValueIndicator) { _state.valueIndicatorController.forward(); } } } if (isEnabled && _lastThumbSelection != null) { final RangeValues currentValues = _discretizeRangeValues(values); if (onChangeStart != null && shouldCallOnChangeStart) { onChangeStart!(currentValues); } final double currentDragValue = _discretize(dragValue); if (_lastThumbSelection == Thumb.start) { _newValues = RangeValues(math.min(currentDragValue, currentValues.end - _minThumbSeparationValue), currentValues.end); } else if (_lastThumbSelection == Thumb.end) { _newValues = RangeValues(currentValues.start, math.max(currentDragValue, currentValues.start + _minThumbSeparationValue)); } onChanged!(_newValues); } } void _endInteraction() { if (!_state.mounted) { return; } if (showValueIndicator && _state.interactionTimer == null) { _state.valueIndicatorController.reverse(); } if (_active && _state.mounted && _lastThumbSelection != null) { final RangeValues discreteValues = _discretizeRangeValues(_newValues); onChangeEnd?.call(discreteValues); _active = false; } _state.overlayController.reverse(); } void _handleDragStart(DragStartDetails details) { _startInteraction(details.globalPosition); } void _handleDragEnd(DragEndDetails details) { _endInteraction(); } void _handleDragCancel() { _endInteraction(); } void _handleTapDown(TapDownDetails details) { _startInteraction(details.globalPosition); } void _handleTapUp(TapUpDetails details) { _endInteraction(); } void _handleTapCancel() { _endInteraction(); } @override bool hitTestSelf(Offset position) => true; @override void handleEvent(PointerEvent event, HitTestEntry entry) { assert(debugHandleEvent(event, entry)); if (event is PointerDownEvent && isEnabled) { // We need to add the drag first so that it has priority. _drag.addPointer(event); _tap.addPointer(event); } if (isEnabled) { if (overlayStartRect != null) { hoveringStartThumb = overlayStartRect!.contains(event.localPosition); } if (overlayEndRect != null) { hoveringEndThumb = overlayEndRect!.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 startValue = _state.startPositionController.value; final double endValue = _state.endPositionController.value; // 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 startVisualPosition; final double endVisualPosition; switch (textDirection) { case TextDirection.rtl: startVisualPosition = 1.0 - startValue; endVisualPosition = 1.0 - endValue; break; case TextDirection.ltr: startVisualPosition = startValue; endVisualPosition = endValue; break; } final Rect trackRect = _sliderTheme.rangeTrackShape!.getPreferredRect( parentBox: this, offset: offset, sliderTheme: _sliderTheme, isDiscrete: isDiscrete, ); _startThumbCenter = Offset(trackRect.left + startVisualPosition * trackRect.width, trackRect.center.dy); _endThumbCenter = Offset(trackRect.left + endVisualPosition * trackRect.width, trackRect.center.dy); if (isEnabled) { final Size overlaySize = sliderTheme.overlayShape!.getPreferredSize(isEnabled, false); overlayStartRect = Rect.fromCircle(center: _startThumbCenter, radius: overlaySize.width / 2.0); overlayEndRect = Rect.fromCircle(center: _endThumbCenter, radius: overlaySize.width / 2.0); } _sliderTheme.rangeTrackShape!.paint( context, offset, parentBox: this, sliderTheme: _sliderTheme, enableAnimation: _enableAnimation, textDirection: _textDirection, startThumbCenter: _startThumbCenter, endThumbCenter: _endThumbCenter, isDiscrete: isDiscrete, isEnabled: isEnabled, ); final bool startThumbSelected = _lastThumbSelection == Thumb.start; final bool endThumbSelected = _lastThumbSelection == Thumb.end; final Size resolvedscreenSize = screenSize.isEmpty ? size : screenSize; if (!_overlayAnimation.isDismissed) { if (startThumbSelected || hoveringStartThumb) { _sliderTheme.overlayShape!.paint( context, _startThumbCenter, activationAnimation: _overlayAnimation, enableAnimation: _enableAnimation, isDiscrete: isDiscrete, labelPainter: _startLabelPainter, parentBox: this, sliderTheme: _sliderTheme, textDirection: _textDirection, value: startValue, textScaleFactor: _textScaleFactor, sizeWithOverflow: resolvedscreenSize, ); } if (endThumbSelected || hoveringEndThumb) { _sliderTheme.overlayShape!.paint( context, _endThumbCenter, activationAnimation: _overlayAnimation, enableAnimation: _enableAnimation, isDiscrete: isDiscrete, labelPainter: _endLabelPainter, parentBox: this, sliderTheme: _sliderTheme, textDirection: _textDirection, value: endValue, textScaleFactor: _textScaleFactor, sizeWithOverflow: resolvedscreenSize, ); } } if (isDiscrete) { final double tickMarkWidth = _sliderTheme.rangeTickMarkShape!.getPreferredSize( isEnabled: isEnabled, 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.rangeTickMarkShape!.paint( context, tickMarkOffset, parentBox: this, sliderTheme: _sliderTheme, enableAnimation: _enableAnimation, textDirection: _textDirection, startThumbCenter: _startThumbCenter, endThumbCenter: _endThumbCenter, isEnabled: isEnabled, ); } } } final double thumbDelta = (_endThumbCenter.dx - _startThumbCenter.dx).abs(); final bool isLastThumbStart = _lastThumbSelection == Thumb.start; final Thumb bottomThumb = isLastThumbStart ? Thumb.end : Thumb.start; final Thumb topThumb = isLastThumbStart ? Thumb.start : Thumb.end; final Offset bottomThumbCenter = isLastThumbStart ? _endThumbCenter : _startThumbCenter; final Offset topThumbCenter = isLastThumbStart ? _startThumbCenter : _endThumbCenter; final TextPainter bottomLabelPainter = isLastThumbStart ? _endLabelPainter : _startLabelPainter; final TextPainter topLabelPainter = isLastThumbStart ? _startLabelPainter : _endLabelPainter; final double bottomValue = isLastThumbStart ? endValue : startValue; final double topValue = isLastThumbStart ? startValue : endValue; final bool shouldPaintValueIndicators = isEnabled && labels != null && !_valueIndicatorAnimation.isDismissed && showValueIndicator; if (shouldPaintValueIndicators) { _state.paintBottomValueIndicator = (PaintingContext context, Offset offset) { if (attached) { _sliderTheme.rangeValueIndicatorShape!.paint( context, bottomThumbCenter, activationAnimation: _valueIndicatorAnimation, enableAnimation: _enableAnimation, isDiscrete: isDiscrete, isOnTop: false, labelPainter: bottomLabelPainter, parentBox: this, sliderTheme: _sliderTheme, textDirection: _textDirection, thumb: bottomThumb, value: bottomValue, textScaleFactor: textScaleFactor, sizeWithOverflow: resolvedscreenSize, ); } }; } _sliderTheme.rangeThumbShape!.paint( context, bottomThumbCenter, activationAnimation: _valueIndicatorAnimation, enableAnimation: _enableAnimation, isDiscrete: isDiscrete, isOnTop: false, textDirection: textDirection, sliderTheme: _sliderTheme, thumb: bottomThumb, isPressed: bottomThumb == Thumb.start ? startThumbSelected : endThumbSelected, ); if (shouldPaintValueIndicators) { final double startOffset = sliderTheme.rangeValueIndicatorShape!.getHorizontalShift( parentBox: this, center: _startThumbCenter, labelPainter: _startLabelPainter, activationAnimation: _valueIndicatorAnimation, textScaleFactor: textScaleFactor, sizeWithOverflow: resolvedscreenSize, ); final double endOffset = sliderTheme.rangeValueIndicatorShape!.getHorizontalShift( parentBox: this, center: _endThumbCenter, labelPainter: _endLabelPainter, activationAnimation: _valueIndicatorAnimation, textScaleFactor: textScaleFactor, sizeWithOverflow: resolvedscreenSize, ); final double startHalfWidth = sliderTheme.rangeValueIndicatorShape!.getPreferredSize( isEnabled, isDiscrete, labelPainter: _startLabelPainter, textScaleFactor: textScaleFactor, ).width / 2; final double endHalfWidth = sliderTheme.rangeValueIndicatorShape!.getPreferredSize( isEnabled, isDiscrete, labelPainter: _endLabelPainter, textScaleFactor: textScaleFactor, ).width / 2; double innerOverflow = startHalfWidth + endHalfWidth; switch (textDirection) { case TextDirection.ltr: innerOverflow += startOffset; innerOverflow -= endOffset; break; case TextDirection.rtl: innerOverflow -= startOffset; innerOverflow += endOffset; break; } _state.paintTopValueIndicator = (PaintingContext context, Offset offset) { if (attached) { _sliderTheme.rangeValueIndicatorShape!.paint( context, topThumbCenter, activationAnimation: _valueIndicatorAnimation, enableAnimation: _enableAnimation, isDiscrete: isDiscrete, isOnTop: thumbDelta < innerOverflow, labelPainter: topLabelPainter, parentBox: this, sliderTheme: _sliderTheme, textDirection: _textDirection, thumb: topThumb, value: topValue, textScaleFactor: textScaleFactor, sizeWithOverflow: resolvedscreenSize, ); } }; } _sliderTheme.rangeThumbShape!.paint( context, topThumbCenter, activationAnimation: _overlayAnimation, enableAnimation: _enableAnimation, isDiscrete: isDiscrete, isOnTop: thumbDelta < sliderTheme.rangeThumbShape!.getPreferredSize(isEnabled, isDiscrete).width, textDirection: textDirection, sliderTheme: _sliderTheme, thumb: topThumb, isPressed: topThumb == Thumb.start ? startThumbSelected : endThumbSelected, ); } /// Describe the semantics of the start thumb. SemanticsNode? _startSemanticsNode = SemanticsNode(); /// Describe the semantics of the end thumb. SemanticsNode? _endSemanticsNode = SemanticsNode(); // Create the semantics configuration for a single value. SemanticsConfiguration _createSemanticsConfiguration( double value, double increasedValue, double decreasedValue, VoidCallback increaseAction, VoidCallback decreaseAction, ) { final SemanticsConfiguration config = SemanticsConfiguration(); config.isEnabled = isEnabled; config.textDirection = textDirection; config.isSlider = true; if (isEnabled) { config.onIncrease = increaseAction; config.onDecrease = decreaseAction; } if (semanticFormatterCallback != null) { config.value = semanticFormatterCallback!(_state._lerp(value)); config.increasedValue = semanticFormatterCallback!(_state._lerp(increasedValue)); config.decreasedValue = semanticFormatterCallback!(_state._lerp(decreasedValue)); } else { config.value = '${(value * 100).round()}%'; config.increasedValue = '${(increasedValue * 100).round()}%'; config.decreasedValue = '${(decreasedValue * 100).round()}%'; } return config; } @override void assembleSemanticsNode( SemanticsNode node, SemanticsConfiguration config, Iterable children, ) { assert(children.isEmpty); final SemanticsConfiguration startSemanticsConfiguration = _createSemanticsConfiguration( values.start, _increasedStartValue, _decreasedStartValue, _increaseStartAction, _decreaseStartAction, ); final SemanticsConfiguration endSemanticsConfiguration = _createSemanticsConfiguration( values.end, _increasedEndValue, _decreasedEndValue, _increaseEndAction, _decreaseEndAction, ); // Split the semantics node area between the start and end nodes. final Rect leftRect = Rect.fromCenter( center: _startThumbCenter, width: kMinInteractiveDimension, height: kMinInteractiveDimension, ); final Rect rightRect = Rect.fromCenter( center: _endThumbCenter, width: kMinInteractiveDimension, height: kMinInteractiveDimension, ); switch (textDirection) { case TextDirection.ltr: _startSemanticsNode!.rect = leftRect; _endSemanticsNode!.rect = rightRect; break; case TextDirection.rtl: _startSemanticsNode!.rect = rightRect; _endSemanticsNode!.rect = leftRect; break; } _startSemanticsNode!.updateWith(config: startSemanticsConfiguration); _endSemanticsNode!.updateWith(config: endSemanticsConfiguration); final List finalChildren = [ _startSemanticsNode!, _endSemanticsNode!, ]; node.updateWith(config: config, childrenInInversePaintOrder: finalChildren); } @override void clearSemantics() { super.clearSemantics(); _startSemanticsNode = null; _endSemanticsNode = null; } @override void describeSemanticsConfiguration(SemanticsConfiguration config) { super.describeSemanticsConfiguration(config); config.isSemanticBoundary = true; } double get _semanticActionUnit => divisions != null ? 1.0 / divisions! : _adjustmentUnit; void _increaseStartAction() { if (isEnabled) { onChanged!(RangeValues(_increasedStartValue, values.end)); } } void _decreaseStartAction() { if (isEnabled) { onChanged!(RangeValues(_decreasedStartValue, values.end)); } } void _increaseEndAction() { if (isEnabled) { onChanged!(RangeValues(values.start, _increasedEndValue)); } } void _decreaseEndAction() { if (isEnabled) { onChanged!(RangeValues(values.start, _decreasedEndValue)); } } double get _increasedStartValue { // Due to floating-point operations, this value can actually be greater than // expected (e.g. 0.4 + 0.2 = 0.600000000001), so we limit to 2 decimal points. final double increasedStartValue = double.parse((values.start + _semanticActionUnit).toStringAsFixed(2)); return increasedStartValue <= values.end - _minThumbSeparationValue ? increasedStartValue : values.start; } double get _decreasedStartValue { return clampDouble(values.start - _semanticActionUnit, 0.0, 1.0); } double get _increasedEndValue { return clampDouble(values.end + _semanticActionUnit, 0.0, 1.0); } double get _decreasedEndValue { final double decreasedEndValue = values.end - _semanticActionUnit; return decreasedEndValue >= values.start + _minThumbSeparationValue ? decreasedEndValue : values.end; } } class _ValueIndicatorRenderObjectWidget extends LeafRenderObjectWidget { const _ValueIndicatorRenderObjectWidget({ required this.state, }); final _RangeSliderState 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 _RangeSliderState state, }) :_state = state { _valueIndicatorAnimation = CurvedAnimation( parent: _state.valueIndicatorController, curve: Curves.fastOutSlowIn, ); } late Animation _valueIndicatorAnimation; late _RangeSliderState _state; @override bool get sizedByParent => true; @override void attach(PipelineOwner owner) { super.attach(owner); _valueIndicatorAnimation.addListener(markNeedsPaint); _state.startPositionController.addListener(markNeedsPaint); _state.endPositionController.addListener(markNeedsPaint); } @override void detach() { _valueIndicatorAnimation.removeListener(markNeedsPaint); _state.startPositionController.removeListener(markNeedsPaint); _state.endPositionController.removeListener(markNeedsPaint); super.detach(); } @override void paint(PaintingContext context, Offset offset) { _state.paintBottomValueIndicator?.call(context, offset); _state.paintTopValueIndicator?.call(context, offset); } @override Size computeDryLayout(BoxConstraints constraints) { return constraints.smallest; } }