How to get Flutter ScrollController to save position of ListView.builder() when changing tabs?

9,873

Solution 1

You can use AutomaticKeepAliveClientMixin to persist the states in Tab View.

For Example

class GetListView extends StatefulWidget{
  @override
  State<StatefulWidget> createState() =>_GetListViewState();
}
class _GetListViewState extends State<GetListView> with AutomaticKeepAliveClientMixin<GetListView>{
  @override
  Widget build(BuildContext context){
    return ListView.builder(
                itemCount: 2000,
                itemBuilder: (context, i) {
                  return ListTile(
                      title: Text(
                    i.toString(),
                    textScaleFactor: 1.5,
                    style: TextStyle(color: Colors.blue),
                  ));
                });
  }
  @override
  bool get wantKeepAlive => true;
} 

Instead of using ListView.builder in childern of TabBarView use GetListView.

For Example

TabBarView(
          children: <Widget>[
            GetListView(),
            ListView.builder(
                controller: controllerB,
                itemCount: 2000,
                itemBuilder: (context, i) {
                  return Card(
                    child: ListTile(
                      title: Text(i.toString()),
                    ),
                  );
                }),
          ],
        ),
      )

The second way to achieve this is by using PageStorageKey. PageStorageKey is used by Scrollables to save the scroll offset. Each time a scroll completes, the scrollable's page storage is updated.

For Example

 ListView.builder(
                key: PageStorageKey<String>('controllerA'),
                controller: statelessControllerA,
                itemCount: 2000,
                itemBuilder: (context, i) {
                  print("Rebuilded 1");
                  return ListTile(
                      title: Text(
                    i.toString(),
                    textScaleFactor: 1.5,
                    style: TextStyle(color: Colors.blue),
                  ));
                }),

Note: In the second example the widgets will be rebuilded everytime with a specific scroll offset. It's recommended to use the first solution.

Solution 2

you can use PageStorageKey which also preserve scroll position.

A key can be used to persist the widget state in storage after the destruction and will be restored when recreated.

ListView.builder(
      key: PageStorageKey(0), //0 is Store index you should use a new one for each page you can also use string
)

Solution 3

u can use pageStorage like this

final PageStorageBucket appBucket = PageStorageBucket();
saveScrollOffset(BuildContext context, double offset, String key) =>
    appBucket.writeState(context, offset, identifier: ValueKey(key));
double currentPageScrollOffset(BuildContext context, String key) =>
    appBucket.readState(context, identifier: ValueKey(key)) ?? 0.0;
main() {
  runApp(MaterialApp(
    home: HomeScreen(),
  ));
}
class HomeScreen extends StatelessWidget {
  HomeScreen();
  @override
  Widget build(BuildContext context) {
    return PageStorage(
      bucket: appBucket,
      child: Scaffold(
        body: Container(
          child: Center(
            child: TextButton(
                onPressed: () {
                  Navigator.of(context).push(MaterialPageRoute(
                      builder: (_) => NeedToSaveScrollPosition()));
                },
                child: Text('push')),
          ),
        ),
      ),
    );
  }
}
class NeedToSaveScrollPosition extends StatelessWidget {
  final String bucketOffsetKey = 'thisPageOffsetKey';
  @override
  Widget build(BuildContext context) {
    return Material(
      child: NotificationListener<ScrollNotification>(
          onNotification: (ScrollNotification pos) {
            if (pos is ScrollEndNotification) {
              saveScrollOffset(context, pos.metrics.pixels, bucketOffsetKey);
              print(currentPageScrollOffset(context, bucketOffsetKey));
            }
            return true;
          },
          child: CustomScrollView(
            controller: ScrollController(
                initialScrollOffset:
                    currentPageScrollOffset(context, bucketOffsetKey)),
            slivers: [
              SliverList(
                  delegate: SliverChildBuilderDelegate(
                      (context, index) => Text("$index"),
                      childCount: 500))
            ],
          )),
    );
  }
}

use NotificationListener or scrollController listener for get listView position

Solution 4

AutomaticKeepAliveClientMixin would save the position only if you are browsing inside the app and haven't closed and reopened it. But if you want to close the app and open it the next day, and still want to start from the same position, then my solution will work.

I had to build a similar feature, where ListView.builder should save the current scroll position, and start from that position everytime the user opens the app the next day.

I used scrollable_positioned_list package to implement it.

Step- 1. First install and import the package.

Step- 2. In place of ListView.builder use ScrollablePositionedList.builder

                   ScrollablePositionedList.builder(
                      itemCount: 100,
                      itemBuilder: (context, index) {
                        return Text('item number $index');
                      });

Step- 3: Add ItemPositionsListener for getting the current scroll position. And ItemScrollController for scrolling to that position the next time.

  final ItemScrollController itemScrollController = ItemScrollController();
  final ItemPositionsListener itemPositionsListener = ItemPositionsListener.create();
                   ScrollablePositionedList.builder(
                      itemCount: 100,
                      itemScrollController: itemScrollController,
                      itemPositionsListener: itemPositionsListener,
                      itemBuilder: (context, index) {
                        return Text('item number $index');
                      });

Step-4: Get the first and last item visible on the screen like this.

Step-5: And save the first item in sharedPreferences.

//step-4...
Widget get positionsView => ValueListenableBuilder<Iterable<ItemPosition>>(
    valueListenable: itemPositionsListener.itemPositions,
    builder: (context, positions, child) {
      int? firstItem;
      int? lastItem;
      if (positions.isNotEmpty) {
        // Determine the first visible item by finding the item with the
        // smallest trailing edge that is greater than 0.  i.e. the first
        // item whose trailing edge in visible in the viewport.
        firstItem = positions
            .where((ItemPosition position) => position.itemTrailingEdge > 0)
            .reduce((ItemPosition first, ItemPosition position) =>
                position.itemTrailingEdge < first.itemTrailingEdge ? position : first)
            .index;
        // Determine the last visible item by finding the item with the
        // greatest leading edge that is less than 1.  i.e. the last
        // item whose leading edge in visible in the viewport.
        lastItem = positions
            .where((ItemPosition position) => position.itemLeadingEdge < 1)
            .reduce((ItemPosition last, ItemPosition position) =>
                position.itemLeadingEdge > last.itemLeadingEdge ? position : last)
            .index;
      }
//Step-5....
      sharedPreferences?.setInt('scrollPosition', firstItem ?? 0);
      return SizedBox.shrink();
    },
  );

Step-6: Add this positionsView getter to the build method, above or below the ScrollablePositionedList.builder

             Stack(
             children: [
                   ScrollablePositionedList.builder(
                      itemCount: 100,
                      itemScrollController: itemScrollController,
                      itemPositionsListener: itemPositionsListener,
                      itemBuilder: (context, index) {
                        return Text('item number $index');
                      }),
                    positionsView,
                ]
             );

Step-7: Add the saved scroll position in sharedPreferences to the ScrollablePositionedList.builder as initialScrollIndex:.

And Done.

             Stack(
             children: [
                   ScrollablePositionedList.builder(
                      itemCount: 100,
                      itemScrollController: itemScrollController,
                      itemPositionsListener: itemPositionsListener,
                      initialScrollIndex: sharedPreferences?.getInt('scrollPosition'),
                      itemBuilder: (context, index) {
                        return Text('item number $index');
                      }),
                    positionsView,
                ]
             );

When the next time scrollable positioned list will build, it will start from the last saved scroll position.

Share:
9,873
phimath
Author by

phimath

Updated on December 17, 2022

Comments

  • phimath
    phimath less than a minute

    I have made a simple example with 2 tabs, each containing a ListView builder. My goal is to be able to scroll in the first list view, switch to the 2nd tab, and then switch back to the first and see the same scroll position from before.

    I have tried adding Keys to each of the list views, but that was only a guess as I don't fully understand keys. That didn't help.

    Why don't the ScrollControllers save the scroll position?

    Here is the example main.dart:

    import 'package:flutter/material.dart';
    void main() => runApp(MyApp());
    class MyApp extends StatelessWidget {
      // This widget is the root of your application.
      @override
      Widget build(BuildContext context) {
        return MaterialApp(
          home: HomeScreen(),
        );
      }
    }
    class HomeScreen extends StatefulWidget {
      @override
      _HomeScreenState createState() => _HomeScreenState();
    }
    class _HomeScreenState extends State<HomeScreen> {
      ScrollController controllerA = ScrollController(keepScrollOffset: true);
      ScrollController controllerB = ScrollController(keepScrollOffset: true);
      @override
      Widget build(BuildContext context) {
        return DefaultTabController(
          length: 2,
          child: Scaffold(
            appBar: AppBar(
              bottom: TabBar(
                tabs: <Widget>[
                  Text('controllerA'),
                  Text('controllerB'),
                ],
              ),
            ),
            body: TabBarView(
              children: <Widget>[
                ListView.builder(
                    controller: controllerA,
                    itemCount: 2000,
                    itemBuilder: (context, i) {
                      return ListTile(
                          title: Text(
                        i.toString(),
                        textScaleFactor: 1.5,
                        style: TextStyle(color: Colors.blue),
                      ));
                    }),
                ListView.builder(
                    controller: controllerB,
                    itemCount: 2000,
                    itemBuilder: (context, i) {
                      return Card(
                        child: ListTile(
                          title: Text(i.toString()),
                        ),
                      );
                    }),
              ],
            ),
          ),
        );
      }
    }
    

    Here is a hacky but working example of what I want. This doesn't feel like the correct way to do this though, as its rebuilding both controllers every frame.

    import 'package:flutter/material.dart';
    void main() => runApp(MyApp());
    class MyApp extends StatelessWidget {
      // This widget is the root of your application.
      @override
      Widget build(BuildContext context) {
        return MaterialApp(
          home: HomeScreen(),
        );
      }
    }
    class HomeScreen extends StatefulWidget {
      @override
      _HomeScreenState createState() => _HomeScreenState();
    }
    class _HomeScreenState extends State<HomeScreen> {
      double offsetA = 0.0;
      double offsetB = 0.0;
      @override
      Widget build(BuildContext context) {
        ScrollController statelessControllerA =
            ScrollController(initialScrollOffset: offsetA);
        statelessControllerA.addListener(() {
          setState(() {
            offsetA = statelessControllerA.offset;
          });
        });
        ScrollController statelessControllerB =
            ScrollController(initialScrollOffset: offsetB);
        statelessControllerB.addListener(() {
          setState(() {
            offsetB = statelessControllerB.offset;
          });
        });
        return DefaultTabController(
          length: 2,
          child: Scaffold(
            appBar: AppBar(
              bottom: TabBar(
                tabs: <Widget>[
                  Text('controllerA'),
                  Text('controllerB'),
                ],
              ),
            ),
            body: TabBarView(
              children: <Widget>[
                ListView.builder(
                    controller: statelessControllerA,
                    itemCount: 2000,
                    itemBuilder: (context, i) {
                      return ListTile(
                          title: Text(
                        i.toString(),
                        textScaleFactor: 1.5,
                        style: TextStyle(color: Colors.blue),
                      ));
                    }),
                ListView.builder(
                    controller: statelessControllerB,
                    itemCount: 2000,
                    itemBuilder: (context, i) {
                      return Card(
                        child: ListTile(
                          title: Text(i.toString()),
                        ),
                      );
                    }),
              ],
            ),
          ),
        );
      }
    }
    
  • phimath
    phimath almost 3 years
    If the controller is saved in the stateful widget already, why do I need to try and keep it alive? I assumed the point of the controller was to avoid keeping the view alive? What is the point of the controller's keepScrollOffset property if not to keep the scroll offset?
  • Ayush Bherwani almost 3 years
    If you are using controllers and saving the scroll offset to be used later, it will rebuild the widget every time with the offset you provide whenever you want to switch the tabs which is not recommended way to persist the states.
  • Ayush Bherwani almost 3 years
    For the scroll offset, I have updated my answer using PageStorageKey
  • phimath
    phimath almost 3 years
    Thank you, the second way is what I thought I was looking for but I will try the first way to not rebuild everytime.
  • abiieez
    abiieez about 2 years
    I am amazed that AutomaticKeepAliveClientMixin caused dispose method will never get called therefore my bloc.close is never called and everything stays intact.
  • ch271828n
    ch271828n over 1 year
    "It's recommended to use the first solution." -> However, IMHO, the first way will cause the whole widget tree to never be disposed, which may consume a lot of memory :( Please correct me if I am wrong!
  • Tharindu Welagedara
    Tharindu Welagedara over 1 year
    PageStorageKey worked for me too. Thanks @AyushBherwani.
  • THEODORE
    THEODORE 6 months
    this is very nice. You can also replace the the Column Widget with Stack Widget so you won't have any constraint error
  • Madhav
    Madhav 6 months
    @Theodore Thank you. You are right. I've made the changes.