Flutter

Automatic Scroll-To-Bottom in Flutter

by Steve Marx on

Earlier today, I saw a question on the FlutterDev Discord about how to automatically scroll to the bottom of a list when a new item is added.

Getting this right requires understanding when various things happen in Flutter, including state updates, tree rebuilds, and rendering.

Demo

Below is the end result, a chat-like interface where new messages trigger an automatic scroll-to-bottom. This is a live demo where a new random message appears every two seconds. Once enough messages load, try scrolling up to see what happens when a new message comes in:

Using a ScrollController

To perform any programmatic scrolling, you need to use a ScrollController. Here’s the basic structure of the demo UI, with a ScrollController configured on the ListView:

class _ChatUIState extends State<ChatUI> {
  List<String> _messages = List<String>();
  final ScrollController _scrollController = ScrollController();

  ...

  build(BuildContext context) {
    return ListView(
      controller: _scrollController,
      children: _messages.map((msg) => ChatBubble(msg)).toList(),
    );
  }

ScrollController.animateTo() and ScrollPosition.maxScrollExtent can be used together to smoothly scroll to the bottom of the view:

_scrollController.animateTo(
  _scrollController.position.maxScrollExtent,
  duration: Duration(milliseconds: 200),
  curve: Curves.easeInOut
);

But when?

If you just want to scroll when the user clicks a button, you already have all the code you need. But if you want to automatically scroll when new items are added to the list, you’ll soon run into trouble.

Your first attempt would likely look like this:

// when it's time to display a new message
setState(() {
  _addMessage();
  _scrollController.animateTo(
    _scrollController.position.maxScrollExtent, ...);
});

This looks reasonable, but if you try it, you’ll find that the list is always being scrolled to the second-to-last item in the ListView. Why is that?

The reason is timing. The call to animateTo() is executed before the UI has been rebuilt with the new message, so the maxScrollExtent is the old extent.

The developer struggling with this on the FlutterDev Discord added a Future.delayed() call to wait 50ms before scrolling. This worked because it gave the UI enough time to update first, but the developer was understandably bothered by this sort of hack.

Waiting until the rebuild

To get the timing right, you need to wait for two things to happen after the setState() call adds the new message to the widget state:

  1. You need to wait for Flutter to rebuild the widget.
  2. You need to wait for Flutter to render the UI so the scroll extent is updated.

It’s easy to wait for the rebuild. You can just move the animateTo() call to your build() function:

// Keep track of whether a scroll is needed.
bool _needsScroll = false;

_scrollToEnd() async {
  _scrollController.animateTo(
    _scrollController.position.maxScrollExtent,
    ...);
}

_addMessage() {
  ...
  // Instead of scrolling here, flag that a scroll is needed.
  _needsScroll = true;
}

build(BuildContext context) {
  if (_needsScroll) {
    _scrollToEnd();
    _needsScroll = false;
  }
  return ...

This still isn’t quite right. _scrollToEnd() runs before any of the UI has been rendered to the screen. To wait for that to happen, you can use WidgetsBinding.instance.addPostFrameCallback(), which schedules a callback to run at the end of the render frame.

With this tiny tweak to the build() method, everything works properly:

build(BuildContext context) {
  if (_needsScroll) {
    WidgetsBinding.instance.addPostFrameCallback(
      (_) => _scrollToEnd());
    _needsScroll = false;
  }
  return ...

Warning: a bug I couldn’t fix

If you play with the demo for awhile, you’ll notice that it doesn’t always scroll the whole way to the bottom. I believe this only happens when you’re not already scrolled to the bottom, but I’m not sure. If you know why this happens, please let me know so I can update this post!

Full source code

You can find the full demo code on DartPad.

Me. In your inbox?

Admit it. You're intrigued.

Subscribe