phimath
phimath

Reputation: 1422

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

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()),
                    ),
                  );
                }),
          ],
        ),
      ),
    );
  }
}

Upvotes: 22

Views: 30961

Answers (4)

Madhav Kumar
Madhav Kumar

Reputation: 1114

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.

Upvotes: 3

mh. bitarafan
mh. bitarafan

Reputation: 1066

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
)

Upvotes: 31

Mohsen Haydari
Mohsen Haydari

Reputation: 675

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

Upvotes: 5

Ayush Bherwani
Ayush Bherwani

Reputation: 2729

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.

Upvotes: 51

Related Questions