Reputation: 1422
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
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,
]
);
Upvotes: 3
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
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
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