Skip to content

Maintain scroll location to prevent content jumping when network images or dynamic network content loads into a listview that has already been laid out #99158

Open

Description

@mark8044

Consider, if there is a ListView with a number of variable height Widgets which further contain network image's that load at their own pace.

When the images load, the height of each individual listview item widget will change (increase) appropriately

If this set of events occurs above the users current scroll position in the listview, then the content will shift / jump. The result is that the user is now looking at content that was formerly above their last viewed position.

This gives for bad user experience.

Is it possible to add a "scroll anchoring" option to listviews (or any scrollable view) that the scroll position should maintain itself relative to the bottom extent of the listview when a layout change occurs above the current position or vice versa when it occurs under neath the current position (as it is currently works). In this way from the user POV, there is not content jumping effect

In the HTML/CSS world this effect has sometimes been called Layout Jank (https://css-tricks.com/the-fight-against-layout-jank/) which has been addressed with various forms of scroll anchoring
(https://blog.chromium.org/2017/04/scroll-anchoring-for-web-developers.html)
(https://developer.mozilla.org/en-US/docs/Web/CSS/overflow-anchor/Guide_to_scroll_anchoring)

https://www.youtube.com/watch?time_continue=15&v=-Fr-i4dicCQ&feature=emb_title

In this case, it would apply to not just a webview but I guess anything that could be controlled by a ScrollContoller?

Activity

  1. darshankawar commented on Feb 28, 2022

    @darshankawar
    Member

    @mark8044
    Thanks for the report. In order to address this properly, can you provide flutter doctor -v, a minimal reproducible code sample that shows the behavior you reported along with actual vs expected result ?

  2. added
    waiting for responseThe Flutter team cannot make further progress on this issue until the original reporter responds
    on Feb 28, 2022
  3. mark8044 commented on Mar 8, 2022

    @mark8044
    Author
    [✓] Flutter (Channel stable, 2.10.3, on macOS 11.6.4 20G417 darwin-x64, locale en-US)
        • Flutter version 2.10.3 at /Users//Desktop/flutter
        • Upstream repository https://github.com/flutter/flutter.git
        • Framework revision 7e9793dee1 (5 days ago), 2022-03-02 11:23:12 -0600
        • Engine revision bd539267b4
        • Dart version 2.16.1
        • DevTools version 2.9.2
    
    [✓] Android toolchain - develop for Android devices (Android SDK version 32.1.0-rc1)
        • Android SDK at /Users//Library/Android/sdk
        • Platform android-31, build-tools 32.1.0-rc1
        • Java binary at: /Applications/Android Studio.app/Contents/jre/Contents/Home/bin/java
        • Java version OpenJDK Runtime Environment (build 11.0.11+0-b60-7590822)
        • All Android licenses accepted.
    
    [✓] Xcode - develop for iOS and macOS (Xcode 13.2.1)
        • Xcode at /Applications/Xcode.app/Contents/Developer
        • CocoaPods version 1.11.2
    
    [✗] Chrome - develop for the web (Cannot find Chrome executable at /Applications/Google Chrome.app/Contents/MacOS/Google Chrome)
        ! Cannot find Chrome. Try setting CHROME_EXECUTABLE to a Chrome executable.
    
    [✓] Android Studio (version 2021.1)
        • Android Studio at /Applications/Android Studio.app/Contents
        • Flutter plugin can be installed from:
          🔨 https://plugins.jetbrains.com/plugin/9212-flutter
        • Dart plugin can be installed from:
          🔨 https://plugins.jetbrains.com/plugin/6351-dart
        • Java version OpenJDK Runtime Environment (build 11.0.11+0-b60-7590822)
    
    [✓] Connected device (1 available)
        • iPhone 13 (mobile) • • ios • com.apple.CoreSimulator.SimRuntime.iOS-15-2 (simulator)
    
    [✓] HTTP Host Availability
        • All required HTTP hosts are available
    
    
    class Test extends StatelessWidget {
      const Test({Key? key}) : super(key: key);
    
      @override
      Widget build(BuildContext context) {
        return ListView(
          children: [
            Text('Lorem ipsum dolor sit amet, consectetur adipiscing elit. Mauris sodales mi vel nisi efficitur, sed sollicitudin orci dictum. Phasellus pellentesque ex et libero accumsan porttitor. Ut at enim ex. Nam ultrices risus massa, et tempor lectus finibus eu. Phasellus varius lorem ante, vitae varius nisi elementum nec. Vestibulum blandit ut metus at ultrices. Fusce rhoncus mi enim, a gravida enim egestas ac. Duis nec consequat lectus. Integer vulputate metus ac neque molestie dignissim. Integer lacinia congue nulla molestie elementum. Nulla quis eleifend tortor. In ac nisi ligula. Proin laoreet, metus in ornare tristique, orci lorem aliquet magna, id finibus nisi libero id erat.\n\n'),
            Text('Lorem ipsum dolor sit amet, consectetur adipiscing elit. Mauris sodales mi vel nisi efficitur, sed sollicitudin orci dictum. Phasellus pellentesque ex et libero accumsan porttitor. Ut at enim ex. Nam ultrices risus massa, et tempor lectus finibus eu. Phasellus varius lorem ante, vitae varius nisi elementum nec. Vestibulum blandit ut metus at ultrices. Fusce rhoncus mi enim, a gravida enim egestas ac. Duis nec consequat lectus. Integer vulputate metus ac neque molestie dignissim. Integer lacinia congue nulla molestie elementum. Nulla quis eleifend tortor. In ac nisi ligula. Proin laoreet, metus in ornare tristique, orci lorem aliquet magna, id finibus nisi libero id erat.\n\n'),
    
            Image.network('https://repository-images.githubusercontent.com/31792824/fb7e5700-6ccc-11e9-83fe-f602e1e1a9f1'),
    
            Text('Lorem ipsum dolor sit amet, consectetur adipiscing elit. Mauris sodales mi vel nisi efficitur, sed sollicitudin orci dictum. Phasellus pellentesque ex et libero accumsan porttitor. Ut at enim ex. Nam ultrices risus massa, et tempor lectus finibus eu. Phasellus varius lorem ante, vitae varius nisi elementum nec. Vestibulum blandit ut metus at ultrices. Fusce rhoncus mi enim, a gravida enim egestas ac. Duis nec consequat lectus. Integer vulputate metus ac neque molestie dignissim. Integer lacinia congue nulla molestie elementum. Nulla quis eleifend tortor. In ac nisi ligula. Proin laoreet, metus in ornare tristique, orci lorem aliquet magna, id finibus nisi libero id erat.\n\n'),
            Text('Lorem ipsum dolor sit amet, consectetur adipiscing elit. Mauris sodales mi vel nisi efficitur, sed sollicitudin orci dictum. Phasellus pellentesque ex et libero accumsan porttitor. Ut at enim ex. Nam ultrices risus massa, et tempor lectus finibus eu. Phasellus varius lorem ante, vitae varius nisi elementum nec. Vestibulum blandit ut metus at ultrices. Fusce rhoncus mi enim, a gravida enim egestas ac. Duis nec consequat lectus. Integer vulputate metus ac neque molestie dignissim. Integer lacinia congue nulla molestie elementum. Nulla quis eleifend tortor. In ac nisi ligula. Proin laoreet, metus in ornare tristique, orci lorem aliquet magna, id finibus nisi libero id erat.\n\n'),
    
          ],
        );
      }
    }
    

    Here in this listview, when the lsitview is first shown you see the four blocks of Lorem ipsum text next to each other

    • When the Network.image loads, then the 3rd&4th paragraphs moves downward (appropriately)

    Question here is: Can it be reversed somehow where the 1st&2nd paragraphs actually move upward?

    For example, maintain the .extentAfter somehow.

    I myself tried to manually adjust the ScrollController.position by doing a ScrollController.jumpTo as soon as the image loads, but unfortunately there is a very slight but minimal glitch perceived by the user as the image pushes down and then the screen jumps down to match its old position

    Maybe a ScrollPhysics could address this case?

  4. removed
    waiting for responseThe Flutter team cannot make further progress on this issue until the original reporter responds
    on Mar 8, 2022
  5. darshankawar commented on Mar 8, 2022

    @darshankawar
    Member

    @mark8044
    Please check below issues and see if they help.

    #40340
    #63946

  6. added
    waiting for responseThe Flutter team cannot make further progress on this issue until the original reporter responds
    on Mar 8, 2022
  7. mark8044 commented on Mar 10, 2022

    @mark8044
    Author

    @darshankawar Unfortunately those two refer to to different scenarios to this one

    #40340 refers to maintaining a scrollview position between states using a PageStorageKey
    #63946 refers to scroll position when adding (or removing) listview items

    The case I'm describing is a dynamic change in a Container (or otherwise) height which then affects the Listview item height pushing the content to a different location from the current scrollview location, giving an unpleasant experience.

    In the HTML/CSS world this effect has sometimes been called Layout Jank (https://css-tricks.com/the-fight-against-layout-jank/) which has been addressed with various forms of scroll anchoring
    (https://blog.chromium.org/2017/04/scroll-anchoring-for-web-developers.html)
    (https://developer.mozilla.org/en-US/docs/Web/CSS/overflow-anchor/Guide_to_scroll_anchoring)

    https://www.youtube.com/watch?time_continue=15&v=-Fr-i4dicCQ&feature=emb_title

  8. removed
    waiting for responseThe Flutter team cannot make further progress on this issue until the original reporter responds
    on Mar 10, 2022
  9. darshankawar commented on Mar 10, 2022

    @darshankawar
    Member

    Thanks for the update.

    Question here is: Can it be reversed somehow where the 1st&2nd paragraphs actually move upward?

    For example, maintain the .extentAfter somehow.

    I myself tried to manually adjust the ScrollController.position by doing a ScrollController.jumpTo as soon as the image loads, but unfortunately there is a very slight but minimal glitch perceived by the user as the image pushes down and then the screen jumps down to match its old position

    Maybe a ScrollPhysics could address this case?

    I'll keep this issue open and label it for further insights on potential solution to address maintaining scroll location.

    /cc @Piinks

  10. added
    d: stackoverflowGood question for Stack Overflow
    f: scrollingViewports, list views, slivers, etc.
    frameworkflutter/packages/flutter repository. See also f: labels.
    and removed
    in triagePresently being triaged by the triage team
    on Mar 10, 2022
  11. 38 remaining items

  12. haroke4 commented on Dec 11, 2024

    @haroke4
  13. hgraceb commented on Dec 17, 2024

    @hgraceb
    Member
  14. lxchx commented on Mar 20, 2025

    @lxchx
  15. Piinks commented on Mar 20, 2025

    @Piinks
    Contributor

    Hi folks,
    To prevent confusion on debugging/addressing this issue, please do not provide feedback for other packages outside of flutter in this issue. :)

  16. santiagos01 commented on Mar 28, 2025

    @santiagos01

    Bringing the discussion from #165414 here

    I'd like to echo what others have said that this is ideally something that can directly be solved within Flutter's list view instead of requiring lots of manual workaround or external packages.

    This is applicable not only for loading images, but also for pagination and real-time content updates, which are very common across many apps.

    Hope that the Flutter team could consider to increase the priority for this issue

  17. mark8044 commented on Apr 2, 2025

    @mark8044
    Author

    @santiagos01 and everyone else, make sure to thumb up the original report/first post, as that is how I believe the flutter team decides which issues to elevate or tackle?

  18. ksokolovskyi commented on May 8, 2025

    @ksokolovskyi
    Contributor

    To reproduce the issue, I created two minimal examples. In both, the ListView.builder includes the items that change their height at some point in time.

    Example 1
    example_1.mov
    Example 2
    example_2.mov
    Source Code
    import 'package:flutter/material.dart';
    
    void main() {
      runApp(App());
    }
    
    class App extends StatelessWidget {
      const App({super.key});
    
      @override
      Widget build(BuildContext context) {
        return MaterialApp(
          debugShowCheckedModeBanner: false,
          home: Scaffold(
            appBar: AppBar(
              title: Text('Scroll Issues'),
              elevation: 10,
              scrolledUnderElevation: 10,
            ),
            body: Builder(builder: (context) {
              return ListView(
                padding: EdgeInsets.symmetric(horizontal: 10),
                children: [
                  Card(
                    clipBehavior: Clip.antiAlias,
                    child: ListTile(
                      title: Text('Loaders Example'),
                      onTap: () {
                        Navigator.of(context).push(
                          MaterialPageRoute(builder: (_) => LoadersExample()),
                        );
                      },
                    ),
                  ),
                  Card(
                    clipBehavior: Clip.antiAlias,
                    child: ListTile(
                      title: Text('Image Loading Example'),
                      onTap: () {
                        Navigator.of(context).push(
                          MaterialPageRoute(builder: (_) => ImageLoadingExample()),
                        );
                      },
                    ),
                  ),
                ],
              );
            }),
          ),
        );
      }
    }
    
    class LoadersExample extends StatefulWidget {
      const LoadersExample({super.key});
    
      @override
      State<LoadersExample> createState() => _LoadersExampleState();
    }
    
    class _LoadersExampleState extends State<LoadersExample> {
      final _controller = ScrollController();
    
      @override
      void dispose() {
        _controller.dispose();
        super.dispose();
      }
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(
            title: Text('Loaders Example'),
            elevation: 10,
            scrolledUnderElevation: 10,
          ),
          body: NotificationListener(
            onNotification: (notification) {
              print(notification);
              print(_controller.position);
              print('');
              return false;
            },
            child: ListView.builder(
              controller: _controller,
              itemCount: 20,
              itemBuilder: (context, index) {
                return FutureBuilder<String>(
                  future: Future.delayed(
                    Duration(milliseconds: 1000),
                    () => 'Item $index',
                  ),
                  builder: (context, snapshot) {
                    final data = snapshot.data;
    
                    if (data == null) {
                      return Center(child: CircularProgressIndicator());
                    }
    
                    return Padding(
                      padding: EdgeInsetsGeometry.all(8),
                      child: ColoredBox(
                        color: Colors.white,
                        child: SizedBox(
                          height: 80,
                          child: Center(child: Text(data)),
                        ),
                      ),
                    );
                  },
                );
              },
            ),
          ),
        );
      }
    }
    
    class ImageLoadingExample extends StatefulWidget {
      const ImageLoadingExample({super.key});
    
      @override
      State<ImageLoadingExample> createState() => _ImageLoadingExampleState();
    }
    
    class _ImageLoadingExampleState extends State<ImageLoadingExample> {
      final _controller = ScrollController();
      final _isLoading = ValueNotifier(true);
    
      @override
      void dispose() {
        _controller.dispose();
        _isLoading.dispose();
        super.dispose();
      }
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(
            title: Text('Image Loading Example'),
            elevation: 10,
            scrolledUnderElevation: 10,
          ),
          floatingActionButton: FloatingActionButton.extended(
            onPressed: () {
              _isLoading.value = !_isLoading.value;
            },
            label: Text('Switch'),
            icon: Icon(Icons.swap_horizontal_circle_outlined),
          ),
          body: NotificationListener(
            onNotification: (notification) {
              print(notification);
              print(_controller.position);
              print('');
              return false;
            },
            child: ListView.builder(
              controller: _controller,
              itemCount: 20,
              itemBuilder: (context, index) {
                if (index == 4) {
                  return ValueListenableBuilder(
                    valueListenable: _isLoading,
                    builder: (context, isLoading, _) {
                      if (isLoading) {
                        return Center(child: CircularProgressIndicator());
                      }
    
                      return Padding(
                        padding: EdgeInsetsGeometry.all(8),
                        child: ColoredBox(
                          color: Colors.red.shade500,
                          child: SizedBox(
                            height: 200,
                            child: Center(child: Text('Image')),
                          ),
                        ),
                      );
                    },
                  );
                }
    
                return Padding(
                  padding: EdgeInsetsGeometry.all(8),
                  child: ColoredBox(
                    color: Colors.white,
                    child: SizedBox(
                      height: 80,
                      child: Center(child: Text('$index')),
                    ),
                  ),
                );
              },
            ),
          ),
        );
      }
    }

    I explored the internals of the RenderSliverList class. To work around the jumping behavior mentioned in this issue, I added logic to detect when an item's extent (height) changed compared to a previous layout pass. When a size change is detected, I return a SliverGeometry with a scrollOffsetCorrection value like this:

    class RenderSliverList extends RenderSliverMultiBoxAdaptor {
    ...
      double? _prevSize;
    
      @override
      void performLayout() {
        ...
       // Now find the first child that ends after our end.
        while (endScrollOffset < targetEndScrollOffset) {
          if (!advance()) {
            reachedEnd = true;
            break;
          }
          if (index == 4) {
            final double newSize = paintExtentOf(child!);
            if (_prevSize == null) {
              _prevSize = newSize;
            } else if (_prevSize != newSize) {
              geometry = SliverGeometry(scrollOffsetCorrection: newSize - _prevSize!);
              _prevSize = newSize;
              return;
            }
          }
        }
        ...
      }
    ...
    }

    This correction signals the RenderViewport to adjust the scroll offset accordingly, and it worked correctly for that particular item (see recording below).

    Scroll Offset Correction
    scroll_offset_correction.mov

    However, there is an important limitation. RenderViewport allows a maximum of 10 layout passes with correction per child, as defined here:

    static const int _maxLayoutCyclesPerChild = 10;

    This means that if multiple items resize in a single frame (for example, several images finish loading at once), and the correction takes more than 10 layout cycles to stabilize, the scroll offset might still jump or end up incorrect.

    I also reviewed related comments from Flutter contributors that clarify the current architecture and its limitations:

    From @Piinks #99158 (comment):

    Scroll position is not content aware, so I don't know that it would be possible for the scroll position to accurately and reliably recalculate where the position 'should' be when the content of the list view is dynamically changing. ... You can also try using ListView.itemExtent or ListView.prototypeItem to better control the length of the list and the calculation of the extent.

    From @knopp #134864 (comment):

    You can always fix this by caching the size of images yourself and then use SizedBox with cached size when buildings item while scrolling back. The main issue here is that your initially create all your items with very small extent and then they resize after a while when the image is loaded. No ListView implementation can fix this (in theory resizing items in cache area can correct the scroll offset transparently but this will fall apart when you scroll too quickly so the best way is to always create the widget with proper size once you know it).

    In summary, the most progress I was able to make to fix the issue at the framework level was through the scroll offset correction implemented in RenderSliverList. While this helps mitigate the issue in some cases, it is not a practical solution due to the RenderViewport’s fixed limit of 10 layout cycles per child.

    The current recommendation is to avoid dynamic size changes by estimating item sizes in advance, using fixed-size placeholders, or caching image dimensions.

  19. added
    customer: crowdAffects or could affect many people, though not necessarily a specific customer.
    on May 9, 2025
  20. CHTNANKO commented on Jun 14, 2025

    @CHTNANKO
  21. rlim1987 commented on Jun 18, 2025

    @rlim1987
  22. moved this from Todo to Blocked in FlutterFlow Blockerson Jul 16, 2025
  23. clintdoriot commented on Apr 6, 2026

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    P3Issues that are less important to the Flutter projectcustomer: crowdAffects or could affect many people, though not necessarily a specific customer.d: stackoverflowGood question for Stack Overflowf: scrollingViewports, list views, slivers, etc.frameworkflutter/packages/flutter repository. See also f: labels.team-frameworkOwned by Framework teamtriaged-frameworkTriaged by Framework teamworkaround availableThere is a workaround available to overcome the issue

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions