18

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
  • 1,322
  • 2
  • 12
  • 22

4 Answers4

42

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.

Ayush Bherwani
  • 2,409
  • 1
  • 15
  • 21
  • 1
    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? – phimath Feb 19 '20 at 05:07
  • 1
    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 Feb 19 '20 at 05:17
  • For the scroll offset, I have updated my answer using `PageStorageKey` – Ayush Bherwani Feb 19 '20 at 05:30
  • 3
    Thank you, the second way is what I thought I was looking for but I will try the first way to not rebuild everytime. – phimath Feb 19 '20 at 06:15
  • 2
    I am amazed that `AutomaticKeepAliveClientMixin` caused `dispose` method will never get called therefore my `bloc.close` is never called and everything stays intact. – abiieez Oct 27 '20 at 16:27
  • "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! – ch271828n Apr 18 '21 at 01:12
  • PageStorageKey worked for me too. Thanks @AyushBherwani. – Tharindu Welagedara May 17 '21 at 15:39
  • 2
    Please don't forget `super.build(context);` in the build method, when using the first approach. – Paul Oct 02 '22 at 06:35
  • i use key: PageStorageKey('controllerA'), and works fine in my use case. Thanks – devblock May 14 '23 at 11:07
18

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
)
mh. bitarafan
  • 886
  • 9
  • 16
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

Mohsen Haydari
  • 550
  • 5
  • 20
3

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.

Madhav Kumar
  • 856
  • 8
  • 19