Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions packages/material_ui/lib/src/range_slider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1419,8 +1419,21 @@ class _RenderRangeSlider extends RenderBox with RelayoutWhenSystemFontsChangeMix
}

double _getValueFromGlobalPosition(Offset globalPosition) {
final double visualPosition =
(globalToLocal(globalPosition).dx - _trackRect.left) / _trackRect.width;
final double localDx = globalToLocal(globalPosition).dx;
final double visualPosition;
// Mirror the padded coordinate system used when painting tick marks and
// thumbs on discrete rounded tracks (see _RenderSlider._getValueFromGlobalPosition).
if (isDiscrete && _sliderTheme.rangeTrackShape!.isRounded) {
final double padding = _trackRect.height;
final double adjustedWidth = _trackRect.width - padding;
if (adjustedWidth <= 0.0) {
visualPosition = 0.5;
} else {
visualPosition = (localDx - _trackRect.left - padding / 2) / adjustedWidth;
}
} else {
visualPosition = (localDx - _trackRect.left) / _trackRect.width;
}
Comment on lines +1426 to +1436

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Using the null-assert operator (!) on _sliderTheme.rangeTrackShape can 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.width is <= 0.0 in the else branch prevents potential division-by-zero errors.

    if (isDiscrete && (_sliderTheme.rangeTrackShape?.isRounded ?? false)) {
      final double padding = _trackRect.height;
      final double adjustedWidth = _trackRect.width - padding;
      if (adjustedWidth <= 0.0) {
        visualPosition = 0.5;
      } else {
        visualPosition = (localDx - _trackRect.left - padding / 2) / adjustedWidth;
      }
    } else {
      visualPosition = _trackRect.width <= 0.0
          ? 0.5
          : (localDx - _trackRect.left) / _trackRect.width;
    }

return _getValueFromVisualPosition(visualPosition);
}

Expand Down
27 changes: 24 additions & 3 deletions packages/material_ui/lib/src/slider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Using the null-assert operator (!) on _sliderTheme.trackShape can lead to a runtime crash if the track shape is null in the theme. It is safer to use optional chaining (?.) with a fallback value to ensure robustness.

  double get _discreteRoundedTrackPadding =>
      (isDiscrete && (_sliderTheme.trackShape?.isRounded ?? false)) ? _trackRect.height : 0.0;


bool get isInteractive => onChanged != null;

bool get isDiscrete => divisions != null && divisions! > 0;
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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,
Expand Down
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
314 changes: 314 additions & 0 deletions packages/material_ui/test/slider_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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
Expand Down
Loading