Skip to content

Conversation

@yiiim
Copy link
Member

@yiiim yiiim commented Aug 15, 2025

Fixes: #145078
Fixes: #173225
Fixes: #149018

This PR adopts the scroll correction approach from RenderViewport, repeatedly calling applyContentDimensions. Additionally, it triggers a relayout on every scroll (as RenderViewport does) to update _lastMetrics in 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-assist bot 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.

@github-actions github-actions bot added framework flutter/packages/flutter repository. See also f: labels. f: scrolling Viewports, list views, slivers, etc. labels Aug 15, 2025
Copy link
Contributor

@gemini-code-assist gemini-code-assist bot left a 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 _):
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

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():

Copy link
Member Author

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.

@github-actions github-actions bot added the f: material design flutter/packages/flutter/material repository. label Aug 16, 2025
@yiiim yiiim requested review from Piinks and xu-baolin August 16, 2025 10:37

offset.applyViewportDimension(_viewportExtent);
offset.applyContentDimensions(_minScrollExtent, _maxScrollExtent);
while (!offset.applyContentDimensions(_minScrollExtent, _maxScrollExtent)) {}
Copy link
Contributor

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.

Copy link
Contributor

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?

Copy link
Member Author

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?

Copy link
Member Author

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?

Copy link
Contributor

@Piinks Piinks left a 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. 🙏

@yiiim yiiim force-pushed the fix_single_child_scrollview branch from b3deaac to eeb62c1 Compare August 28, 2025 02:08
@yiiim yiiim requested a review from Piinks September 9, 2025 05:43
_needsPixelsCorrection = false;
correctPixels(
tabBar._initialScrollOffset(viewportDimension, minScrollExtent, maxScrollExtent),
final double newPixels = tabBar._initialScrollOffset(
Copy link
Contributor

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?

Copy link
Member Author

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?

Copy link
Contributor

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.

@Piinks
Copy link
Contributor

Piinks commented Sep 19, 2025

Pinging back to the top of my inbox to check on the Google testing results.

@Piinks
Copy link
Contributor

Piinks commented Sep 26, 2025

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.
Do you have thoughts on options to make this a non-breaking change?

@dkwingsmt
Copy link
Contributor

@yiiim Hi! Are you able to address the comments above? (From triage)

@yiiim
Copy link
Member Author

yiiim commented Oct 16, 2025

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. Do you have thoughts on options to make this a non-breaking change?

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.

@dkwingsmt dkwingsmt requested a review from Piinks October 22, 2025 18:19

expect(events.length, 5);
expect(events.length, 6);
// user scroll do not trigger the ScrollMetricsNotification.
Copy link
Contributor

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?

Copy link
Member Author

Choose a reason for hiding this comment

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

It should be removed.

Copy link
Contributor

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?

@chunhtai chunhtai self-requested a review October 29, 2025 17:06
offset.correctBy(_maxScrollExtent - offset.pixels);
} else if (offset.pixels < _minScrollExtent) {
offset.correctBy(_minScrollExtent - offset.pixels);
while (true) {
Copy link
Contributor

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

Copy link
Contributor

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?

Copy link
Member Author

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.

@yiiim yiiim force-pushed the fix_single_child_scrollview branch from f7e537d to 42c6d11 Compare November 5, 2025 01:47
@Piinks
Copy link
Contributor

Piinks commented Nov 11, 2025

I have added maxLayoutCycles. Could you rerun the Google tests and try again?

Rebasing to rerun 👍

@Piinks Piinks force-pushed the fix_single_child_scrollview branch from 21be473 to 0d2ce25 Compare November 11, 2025 20:56
@Piinks Piinks force-pushed the fix_single_child_scrollview branch from 0d2ce25 to 66a5a1f Compare November 13, 2025 20:19
Copy link
Contributor

@chunhtai chunhtai left a 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();
Copy link
Contributor

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.

Copy link
Member Author

Choose a reason for hiding this comment

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

If this is not done, _lastMetrics will be incorrect after scrolling.

} else if (offset.pixels < _minScrollExtent) {
offset.correctBy(_minScrollExtent - offset.pixels);
for (int i = 0; i < _maxLayoutCycles; i++) {
final bool didAcceptViewportDimension = offset.applyViewportDimension(_viewportExtent);
Copy link
Contributor

@chunhtai chunhtai Nov 18, 2025

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.

Copy link
Member Author

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.

@yiiim
Copy link
Member Author

yiiim commented Nov 24, 2025

As I understand it, the while loop for applyContentDimensions is intended to correct the offset when minScrollExtent or maxScrollExtent changes. This is not related to whether the child is a Sliver. The way applyContentDimensions works requires it to be called repeatedly.

@yiiim yiiim requested a review from chunhtai November 24, 2025 03:42
@chunhtai
Copy link
Contributor

I may have missed something, so please correct me if i am wrong.

the while loop for applyContentDimensions is intended to correct the offset when minScrollExtent or maxScrollExtent changes

do you have an repro that will fail unless the applyContentDimensions has to be called several times even through the parameter in performLayout won't change? it feels to me there may be something else that need to be fixed instead of using a loop here.

If this is not done, _lastMetrics will be incorrect after scrolling.

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 _lastMetrics

Again, is there a repro that can demonstrate the issue

@yiiim
Copy link
Member Author

yiiim commented Nov 25, 2025

do you have an repro that will fail unless the applyContentDimensions has to be called several times even through the parameter in performLayout won't change? it feels to me there may be something else that need to be fixed instead of using a loop here.

You can reproduce this issue using the code from this comment.

To observe the difference, set _maxLayoutCycles in current branch to 1 and then to a value greater than 1.

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)

You are absolutely right. I will find a way to update _lastMetrics without triggering a new layout.

@chunhtai
Copy link
Contributor

This is the code i ran

Details
import 'package:flutter/material.dart';


void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        // This is the theme of your application.
        //
        // Try running your application with "flutter run". You'll see the
        // application has a blue toolbar. Then, without quitting the app, try
        // changing the primarySwatch below to Colors.green and then invoke
        // "hot reload" (press "r" in the console where you ran "flutter run",
        // or simply save your changes to "hot reload" in a Flutter IDE).
        // Notice that the counter didn't reset back to zero; the application
        // is not restarted.
        primarySwatch: Colors.blue,
        // This makes the visual density adapt to the platform that you run
        // the app on. For desktop platforms, the controls will be smaller and
        // closer together (more dense) than on mobile platforms.
        visualDensity: VisualDensity.adaptivePlatformDensity,
      ),
      home: const HomePage(),
    );
  }
}

class HomePage extends StatefulWidget {
  const HomePage({Key? key}) : super(key: key);

  @override
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  final ScrollController _scrollController = ScrollController();

  @override
  void initState() {
    _scrollController.addListener(_scrollListener);
    super.initState();
  }

  @override
  void dispose() {
    _scrollController.dispose();
    super.dispose();
  }

  void _scrollListener() {
    // offset bigger than maxScrollExtent?
    print(
        'offset: ${_scrollController.offset} / maxScrollExtent: ${_scrollController.position.maxScrollExtent}');
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        body: Theme(
            data: ThemeData(
                scrollbarTheme: ScrollbarThemeData(
                    thumbVisibility: MaterialStateProperty.all(true),
                    thumbColor: MaterialStateProperty.all(Colors.red))),
            child: Scrollbar(
                controller: _scrollController,
                child: SingleChildScrollView(
                    controller: _scrollController,
                    scrollDirection: Axis.horizontal,
                    child:
                    const SizedBox(width: 2000, child: Placeholder())))));
  }
}

and I just comment out the offset correction part
image

I can't reproduce the error

Screen.Recording.2025-11-25.at.10.57.26.AM.mov

@chunhtai
Copy link
Contributor

oh nvm I see the issue now, you have to make it small enough to be a scrollable

@chunhtai
Copy link
Contributor

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.

Copy link
Contributor

@chunhtai chunhtai left a 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;
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
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);
Copy link
Contributor

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?

@yiiim
Copy link
Member Author

yiiim commented Dec 9, 2025

Looking for a solution to #179133, will be back later.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

f: material design flutter/packages/flutter/material repository. f: scrolling Viewports, list views, slivers, etc. framework flutter/packages/flutter repository. See also f: labels.

Projects

None yet

4 participants