-
Notifications
You must be signed in to change notification settings - Fork 4k
Fix discrete Slider snapping to wrong value when tapping lower-half tick marks #12927
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1294,6 +1294,13 @@ class _RenderSlider extends RenderBox with RelayoutWhenSystemFontsChangeMixin { | |
| isDiscrete: false, | ||
| ); | ||
|
|
||
| // Tick marks and the thumb are painted in an adjusted coordinate space that | ||
| // reserves half a track height of padding on each end for discrete rounded | ||
| // tracks, so gesture math needs to account for that same padding to stay | ||
| // aligned with the visual tick positions. | ||
| double get _discreteRoundedTrackPadding => | ||
| (isDiscrete && _sliderTheme.trackShape!.isRounded) ? _trackRect.height : 0.0; | ||
|
Comment on lines
+1301
to
+1302
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Using the null-assert operator ( double get _discreteRoundedTrackPadding =>
(isDiscrete && (_sliderTheme.trackShape?.isRounded ?? false)) ? _trackRect.height : 0.0; |
||
|
|
||
| bool get isInteractive => onChanged != null; | ||
|
|
||
| bool get isDiscrete => divisions != null && divisions! > 0; | ||
|
|
@@ -1611,8 +1618,16 @@ class _RenderSlider extends RenderBox with RelayoutWhenSystemFontsChangeMixin { | |
| } | ||
|
|
||
| double _getValueFromGlobalPosition(Offset globalPosition) { | ||
| final double visualPosition = | ||
| (globalToLocal(globalPosition).dx - _trackRect.left) / _trackRect.width; | ||
| final double localDx = globalToLocal(globalPosition).dx; | ||
| final double padding = _discreteRoundedTrackPadding; | ||
| final double adjustedWidth = _trackRect.width - padding; | ||
| // Invert the padded track-geometry formula (see _discreteRoundedTrackPadding) | ||
| // so tap coordinates align with the visual tick positions. Without this, the | ||
| // snap midpoints in tap space diverge from the visual midpoints between tick | ||
| // marks, biasing lower-half taps toward the wrong (lower) division. | ||
| final double visualPosition = adjustedWidth <= 0.0 | ||
| ? 0.5 | ||
| : (localDx - _trackRect.left - padding / 2) / adjustedWidth; | ||
| return _getValueFromVisualPosition(visualPosition); | ||
| } | ||
|
|
||
|
|
@@ -1695,7 +1710,13 @@ class _RenderSlider extends RenderBox with RelayoutWhenSystemFontsChangeMixin { | |
| case SliderInteraction.slideOnly: | ||
| case SliderInteraction.slideThumb: | ||
| if (_active && isInteractive) { | ||
| final double valueDelta = details.primaryDelta! / _trackRect.width; | ||
| // Use the same coordinate width as _getValueFromGlobalPosition so | ||
| // that dragging immediately after a tap does not jump. | ||
| final double effectiveWidth = _trackRect.width - _discreteRoundedTrackPadding; | ||
| if (effectiveWidth <= 0.0) { | ||
| break; | ||
| } | ||
| final double valueDelta = details.primaryDelta! / effectiveWidth; | ||
| _currentDragValue += switch (textDirection) { | ||
| TextDirection.rtl => -valueDelta, | ||
| TextDirection.ltr => valueDelta, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| changelog: | | ||
| - Fixes discrete `Slider` and `RangeSlider` snapping to the wrong division when tapping the lower half of a tick mark on a rounded track. | ||
| version: patch |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5712,6 +5712,320 @@ void main() { | |
|
|
||
| await gesture.up(); | ||
| }); | ||
|
|
||
| // Regression tests for https://github.com/flutter/flutter/issues/184391 | ||
| // Discrete Slider taps on tick marks must snap to the correct division. | ||
| // | ||
| // Tick marks are painted at `trackLeft + i/d * adjustedWidth + padding/2` | ||
| // (global coords) but tap detection previously used an unpadded formula, | ||
| // making lower-half tick taps snap to the wrong division. | ||
| // | ||
| // _PositionRecordingTickMarkShape records the actual painted centers so the | ||
| // tests can tap there without replicating the track-geometry math. | ||
| testWidgets( | ||
| 'Discrete Slider tapping tick marks snaps to correct value (M2, LTR)', | ||
| (WidgetTester tester) async { | ||
| const divisions = 10; | ||
| double value = 0; | ||
| final tickPositions = <Offset>[]; | ||
|
|
||
| await tester.pumpWidget( | ||
| MaterialApp( | ||
| theme: ThemeData(useMaterial3: false), | ||
| home: SliderTheme( | ||
| data: SliderThemeData( | ||
| tickMarkShape: _PositionRecordingTickMarkShape(tickPositions), | ||
| ), | ||
| child: StatefulBuilder( | ||
| builder: (BuildContext context, StateSetter setState) { | ||
| return Material( | ||
| child: Center( | ||
| child: Slider( | ||
| max: 10, | ||
| divisions: divisions, | ||
| value: value, | ||
| onChanged: (double v) => setState(() => value = v), | ||
| ), | ||
| ), | ||
| ); | ||
| }, | ||
| ), | ||
| ), | ||
| ), | ||
| ); | ||
|
|
||
| // Snapshot the divisions+1 positions painted on the first frame. | ||
| // (Further pumps after taps add more entries; we ignore those.) | ||
| final List<Offset> tickCenters = tickPositions.take(divisions + 1).toList(); | ||
| expect(tickCenters.length, equals(divisions + 1)); | ||
|
|
||
| // tickCenters[i] is the painted center of the tick at value i. | ||
| // Ticks 1–5 are the lower-half regression cases. | ||
| for (var i = 1; i <= divisions; i++) { | ||
| await tester.tapAt(tickCenters[i]); | ||
| await tester.pump(); | ||
| expect( | ||
| value, | ||
| equals(i.toDouble()), | ||
| reason: 'Tapping tick $i should snap to $i, got $value', | ||
| ); | ||
| } | ||
| }, | ||
| ); | ||
|
|
||
| testWidgets( | ||
| 'Discrete Slider tapping tick marks snaps to correct value (M3, LTR)', | ||
| (WidgetTester tester) async { | ||
| // ThemeData(useMaterial3: true) defaults Slider.year2023 to true, which | ||
| // resolves to _SliderDefaultsM3Year2023 — the same RoundedRectSliderTrackShape | ||
| // and trackHeight as M2, not the year-2023-opt-out GappedSliderTrackShape. | ||
| // This test exercises the same geometry as the M2 case above via a | ||
| // different theme resolution path. | ||
| const divisions = 10; | ||
| double value = 0; | ||
| final tickPositions = <Offset>[]; | ||
|
|
||
| await tester.pumpWidget( | ||
| MaterialApp( | ||
| theme: ThemeData(useMaterial3: true), | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We can remove this line because useMaterial3 is true by default. |
||
| home: SliderTheme( | ||
| data: SliderThemeData( | ||
| tickMarkShape: _PositionRecordingTickMarkShape(tickPositions), | ||
| ), | ||
| child: StatefulBuilder( | ||
| builder: (BuildContext context, StateSetter setState) { | ||
| return Material( | ||
| child: Center( | ||
| child: Slider( | ||
| max: 10, | ||
| divisions: divisions, | ||
| value: value, | ||
| onChanged: (double v) => setState(() => value = v), | ||
| ), | ||
| ), | ||
| ); | ||
| }, | ||
| ), | ||
| ), | ||
| ), | ||
| ); | ||
|
|
||
| final List<Offset> tickCenters = tickPositions.take(divisions + 1).toList(); | ||
| expect(tickCenters.length, equals(divisions + 1)); | ||
|
|
||
| for (var i = 1; i <= divisions; i++) { | ||
| await tester.tapAt(tickCenters[i]); | ||
| await tester.pump(); | ||
| expect( | ||
| value, | ||
| equals(i.toDouble()), | ||
| reason: 'Tapping tick $i (M3) should snap to $i, got $value', | ||
| ); | ||
| } | ||
| }, | ||
| ); | ||
|
|
||
| testWidgets( | ||
| 'Discrete Slider tapping tick marks snaps to correct value (RTL)', | ||
| (WidgetTester tester) async { | ||
| const divisions = 10; | ||
| double value = 0; | ||
| final tickPositions = <Offset>[]; | ||
|
|
||
| await tester.pumpWidget( | ||
| MaterialApp( | ||
| theme: ThemeData(useMaterial3: false), | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Seems this test is only checking M2. M3 might be more important to test since M2 has become a out of date style. We can remove this line to test the default value - M3. |
||
| home: Directionality( | ||
| textDirection: TextDirection.rtl, | ||
| child: SliderTheme( | ||
| data: SliderThemeData( | ||
| tickMarkShape: _PositionRecordingTickMarkShape(tickPositions), | ||
| ), | ||
| child: StatefulBuilder( | ||
| builder: (BuildContext context, StateSetter setState) { | ||
| return Material( | ||
| child: Center( | ||
| child: Slider( | ||
| max: 10, | ||
| divisions: divisions, | ||
| value: value, | ||
| onChanged: (double v) => setState(() => value = v), | ||
| ), | ||
| ), | ||
| ); | ||
| }, | ||
| ), | ||
| ), | ||
| ), | ||
| ), | ||
| ); | ||
|
|
||
| final List<Offset> tickCenters = tickPositions.take(divisions + 1).toList(); | ||
| expect(tickCenters.length, equals(divisions + 1)); | ||
|
|
||
| // The paint loop iterates i=0..d left-to-right in pixel space regardless | ||
| // of text direction, so tickCenters[i] is at visual position i/d from | ||
| // the left. In RTL, visual position i/d = value (divisions - i), so to | ||
| // tap at value v we tap tickCenters[divisions - v]. | ||
| for (var i = 1; i <= divisions; i++) { | ||
| await tester.tapAt(tickCenters[divisions - i]); | ||
| await tester.pump(); | ||
| expect( | ||
| value, | ||
| equals(i.toDouble()), | ||
| reason: 'RTL: tapping pixel index ${divisions - i} should snap to $i, got $value', | ||
| ); | ||
| } | ||
| }, | ||
| ); | ||
|
|
||
| testWidgets( | ||
| 'Discrete Slider tap then drag stays in consistent coordinate space', | ||
| (WidgetTester tester) async { | ||
| // The drag-delta normalizer must use the same effective width as | ||
| // _getValueFromGlobalPosition to avoid a value jump on tap-then-drag. | ||
| const divisions = 10; | ||
| double value = 0; | ||
| final tickPositions = <Offset>[]; | ||
|
|
||
| await tester.pumpWidget( | ||
| MaterialApp( | ||
| theme: ThemeData(useMaterial3: false), | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same here. |
||
| home: SliderTheme( | ||
| data: SliderThemeData( | ||
| tickMarkShape: _PositionRecordingTickMarkShape(tickPositions), | ||
| ), | ||
| child: StatefulBuilder( | ||
| builder: (BuildContext context, StateSetter setState) { | ||
| return Material( | ||
| child: Center( | ||
| child: Slider( | ||
| max: 10, | ||
| divisions: divisions, | ||
| value: value, | ||
| onChanged: (double v) => setState(() => value = v), | ||
| ), | ||
| ), | ||
| ); | ||
| }, | ||
| ), | ||
| ), | ||
| ), | ||
| ); | ||
|
|
||
| final List<Offset> tickCenters = tickPositions.take(divisions + 1).toList(); | ||
|
|
||
| // Tap tick 2. | ||
| await tester.tapAt(tickCenters[2]); | ||
| await tester.pump(); | ||
| expect(value, equals(2.0)); | ||
|
|
||
| // Drag exactly one tick-width to the right — should land on tick 3. | ||
| final double tickWidth = tickCenters[3].dx - tickCenters[2].dx; | ||
| final TestGesture gesture = await tester.startGesture(tickCenters[2]); | ||
| await gesture.moveBy(Offset(tickWidth, 0)); | ||
| await tester.pump(); | ||
| await gesture.up(); | ||
| await tester.pump(); | ||
| expect( | ||
| value, | ||
| equals(3.0), | ||
| reason: 'Dragging one tick-width should advance exactly one division', | ||
| ); | ||
| }, | ||
| ); | ||
|
|
||
| testWidgets( | ||
| 'Discrete Slider tapping near a tick snaps to the nearer tick', | ||
| (WidgetTester tester) async { | ||
| // Tapping just past the midpoint toward a tick (x.1/x.9 of the way, | ||
| // rather than the exact midpoint) should snap to the nearer tick. | ||
| // Loops over every tick pair rather than hardcoding one, since which | ||
| // pair is affected by rounding depends on track geometry. | ||
| const divisions = 10; | ||
| double value = 0; | ||
| final tickPositions = <Offset>[]; | ||
|
|
||
| await tester.pumpWidget( | ||
| MaterialApp( | ||
| theme: ThemeData(useMaterial3: false), | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same here. |
||
| home: SliderTheme( | ||
| data: SliderThemeData( | ||
| tickMarkShape: _PositionRecordingTickMarkShape(tickPositions), | ||
| ), | ||
| child: StatefulBuilder( | ||
| builder: (BuildContext context, StateSetter setState) { | ||
| return Material( | ||
| child: Center( | ||
| child: Slider( | ||
| max: 10, | ||
| divisions: divisions, | ||
| value: value, | ||
| onChanged: (double v) => setState(() => value = v), | ||
| ), | ||
| ), | ||
| ); | ||
| }, | ||
| ), | ||
| ), | ||
| ), | ||
| ); | ||
|
|
||
| final List<Offset> tickCenters = tickPositions.take(divisions + 1).toList(); | ||
| expect(tickCenters.length, equals(divisions + 1)); | ||
|
|
||
| for (var k = 0; k < divisions; k++) { | ||
| final double dx = tickCenters[k + 1].dx - tickCenters[k].dx; | ||
| final double dy = tickCenters[k].dy; | ||
|
|
||
| final nearLower = Offset(tickCenters[k].dx + dx * 0.1, dy); | ||
| await tester.tapAt(nearLower); | ||
| await tester.pump(); | ||
| expect( | ||
| value, | ||
| equals(k.toDouble()), | ||
| reason: 'Tap near tick $k (10% toward ${k + 1}) should snap to $k', | ||
| ); | ||
|
|
||
| final nearUpper = Offset(tickCenters[k].dx + dx * 0.9, dy); | ||
| await tester.tapAt(nearUpper); | ||
| await tester.pump(); | ||
| expect( | ||
| value, | ||
| equals((k + 1).toDouble()), | ||
| reason: 'Tap near tick ${k + 1} (90% toward ${k + 1}) should snap to ${k + 1}', | ||
| ); | ||
| } | ||
| }, | ||
| ); | ||
| } | ||
|
|
||
| /// Records the global center of each tick mark as it is painted. | ||
| /// Used in regression tests for https://github.com/flutter/flutter/issues/184391 | ||
| /// to obtain actual tick pixel positions without replicating track-geometry math. | ||
| class _PositionRecordingTickMarkShape extends SliderTickMarkShape { | ||
| _PositionRecordingTickMarkShape(this.positions); | ||
|
|
||
| final List<Offset> positions; | ||
|
|
||
| @override | ||
| Size getPreferredSize({required SliderThemeData sliderTheme, required bool isEnabled}) { | ||
| return Size.zero; | ||
| } | ||
|
|
||
| @override | ||
| void paint( | ||
| PaintingContext context, | ||
| Offset center, { | ||
| required RenderBox parentBox, | ||
| required SliderThemeData sliderTheme, | ||
| required Animation<double> enableAnimation, | ||
| required Offset thumbCenter, | ||
| required bool isEnabled, | ||
| required TextDirection textDirection, | ||
| }) { | ||
| positions.add(center); | ||
| } | ||
| } | ||
|
|
||
| // A slider value indicator that's a circle with a fixed size and | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Using the null-assert operator (
!) on_sliderTheme.rangeTrackShapecan lead to a runtime crash if the range track shape is null in the theme. It is safer to use optional chaining (?.) with a fallback value to ensure robustness. Additionally, handling the case where_trackRect.widthis<= 0.0in theelsebranch prevents potential division-by-zero errors.