-
Notifications
You must be signed in to change notification settings - Fork 29.7k
Fix the issue where SingleChildScrollView resets its scroll position after relayout. #173857
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: master
Are you sure you want to change the base?
Conversation
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.
Code Review
This pull request addresses an issue where SingleChildScrollView would incorrectly reset its scroll position upon relayout. The core change involves replacing markNeedsPaint() with markNeedsLayout() in _hasScrolled, ensuring that a full layout pass occurs when the scroll offset changes. Additionally, performLayout now uses a while loop with applyContentDimensions to ensure the scroll position is fully corrected. These changes seem correct and effectively address the described problem. The updated and new tests also align with these changes and improve test coverage for this behavior. I have a couple of minor suggestions in the test files to improve correctness and code quality.
| notification = value; | ||
| switch (value) { | ||
| case ScrollStartNotification(): | ||
| case ScrollUpdateNotification(dragDetails: DragUpdateDetails _): |
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.
This switch to using a switch statement is a nice modernization of the code. However, the case for ScrollUpdateNotification is now more restrictive. The original value is ScrollUpdateNotification would match any ScrollUpdateNotification, but case ScrollUpdateNotification(dragDetails: DragUpdateDetails _): will only match when dragDetails is not null. This excludes updates from non-drag activities like ballistic scrolling. Was this intentional? If not, you could use case ScrollUpdateNotification(): to match all ScrollUpdateNotification instances, which would be equivalent to the original logic.
case ScrollUpdateNotification():
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.
Yes, this is intentional. Since scrolling now triggers a relayout, the ScrollNotificationObserver will send an additional ScrollUpdateNotification at this location, with dragDetails set to null.
packages/flutter/test/widgets/single_child_scroll_view_test.dart
Outdated
Show resolved
Hide resolved
|
|
||
| offset.applyViewportDimension(_viewportExtent); | ||
| offset.applyContentDimensions(_minScrollExtent, _maxScrollExtent); | ||
| while (!offset.applyContentDimensions(_minScrollExtent, _maxScrollExtent)) {} |
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.
Should applyViewportDimension also be in the loop? I am looking at comparing this to RenderShrinkWrappingViewport (similar to SingleChildScrollView), which breaks out of the loop when both are return true.
Comparing to RenderViewport I think is a bit different than expected here, which used maxLayoutCycles to break the loop.
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.
This was marked resolved, but I am still curious about whether or not maxLayoutCycles should apply here? Otherwise we could get stuck in this loop, right?
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.
If we use a fixed maxLayoutCycles to break out of the loop, perhaps we can avoid breaking changes? Would you agree with this approach?
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.
I have added maxLayoutCycles. Could you rerun the Google tests and try again?
Piinks
left a comment
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.
Apologies for the delay here while I was OOO. 🙏
b3deaac to
eeb62c1
Compare
| _needsPixelsCorrection = false; | ||
| correctPixels( | ||
| tabBar._initialScrollOffset(viewportDimension, minScrollExtent, maxScrollExtent), | ||
| final double newPixels = tabBar._initialScrollOffset( |
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.
This makes me wonder if this will be a breaking change. Why does a change in SingleChildScrollView affect _TabBarScrollPosition? Will other custom scroll positions be broken?
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.
What you said makes a lot of sense. If use SingleChildScrollView with a custom ScrollPosition and always override applyContentDimensions to return false, it will indeed result in an infinite loop. However, always returning false is probably an incorrect usage of applyContentDimensions. According to the documentation, returning false will cause it to be called multiple times. I don't think it's possible to prevent this situation with an assertion. Do we have any conventional practices to handle this?
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.
It would be hard to try to turn it into a soft break, let me kick off Google testing to see if it uncovers any breaks.
|
Pinging back to the top of my inbox to check on the Google testing results. |
|
Following up on Google testing, it looks like this breaks a couple of things in subtle ways that might be hard for developers to diagnose and fix. There is at least one custom scroll position class in the google testing set that looks to have caused some test failures. Separately there are some screen shot tests that show that the scroll position is not the same with this change. |
|
@yiiim Hi! Are you able to address the comments above? (From triage) |
Thank you for your feedback. After carefully reviewing the implementation, I couldn't find an alternative approach to achieve the desired functionality without introducing a breaking change. If you have any suggestions or ideas, I would be happy to explore them further. |
|
|
||
| expect(events.length, 5); | ||
| expect(events.length, 6); | ||
| // user scroll do not trigger the ScrollMetricsNotification. |
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.
This comment indicates the metrics notification should not be fired?
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.
It should be removed.
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.
I don't think ScrollMetricsNotification is meant to trigger in this way. This could introduce more unexpected behavior. @chunhtai do you recall from your work on metrics notifications?
| offset.correctBy(_maxScrollExtent - offset.pixels); | ||
| } else if (offset.pixels < _minScrollExtent) { | ||
| offset.correctBy(_minScrollExtent - offset.pixels); | ||
| while (true) { |
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.
while (!offset.applyViewportDimension(_viewportExtent) || !offset.applyContentDimensions(
_minScrollExtent,
_maxScrollExtent,
))
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.
why is this convert to a while loop? are we expect the offset needs to be call several time until it stablized?
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.
Yes, this is mentioned in the PR description.
f7e537d to
42c6d11
Compare
Rebasing to rerun 👍 |
21be473 to
0d2ce25
Compare
0d2ce25 to
66a5a1f
Compare
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.
The renderViewport needs to do a while loop to correct the offset because rendersliver can report offset adjustment because it is lazy list, this is not the case for singlechildscrollview because its child is a not a sliver.
It feels to me the problem is that RenderSingleChildViewport's offset correction doesn't consider the overscroll cases where the RenderViewport uses a math.max(0, -centerOffset) to "lie" about overscroll so that the sliver won't attempt to correct it.
In RenderSingleChildViewport case, I don't think we need to correct offset at all because it doesn't work with sliver. I think we can just remove the offset correction and leave the applyContent/ViewportDimesion part alone
also, please use re-request review if you want me to take another look. I sometimes forget to follow up with pr after review
|
|
||
| void _hasScrolled() { | ||
| markNeedsPaint(); | ||
| markNeedsLayout(); |
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.
Why do mark layout dirty? we don't use scroll offset during layout at all.
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.
| _lastMetrics = copyWith(); |
| } else if (offset.pixels < _minScrollExtent) { | ||
| offset.correctBy(_minScrollExtent - offset.pixels); | ||
| for (int i = 0; i < _maxLayoutCycles; i++) { | ||
| final bool didAcceptViewportDimension = offset.applyViewportDimension(_viewportExtent); |
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.
I still don't understand why we want to call this in a loop, the _viewportExtent, _minScrollExtent, and _maxScrollExtent will only change when child relayout, but that is not the case (and also doesn't need to) in this loop.
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.
Even if minScrollExtent and maxScrollExtent do not change, applyContentDimensions should still be called repeatedly. This is described in the documentation for applyContentDimensions, and it is necessary for correct behavior. When this line returns false, it needs to keep being called. Otherwise, issues like #105733 may occur.
|
As I understand it, the while loop for |
|
I may have missed something, so please correct me if i am wrong.
do you have an repro that will fail unless the
Looking at the code, it seems we should update _lastMetrics (or part of it) when the scroll positiion change, not just in applyContentDimensions, right now the the code does a unnecessary round trip, ScrollPosition scrolled > notify SingleChildScrollView > mark layout dirty > layout> applyContentDimensions, just to update the lastMetrics (the layout shouldn't change in scrolling) instead when ScrollPosition scrolled, it could just update whatever relevant metrics in Again, is there a repro that can demonstrate the issue |
You can reproduce this issue using the code from this comment. To observe the difference, set
You are absolutely right. I will find a way to update _lastMetrics without triggering a new layout. |
|
oh nvm I see the issue now, you have to make it small enough to be a scrollable |
|
Yeah I think repeatedly calling applycontentDimesion makes sense now after I read more into the code. the return value is actually used to decide who should drive the scroll offset correction, return false means the performalayout should drive by repeatedly calls applycontentDimesion, so that part of the code LGTM now. |
chunhtai
left a comment
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.
we should still avoid relayout when scroll in singlechildscrollview
| return constraints.constrain(childSize); | ||
| } | ||
|
|
||
| static const int _maxLayoutCycles = 10; |
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.
| static const int _maxLayoutCycles = 10; | |
| static const int _kMaxLayoutCycles = 10; |
Also either apply this to all RenderViewport or ignore this check
| expect(events[2] is ScrollUpdateNotification, true); | ||
| expect(events[3] is ScrollEndNotification, true); | ||
| expect(events[4] is UserScrollNotification, true); | ||
| expect(events[3] is ScrollMetricsNotification, true); |
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.
Is this just change to the pixel location?
|
Looking for a solution to #179133, will be back later. |

Fixes: #145078
Fixes: #173225
Fixes: #149018
This PR adopts the scroll correction approach from
RenderViewport, repeatedly callingapplyContentDimensions. Additionally, it triggers a relayout on every scroll (asRenderViewportdoes) to update_lastMetricsin scroll_position.dart.This PR also reverts several tests that were changed in #136239, because the code added in #136239 has now been replaced by this PR. Some tests modified by #136239 have been restored to their original state, but the new tests added by #136239 are still retained.
Pre-launch Checklist
///).If you need help, consider asking for advice on the #hackers-new channel on Discord.
Note: The Flutter team is currently trialing the use of Gemini Code Assist for GitHub. Comments from the
gemini-code-assistbot should not be taken as authoritative feedback from the Flutter team. If you find its comments useful you can update your code accordingly, but if you are unsure or disagree with the feedback, please feel free to wait for a Flutter team member's review for guidance on which automated comments should be addressed.