更改选项卡时如何让 Flutter ScrollController 保存 ListView.builder() 的位置?

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

我用 2 个选项卡做了一个简单的示例,每个选项卡都包含一个 ListView 构建器。我的目标是能够在第一个列表视图中滚动,切换到第二个选项卡,然后切换回第一个并查看与之前相同的滚动位置。

我已经尝试将键添加到每个列表视图,但这只是一个猜测,因为我不完全理解键。那没有帮助。

为什么 ScrollControllers 不保存滚动位置?

示例如下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()),
                    ),
                  );
                }),
          ],
        ),
      ),
    );
  }
}

这是我想要的一个 hacky 但有效的例子。不过,这感觉不是正确的方法,因为它每帧都会重建两个控制器。

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

您可以使用 AutomaticKeepAliveClientMixin 在选项卡视图中保留状态。

例如

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;

} 

不要在 TabBarView 的子代中使用 ListView.builder 使用 GetListView.

例如

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

实现此目的的第二种方法是使用 PageStorageKeyPageStorageKey被Scrollables用来保存滚动偏移量。每次滚动完成时,滚动的页面存储都会更新。

例如

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

注意:在第二个示例中,每次都会使用特定的滚动偏移重建小部件。推荐使用第一种方案。

你可以像这样使用 pageStorage

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

使用 NotificationListener 或 scrollController 侦听器获取 listView 位置

您可以使用 PageStorageKey 来保留滚动位置。

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
)

我还必须构建一个类似的功能,其中 ListView.builder 应该保存当前滚动位置,并在用户第二天每次打开应用程序时从该位置开始。

我使用了 scrollable_positioned_list 包来实现它。

步骤- 1. 首先安装并导入包。

步骤 - 2. 用 ScrollablePositionedList.builder

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

第三步:添加ItemPositionsListener获取当前滚动位置。 ItemScrollController 用于下次滚动到该位置。

  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');
                      });

第 4 步:像这样在屏幕上显示第一个和最后一个项目。

第 5 步:并在 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();
    },
  );

第 6 步:将此 positionsView getter 添加到构建方法中,位于 ScrollablePositionedList.builder

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

Step-7: 将 sharedPreferences 中保存的滚动位置添加到 ScrollablePositionedList.builder 作为 initialScrollIndex:.

完成。

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

下次构建可滚动定位列表时,将从上次保存的滚动位置开始。