Issue
If your attempt at scrolling to the bottom of a scroll view isn't working as expected, it's likely due to the underlying ScrollController
not knowing that the "maximum scroll extent" is. This is because lists can be lazily loaded, meaning children are only constructed when they're needed.
This presents itself as the following code snippet stopping early:
ScrollController.position.jumpTo(ScrollController.position.maxScrollExtent)
Solution
You can use an AnimationController
to drive your ScrollController
for moving to the bottom of your scroll view.
- Add a listener callback to your
AnimationController
so that it updates your ScrollController
such that the animation controller's value
(zero to one) corresponds to the scroll controller's scroll position (zero to maxScrollExtent
).
- Drive your animation forward or backward as needed (in this example I'm using
fling()
with a velocity of 1.0 for going down and -1.0 for going up, but you may also directly set the value to any double between 0 and 1).
- Provide the
ScrollController
to your scroll view.
Then, whenever you add a new item to your list, you can simply update your AnimationController
to scroll to the bottom.
void addListItem() {
setState(() => items.add(newItem));
WidgetsBinding.instance.addPostFrameCallback((_) {
// Doing this in the next frame ensures your list view has rendered with the new item
animationController.fling();
});
}
Dartpad example
In the example below, the two text buttons at the top illustrate scrolling to the bottom and the two text buttons at the bottom illustrate scrolling to the top. Notice how scrolling to the bottom via "Bad scroll to bottom" stops early, but "Good scroll to bottom" completes successfully.
import 'package:flutter/material.dart';
const Color darkBlue = Color.fromARGB(255, 18, 32, 47);
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData.dark().copyWith(
scaffoldBackgroundColor: darkBlue,
),
debugShowCheckedModeBanner: false,
home: Scaffold(
body: LazyList(),
),
);
}
}
class LazyList extends StatefulWidget {
@override
State<LazyList> createState() => _LazyListState();
}
class _LazyListState extends State<LazyList> with SingleTickerProviderStateMixin {
late final _animationController = AnimationController(
vsync: this,
duration: const Duration(seconds: 3),
);
final _scrollController = ScrollController();
@override
void initState() {
super.initState();
_animationController.addListener(_linkAnimationToScroll);
}
@override
void dispose() {
_animationController.dispose();
_scrollController.dispose();
super.dispose();
}
/// Joins the `AnimationController` to the `ScrollController`, providing ample
/// time for the lazy list to render its contents while scrolling to the bottom.
void _linkAnimationToScroll() {
_scrollController.jumpTo(
_animationController.value * _scrollController.position.maxScrollExtent,
);
}
/// Relying on just the `ScrollController` will fail because the cache extent
/// is not long enough for its max position to be correctly computed.
void _badScrollToBottom() {
_scrollController.position.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 500),
curve: Curves.fastEaseInToSlowEaseOut,
);
}
/// Utilizings the link between the `AnimationController` and `ScrollController`
/// to start at the user's current scroll position and fling them to the bottom.
///
/// ("bottom" is the max scroll extent seen in [_linkAnimationToScroll])
void _goodScrollToBottom() {
_animationController.value = _scrollController.position.pixels / _scrollController.position.maxScrollExtent;
_animationController.fling();
}
/// This is fine because the `ScrollController` already knows where 0 is.
void _goodScrollToTop() {
_scrollController.position.animateTo(
0,
duration: const Duration(milliseconds: 500),
curve: Curves.fastEaseInToSlowEaseOut,
);
}
/// This is also good because the `AnimationController` is wired up to the `ScrollController`.
void _otherGoodScrollToTop() {
_animationController.value = _scrollController.position.pixels / _scrollController.position.maxScrollExtent;
_animationController.fling(velocity: -1);
}
@override
Widget build(BuildContext context) {
return CustomScrollView(
controller: _scrollController,
slivers: [
const SliverAppBar(
title: Text('Scroll to bottom demo'),
centerTitle: false,
),
SliverToBoxAdapter(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
TextButton(
onPressed: _badScrollToBottom,
child: const Text('Bad scroll to bottom'),
),
const SizedBox(width: 8),
TextButton(
onPressed: _goodScrollToBottom,
child: const Text('Good scroll to bottom'),
),
],
),
),
SliverList.list(
children: List.generate(
50,
(i) {
return Container(
alignment: Alignment.centerLeft,
height: i * 20,
child: Text('$i'),
);
},
),
),
SliverToBoxAdapter(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
TextButton(
onPressed: _goodScrollToTop,
child: const Text('Good scroll to top'),
),
const SizedBox(width: 8),
TextButton(
onPressed: _otherGoodScrollToTop,
child: const Text('Other good scroll to top'),
),
],
),
),
],
);
}
}