Repository navigation
Conversation
|
Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA). View this failed invocation of the CLA check for more information. For the most up to date status, view the checks section at the bottom of the pull request. |
There was a problem hiding this comment.
Code Review
This pull request updates the discrete slider and range slider implementations to ensure tap and drag coordinates align correctly on rounded tracks, and adds a small epsilon before rounding to prevent IEEE 754 precision errors. It also introduces corresponding regression tests. The review feedback recommends updating the comment style of a private helper class in the test file to use triple-slash documentation comments (///) instead of double-slash comments (//), adhering to the repository's style guide.
| // 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. |
There was a problem hiding this comment.
According to the Flutter Style Guide, public-quality documentation should use /// instead of //, even for private members.
/// 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.References
- Use
///for public-quality documentation, even on private members. (link)
32b6601 to
822cd1c
Compare
|
I've marked this PR as not ready to port to flutter/packages yet. |
…ick marks Fixes flutter#184391 Two bugs caused discrete Slider taps to land on the wrong division: 1. Coordinate system mismatch: tick marks and the thumb are painted at `trackLeft + i/d * (trackWidth - padding) + padding/2`, but tap detection inverted a different, unpadded formula. On rounded tracks (RoundedRectSliderTrackShape, GappedSliderTrackShape) this pushes lower-half tap midpoints into the wrong snap bucket. The mismatch is 4 px on M2 and up to 16 px on M3. Fix: _getValueFromGlobalPosition now uses the same padded formula when isDiscrete && trackShape.isRounded. The drag-delta normalizer in _handleDragUpdate is updated to match so tap-then-drag is consistent. 2. IEEE 754 precision: 0.35 * 10 = 3.4999…9 in double arithmetic, so .round() returns 3 instead of 4 at the midpoint between ticks 3 and 4. A 1e-10 epsilon guard in _discretize corrects this without biasing values that are genuinely below the midpoint. Both fixes are applied to Slider and RangeSlider. Five regression tests are added to slider_test.dart covering M2 LTR, M3 LTR, RTL, drag continuity, and the IEEE 754 midpoint case.
2768ebe to
82cd056
Compare
dkwingsmt
left a comment
There was a problem hiding this comment.
Thanks for your investigation. I'm generally ok with the approach. But can you provide more details on why you think the IEEE 754 precision is an issue? It should be almost impossible for regular users to touch at exactly 3.5, and they shouldn't notice or complain about it even if they do.
| double _getValueFromGlobalPosition(Offset globalPosition) { | ||
| final double visualPosition = | ||
| (globalToLocal(globalPosition).dx - _trackRect.left) / _trackRect.width; | ||
| final double localDx = globalToLocal(globalPosition).dx; | ||
| final double visualPosition; | ||
| // Tick marks and the thumb are painted in an adjusted coordinate space that | ||
| // accounts for track height padding on discrete rounded tracks: | ||
| // dx = trackLeft + value * (trackWidth - padding) + padding/2 | ||
| // Invert that same formula here 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. | ||
| if (isDiscrete && _sliderTheme.trackShape!.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; | ||
| } | ||
| return _getValueFromVisualPosition(visualPosition); | ||
| } |
There was a problem hiding this comment.
I think this logic is over-complicated. I propose simplifying it to follows, with additional doc:
// Converts a global screen coordinate into a normalized visual position (0.0 to 1.0).
//
// For discrete rounded tracks, tick marks and thumb centers are painted with
// padding equal to `trackHeight` (half-height padding on each end):
// ```dart
// dx = trackLeft + padding / 2 + visualPosition * (trackWidth - padding)
// ```
// Inverting this formula aligns tap coordinates with visual tick mark centers,
// preventing tap midpoints from diverging from visual midpoints.
//
// For non-discrete or non-rounded tracks, `padding` is 0.0, mapping directly
// across `trackWidth`.
//
// Returns 0.5 (neutral midpoint) when `adjustedWidth` is non-positive to
// prevent division by zero.
double _getValueFromGlobalPosition(Offset globalPosition) {
final double localDx = globalToLocal(globalPosition).dx;
final double padding = (isDiscrete && _sliderTheme.trackShape!.isRounded)
? _trackRect.height
: 0.0;
final double adjustedWidth = _trackRect.width - padding;
final double visualPosition = adjustedWidth <= 0.0
? 0.5
: (localDx - _trackRect.left - padding / 2) / adjustedWidth;
return _getValueFromVisualPosition(visualPosition);
}There was a problem hiding this comment.
Done — extracted the padding calc into _discreteRoundedTrackPadding and flattened this method as suggested (dbd277b).
On the IEEE 754 question: the regression test taps the actual pixel midpoint between ticks 3 and 4, which computes to 0.34999999999999997 (not 0.35) from real screen-coordinate math, not an idealized decimal. Without the epsilon that rounds down to the wrong tick — and tapping the midpoint between two ticks is a completely normal interaction, not an edge case.
| final double effectiveWidth = (isDiscrete && _sliderTheme.trackShape!.isRounded) | ||
| ? _trackRect.width - _trackRect.height | ||
| : _trackRect.width; |
There was a problem hiding this comment.
It seems to me that we can extract
final double padding = (isDiscrete && _sliderTheme.trackShape!.isRounded)
? _trackRect.height
: 0.0;
to be a separate getter.
There was a problem hiding this comment.
Done in dbd277b — _discreteRoundedTrackPadding now backs both call sites.
Extracts the repeated isDiscrete/isRounded padding calculation into a single _discreteRoundedTrackPadding getter and flattens the branching in _getValueFromGlobalPosition, per dkwingsmt's review suggestion.
|
I experimented by removing the three cases of Also, while tapping any point on the slider is possible for a user, the user won't notice whether they've tapped 3.499999 or 3.500001, therefore behavior around it isn't particularly important, making the |
…pping The IEEE-754 comment was wrong (0.35 * 10 is exactly 3.5, not 3.4999...), and the regression test tapped a tick pair (3-4) that isn't actually epsilon-sensitive on this geometry, so it passed identically with or without the fix. The real effect is upstream float error in the pixel-to-fraction conversion landing a few ULPs below an exact tie between two divisions. Rewrote the comments to describe that, dropped the redundant clampDouble wrapper, and replaced the test with one that loops over every tick midpoint and genuinely fails without the epsilon.
|
You're right, and I dug into why: |
There was a problem hiding this comment.
I still don't think the behavior at exactly x.5 matters. Whether it rounds up or down doesn't affect practical user experience, meanwhile it complicates the logic. It's not what the original issue complained about, and is not checked by any current unit tests. If you still think this is a real problem, I'd prefer filing a new issue to describe it, which will make the subsequent PR clearer.
I do appreciate the remaining fix as well as the new unit tests, especially the loop test that verifies the snapping behavior comprehensively - it's essential to discrete sliders!
I recommend removing the changes of + 1e-10 and changing the test case Discrete Slider tapping the exact midpoint between two ticks snaps up to checking x.1 values and x.9 values.
dkwingsmt pointed out the +1e-10 epsilon on exact tick-midpoint ties is unverified by any test and imperceptible to users, and asked for it to be dropped in favor of covering near-tick taps instead. Reworks the midpoint regression test to tap 10%/90% of the way between ticks rather than the exact midpoint, which exercises the snapping behavior without depending on tie-breaking rounding.
|
@dkwingsmt Removed the Thanks for pushing on this — you're right that it wasn't worth the complexity for a case no user can perceive. |
| ); | ||
| } | ||
|
|
||
| /// Records the global center of each tick mark as it is painted. |
There was a problem hiding this comment.
nit: AFAIK we do not usually explain why a certain helper class is used in tests, @dkwingsmt what do you think about that ?
There was a problem hiding this comment.
I'd say it's a bit excessive to mention the issue name twice here but I don't mind documenting the helper class. Sometimes it can take a while to understand the helper classes to understand the test cases. :)
|
|
||
| await tester.pumpWidget( | ||
| MaterialApp( | ||
| theme: ThemeData(useMaterial3: true), |
There was a problem hiding this comment.
nit: this is the default value so we can remove this line.
| theme: ThemeData(useMaterial3: true), |
|
|
||
| await tester.pumpWidget( | ||
| MaterialApp( | ||
| theme: ThemeData(useMaterial3: false), |
There was a problem hiding this comment.
Do we only test M2? Actually M3 might be more important to test since M2 has become a out of dated style. We can remove this line as well to test the default value - M3.
| theme: ThemeData(useMaterial3: false), |
|
|
||
| await tester.pumpWidget( | ||
| MaterialApp( | ||
| theme: ThemeData(useMaterial3: false), |
|
|
||
| await tester.pumpWidget( | ||
| MaterialApp( | ||
| theme: ThemeData(useMaterial3: false), |
There was a problem hiding this comment.
| theme: ThemeData(useMaterial3: false), |
QuncCccccc
left a comment
There was a problem hiding this comment.
Could you also update PR description to reflect the latest change?
|
Golden file changes have been found for this pull request. Click here to view and triage (e.g. because this is an intentional change). If you are still iterating on this change and are not ready to resolve the images on the Flutter Gold dashboard, consider marking this PR as a draft pull request above. You will still be able to view image results on the dashboard, commenting will be silenced, and the check will not try to resolve itself until marked ready for review. For more guidance, visit Writing a golden file test for Reviewers: Read the Tree Hygiene page and make sure this patch meets those guidelines before LGTMing. |
|
This pull request contains changes to Material or Cupertino, which are currently frozen in this repository. Changes should be made in Please refer to #188444 for instructions. |
|
Hi @hkarmoush this PR is now ready to port over to material_ui. |
|
This pull request executed golden file tests, but it has not been updated in a while (20+ days). Test results from Gold expire after as many days, so this pull request will need to be updated with a fresh commit in order to get results from Gold. For more guidance, visit Writing a golden file test for Reviewers: Read the Tree Hygiene page and make sure this patch meets those guidelines before LGTMing. |
|
Ported to `material_ui` in flutter/packages#12927 now that the Material/Cupertino code freeze has lifted. Closing this in favor of that PR — see #188444 for the porting process. |
…ick marks Port of flutter/flutter#188012. Tick marks and the thumb on discrete rounded tracks are painted in a coordinate space padded by half a track height on each end, but the gesture math used an unpadded formula, so lower-half tick taps could snap to the wrong (lower) division. Mirrors the padded geometry in _getValueFromGlobalPosition (Slider and RangeSlider) and in the drag-delta normalizer so tap and drag stay in the same coordinate space.
… the track ends (#12927) Ports flutter/flutter#188012 to `material_ui` now that the Material/Cupertino code freeze has lifted, per the instructions in flutter/flutter#188444. Original PR description: --- Fixes flutter/flutter#194855 Tick marks and the thumb on discrete rounded tracks are *painted* in a padded coordinate space: ``` dx = trackLeft + i/d * (trackWidth − padding) + padding/2 ``` where `padding = trackHeight` (4 px on M2's `RoundedRectSliderTrackShape`, up to 16 px on M3's `GappedSliderTrackShape`). But tap detection inverted a different, unpadded formula, so the snap midpoints in tap space diverged from the visual midpoints between tick marks. Lower-half ticks consistently landed in the wrong snap bucket. Fix: `_getValueFromGlobalPosition` now uses the padded inverse formula when `isDiscrete && trackShape.isRounded`. The drag-delta normalizer in `_handleDragUpdate` is updated to match so a tap followed immediately by a drag is consistent. Applied to both `Slider` and `RangeSlider`. ## Tests Added regression tests to `slider_test.dart` and `range_slider_test.dart`. They fail without the fix and pass with it: - `Slider` snaps at the visual midpoint between every pair of ticks, tapping 0.05px either side of the midpoint, for the default Material 3 track (LTR and RTL) and the gapped track from `Slider.year2023: false` (LTR and RTL), which has the largest padding. Tapping exactly on a tick center can't expose the bug, because the old and new mappings differ by only a couple of pixels there. - A full-track drag after a tap on a 100-division gapped slider, which covers the drag delta normalizer. - `RangeSlider` snaps at the visual midpoint between ticks for both thumbs. - A tick-center sanity check for `Slider`. All of `slider_test.dart` and `range_slider_test.dart` pass locally. --- Fixes flutter/flutter#194855 Closes flutter/flutter#188012 (superseded by this PR) ## Pre-Review Checklist [^1]: Regular contributors who have demonstrated familiarity with the repository guidelines only need to comment if the PR is not auto-exempted by repo tooling.
Fixes #184391
Two independent bugs caused a discrete
Slider(orRangeSlider) to snap to the wrong division when the user tapped a lower-half tick mark:Bug 1 — Coordinate system mismatch (dominant cause)
Tick marks and the thumb are painted in a padded coordinate space:
where
padding = trackHeight(4 px on M2'sRoundedRectSliderTrackShape, up to 16 px on M3'sGappedSliderTrackShape). But tap detection inverted a different, unpadded formula, so the snap midpoints in tap space diverged from the visual midpoints between tick marks. Lower-half ticks consistently landed in the wrong snap bucket.Fix:
_getValueFromGlobalPositionnow uses the padded inverse formula whenisDiscrete && trackShape.isRounded. The drag-delta normalizer in_handleDragUpdateis updated to match so a tap followed immediately by a drag is consistent.Bug 2 — IEEE 754 precision in
_discretize0.35 * 10 = 3.4999…9in double arithmetic, so.round()returns3instead of4at the exact midpoint between ticks 3 and 4. A1e-10epsilon guard before rounding corrects this without biasing values that are genuinely below the midpoint.Both fixes are applied to
SliderandRangeSlider.Pre-launch Checklist
///).