'Programmatically scrolling to the end of a ListView

I have a scrollable ListView where the number of items can change dynamically. Whenever a new item is added to the end of the list, I would like to programmatically scroll the ListView to the end. (e.g., something like a chat message list where new messages can be added at the end)

My guess is that I would need to create a ScrollController in my State object and pass it manually to the ListView constructor, so I can later call animateTo() / jumpTo() method on the controller. However, since I cannot easily determine the maximum scroll offset, it seems impossible to simply perform a scrollToEnd() type of operation (whereas I can easily pass 0.0 to make it scroll to the initial position).

Is there an easy way to achieve this?

Using reverse: true is not a perfect solution for me, because I would like the items to be aligned at the top when there are only a small number of items that fit within the ListView viewport.



Solution 1:[1]

If you use a shrink-wrapped ListView with reverse: true, scrolling it to 0.0 will do what you want.

import 'dart:collection';

import 'package:flutter/material.dart';

void main() {
  runApp(new MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return new MaterialApp(
      title: 'Example',
      home: new MyHomePage(),
    );
  }
}

class MyHomePage extends StatefulWidget {
  @override
  _MyHomePageState createState() => new _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  List<Widget> _messages = <Widget>[new Text('hello'), new Text('world')];
  ScrollController _scrollController = new ScrollController();

  @override
  Widget build(BuildContext context) {
    return new Scaffold(
      body: new Center(
        child: new Container(
          decoration: new BoxDecoration(backgroundColor: Colors.blueGrey.shade100),
          width: 100.0,
          height: 100.0,
          child: new Column(
            children: [
              new Flexible(
                child: new ListView(
                  controller: _scrollController,
                  reverse: true,
                  shrinkWrap: true,
                  children: new UnmodifiableListView(_messages),
                ),
              ),
            ],
          ),
        ),
      ),
      floatingActionButton: new FloatingActionButton(
        child: new Icon(Icons.add),
        onPressed: () {
          setState(() {
            _messages.insert(0, new Text("message ${_messages.length}"));
          });
          _scrollController.animateTo(
            0.0,
            curve: Curves.easeOut,
            duration: const Duration(milliseconds: 300),
          );
        }
      ),
    );
  }
}

Solution 2:[2]

Screenshot:

enter image description here

  1. Scrolling with animation:

    final ScrollController _controller = ScrollController();
    
    // This is what you're looking for!
    void _scrollDown() {
      _controller.animateTo(
        _controller.position.maxScrollExtent,
        duration: Duration(seconds: 2),
        curve: Curves.fastOutSlowIn,
      );
    }
    
    @override
    Widget build(BuildContext context) {
      return Scaffold(
        floatingActionButton: FloatingActionButton.small(
          onPressed: _scrollDown,
          child: Icon(Icons.arrow_downward),
        ),
        body: ListView.builder(
          controller: _controller,
          itemCount: 21,
          itemBuilder: (_, i) => ListTile(title: Text('Item $i')),          
        ),
      );
    }
    
  2. Scrolling without animation:

    Replace above _scrollDown method with this:

    void _scrollDown() {
      _controller.jumpTo(_controller.position.maxScrollExtent);
    }
    

Solution 3:[3]

listViewScrollController.animateTo(listViewScrollController.position.maxScrollExtent) is the simplest way.

Solution 4:[4]

to get the perfect results I combined Colin Jackson and CopsOnRoad's answers as follows:

_scrollController.animateTo(
    _scrollController.position.maxScrollExtent,
    curve: Curves.easeOut,
    duration: const Duration(milliseconds: 500),
 );

Solution 5:[5]

While all the answers produces the desired effects we should do some improvements here.

  • First of all in most cases (speaking about auto scrolling) is useless using postFrameCallbacks because some stuff could be rendered after the ScrollController attachment (produced by the attach method), the controller will scroll until the last position that he knows and that position could not be the latest in your view.

  • Using reverse:true should be a good trick to 'tail' the content but the physic will be reversed so when you try to manually move the scrollbar you must move it to the opposite side -> BAD UX.

  • Using timers is a very bad practice when designing graphic interfaces -> timer are a kind of virus when used to update/spawn graphics artifacts.

Anyway speaking about the question the right way to accomplish the task is using the jumpTo method with the hasClients method as a guard.

Whether any ScrollPosition objects have attached themselves to the ScrollController using the attach method. If this is false, then members that interact with the ScrollPosition, such as position, offset, animateTo, and jumpTo, must not be called

Speaking in code simply do something like this:

if (_scrollController.hasClients) {
    _scrollController.jumpTo(_scrollController.position.maxScrollExtent);
}

Anyway this code is still not enough, the method will be triggered even when the scrollable isn't at the end of the screen so if you are manually moving the bar the method will triggered and autoscrolling will be performed.

We ca do better, with the help of a listener and a couple of bool will be fine.
I'm using this technique to visualize in a SelectableText the value of a CircularBuffer of size 100000 and the content keeps updating correctly, the autoscroll is very smooth and there are not performance issues even for very very very long contents. Maybe as someone said in other answers the animateTo method could be smoother and more customizable so feel free to give a try.

  • First of all declare these variables:
ScrollController _scrollController = new ScrollController();
bool _firstAutoscrollExecuted = false;
bool _shouldAutoscroll = false;
  • Then let's create a method for autoscrolling:
void _scrollToBottom() {
    _scrollController.jumpTo(_scrollController.position.maxScrollExtent);
}
  • Then we need the listener:
void _scrollListener() {
    _firstAutoscrollExecuted = true;

    if (_scrollController.hasClients && _scrollController.position.pixels == _scrollController.position.maxScrollExtent) {
        _shouldAutoscroll = true;
    } else {
        _shouldAutoscroll = false;
    }
}
  • Register it in initState:
@override
void initState() {
    super.initState();
    _scrollController.addListener(_scrollListener);
}
  • Remove the listener in your dispose:
@override
void dispose() {
    _scrollController.removeListener(_scrollListener);
    super.dispose();
}
  • Then trigger _scrollToBottom, basing on your logic and needs, in your setState:
setState(() {
    if (_scrollController.hasClients && _shouldAutoscroll) {
        _scrollToBottom();
    }

    if (!_firstAutoscrollExecuted && _scrollController.hasClients) {
         _scrollToBottom();
    }
});

EXPLANATION

  • We made a simple method: _scrollToBottom() in order to avoid code repetitions;
  • We made a _scrollListener() and we attached it to the _scrollController in the initState -> will be triggered after the first time that the scrollbar will move. In this listener we update the value of the bool value _shouldAutoscroll in order to understand if the scrollbar is at the bottom of the screen.
  • We removed the listener in the dispose just to be sure to not do useless stuff after the widget dispose.
  • In our setState when we are sure that the _scrollController is attached and that's at the bottom (checking for the value of shouldAutoscroll) we can call _scrollToBottom().
    At the same time, only for the 1st execution we force the _scrollToBottom() short-circuiting on the value of _firstAutoscrollExecuted.

Solution 6:[6]

Do not put the widgetBinding in the initstate, instead, you need to put it in the method that fetches your data from database. for example, like this. If put in initstate, the scrollcontroller will not attach to any listview.

    Future<List<Message>> fetchMessage() async {

    var res = await Api().getData("message");
    var body = json.decode(res.body);
    if (res.statusCode == 200) {
      List<Message> messages = [];
      var count=0;
      for (var u in body) {
        count++;
        Message message = Message.fromJson(u);
        messages.add(message);
      }
      WidgetsBinding.instance
          .addPostFrameCallback((_){
        if (_scrollController.hasClients) {
          _scrollController.jumpTo(_scrollController.position.maxScrollExtent);
        }
      });
      return messages;
    } else {
      throw Exception('Failed to load album');
    }
   }

Solution 7:[7]

I came across this issue when I was using the StreamBuilder widget to get data from my database. I put WidgetsBinding.instance.addPostFrameCallback on top of the widget's build method, and it wouldn't scroll all the way to the end. I fixed it by doing this:

...
StreamBuilder(
  stream: ...,
  builder: (BuildContext context, AsyncSnapshot snapshot) {
    // Like this:
    WidgetsBinding.instance.addPostFrameCallback((_) {
      if (_controller.hasClients) {
        _controller.jumpTo(_controller.position.maxScrollExtent);
      } else {
        setState(() => null);
      }
     });

     return PutYourListViewHere
}),
...

I tried it with _controller.animateTo too but it didn't seem to work.

Solution 8:[8]

_controller.jumpTo(_controller.position.maxScrollExtent);
_controller.animateTo(_controller.position.maxScrollExtent);

These calls do not work well for a list of dynamically sized items. We don't know at the time that you call jumpTo() how long the list is, since all of the items are variable and are lazily built as we scroll down the list.

This may not be the smart way, but as a last resort you can do the following:

Future scrollToBottom(ScrollController scrollController) async {
  while (scrollController.position.pixels != scrollController.position.maxScrollExtent) {
    scrollController.jumpTo(scrollController.position.maxScrollExtent);
    await SchedulerBinding.instance!.endOfFrame;
  }
}

Solution 9:[9]

I have so much problem trying to use the scroll controller to go to the bottom of the list that I use another approach.

Instead of creating an event to send the list to the bottom, I change my logic to use a reversed list.

So, each time I have a new item, I simply, made at insert at the top of the list.

// add new message at the begin of the list 
list.insert(0, message);
// ...

// pull items from the database
list = await bean.getAllReversed(); // basically a method that applies a descendent order

// I remove the scroll controller
new Flexible(
  child: new ListView.builder(
    reverse: true, 
    key: new Key(model.count().toString()),
    itemCount: model.count(),
    itemBuilder: (context, i) => ChatItem.displayMessage(model.getItem(i))
  ),
),

Solution 10:[10]

depending on this answer I have created this class, just send your scroll_controller, and if you want the opposite direction use the reversed parameter

import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';

class ScrollService {
  static scrollToEnd(
      {required ScrollController scrollController, reversed = false}) {
    SchedulerBinding.instance!.addPostFrameCallback((_) {
      scrollController.animateTo(
        reverced
            ? scrollController.position.minScrollExtent
            : scrollController.position.maxScrollExtent,
        duration: const Duration(milliseconds: 300),
        curve: Curves.easeOut,
      );
    });
  }
}

Solution 11:[11]

My solution :

Step : 1 Define global key like this :

final lastKey = GlobalKey();

Step : 2 Attach to last message

SingleChildScrollView(
    controller: scrollController,
    padding: const EdgeInsets.fromLTRB(10, 10, 10, 10),
    physics: const AlwaysScrollableScrollPhysics(),
    child: Column(
        children: List.generate(
            data.length,
            (index) {
            return BuildMessage(
                key:
                    data.length == index + 1 ? lastKey : null,
                message: data[index],
                focusNode: focusNode,
            );
            },
        ),
    ),
)

Step : 3 Create function call to scroll

void scrollToBottom() {
    Scrollable.ensureVisible(lastKey.currentContext!,alignment: 1, duration: const Duration(milliseconds: 500));
}

Call when you want to scroll to bottom with some delay 100 milliseconds

Timer(const Duration(milliseconds: 100),() => scrollToBottom());

Solution 12:[12]

if you want to see the last item visible with a padding from bottom then add extra distance like this

 _controller.jumpTo(_controller.position.maxScrollExtent + 200);

here 200 will be extra distance

Solution 13:[13]

The easiest way

mListView.smoothScrollToPosition(mList.size()-1);

Solution 14:[14]

_scrollController.animateTo( _scrollController.position.maxScrollExtent, duration: const Duration(milliseconds: 400), curve: Curves.fastOutSlowIn); });

Solution 15:[15]

I was using a dynamic list view, but scrollController.animateTo() won't work for dynamic lists as mentioned here https://stackoverflow.com/a/67561421/13814518, and I didn't even find any good solution in previous replies. So here's how I solved the issue.

void scrollToMaxExtent() {
  WidgetsBinding.instance.addPostFrameCallback((_) {
    scrollController.animateTo(
      scrollController.position.maxScrollExtent,
      duration: const Duration(milliseconds: 100),
      curve: Curves.easeIn,
    );
  });
}