231

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.

CopsOnRoad
  • 237,138
  • 77
  • 654
  • 440
yyoon
  • 3,555
  • 3
  • 21
  • 17

22 Answers22

285

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);
    }
    
CopsOnRoad
  • 237,138
  • 77
  • 654
  • 440
  • 8
    For Firebase Realtime Database, I have so far been able to get away with 250ms (which is probably really long). I wonder how low we can go? The problem is maxScrollExtent needs to wait for the widget build to finish with the new item added. How to profile the average duration from callback to build completion? – SacWebDeveloper Aug 19 '19 at 00:43
  • 3
    Do you think this will work properly with using streambuilder for fetching data from firebase? Also adding chat message on send button as well? Also if internet connection is slow will it work? anything better if you can suggest. Thanks. – Jay Mungara Nov 04 '19 at 07:01
  • @SacWebDeveloper what was your approach with this issue plus firebase ? – Idris Stack May 22 '20 at 23:40
  • @IdrisStack Actually, 250ms is evidently not long enough as sometimes, the jumpTo happens before the update occurs. I think the better approach would be to compare list length after update and jumpTo the bottom if the length is one greater. – SacWebDeveloper May 24 '20 at 06:14
  • Thanks @SacWebDeveloper, your approach works, maybe can I ask you another question in the context of Firestore, it might divert the topic abit. Qn. Why is it that when I send a message to someone the list doesn't automatically scroll to bottom, is there a way to listen the message and scroll to bottom ? – Idris Stack May 24 '20 at 07:41
  • @IdrisStack You'll need to make a new question on SO with your code. It could be too many possible reasons. Does the list update at all? Make sure you are subscribed to child added. Also, you don't need to wait for the server to display and scroll to your own messages. Just update locally. – SacWebDeveloper May 29 '20 at 04:30
  • 2
    hope is not a strategy: system may delay execution of your app for as long as it pleases. You should perform this action in a post frame callback as explained in the other answer: https://stackoverflow.com/a/64086128/1220560 – morgwai May 29 '21 at 15:58
170

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),
          );
        }
      ),
    );
  }
}
Collin Jackson
  • 110,240
  • 31
  • 221
  • 152
  • 1
    This did the trick. In my specific case I had to use nested Columns in order to put the ListView in an Expanded widget, but the basic idea is the same. – yyoon Apr 19 '17 at 20:33
  • 36
    I came searching for a solution, just wanted to mention that the other answer may be a better way to achieve this - https://stackoverflow.com/questions/44141148/how-to-get-full-size-of-a-scrollcontroller – Animesh Jain Aug 30 '17 at 15:11
  • 6
    I agree, that answer (which I also wrote) definitely better. – Collin Jackson Aug 30 '17 at 15:14
  • Why do we have to enable the reverse as true? – Daksh Gargas Aug 09 '18 at 11:36
  • 1
    @Dennis So that the ListView starts in reverse order that is from the bottom. – CopsOnRoad Oct 29 '18 at 11:41
  • ShrinkWrap can be dangerous to set https://docs.flutter.io/flutter/widgets/ScrollView/shrinkWrap.html "Shrink wrapping the content of the scroll view is significantly more expensive than expanding to the maximum allowed size because the content can expand and contract during scrolling" I was able to achieve the same thing with ```scrollController.animateTo(scrollController.position.maxScrollExtent, duration: Duration(milliseconds: 300), curve: Curves.easeOut)``` – Pranay Airan Dec 06 '18 at 16:41
  • I'm removing this code from my project because it gaves me some headaches. Try creating a scaffold project with a list of messages and then create a Timer.periodic within every message widget to count an int variable. Then add a message to the list using insert(0, message) and you'll see a bug: The first message counter will reset and the last message sent now will be with the counter from the first message. And not, this is not best answer. Try @CopsOnRoad answer. His answer is better than this. – Renan Coelho Dec 10 '19 at 20:52
  • To get last item in `Listview` you might want to add extra bias to `controller` `controller.jumpTo(_controller.position.maxScrollExtent+bias);` – Bawantha Mar 25 '21 at 03:49
  • My answer would work with you perfectly (https://stackoverflow.com/a/68983662/11567596) – Abanoub Hany Aug 30 '21 at 11:54
  • How can I get this to work with reverse set to false? – Luke Irvin Feb 05 '22 at 23:14
  • I want to scroll automatically without any click but it says controller is not attached to any view. how can I know if there is data loaded in list view builder – MANISH Mar 04 '22 at 12:14
  • We need to wrap it inside `WidgetsBinding.instance.addPostFrameCallback()` as mentioned here: https://stackoverflow.com/a/71794775/9851072 to get correct positioning for dynamic lists. – L0stLink Mar 06 '23 at 12:31
29

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

jack
  • 416
  • 4
  • 5
  • 8
    Hi Jack, thanks for your answer, I believe I wrote the same, so there is no advantage of adding same solution again IMHO. – CopsOnRoad Nov 04 '19 at 14:42
  • 1
    I was using this approach but you need to wait for some ms to do it because the screen needs to be rendered before running the animateTo(...). Maybe we've a nice way using good practices to do it without a hack. – Renan Coelho Dec 10 '19 at 20:58
  • 1
    That is the right answer. It will scroll your list to bottom if you +100 in current line. like listViewScrollController.position.maxScrollExtent+100; – Rizwan Ansar Apr 09 '20 at 20:44
  • 2
    With inspiration from @RenanCoelho, I wrapped this line of code in a Timer with delay of 100 milliseconds. – ymerdrengene Oct 09 '20 at 23:05
24

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),
 );
arjava
  • 1,719
  • 2
  • 17
  • 21
18

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.
Roberto Manfreda
  • 2,345
  • 3
  • 25
  • 39
9

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');
    }
   }
Graviton
  • 81,782
  • 146
  • 424
  • 602
Firzan
  • 91
  • 1
  • 1
  • I'd put TL;DR `WidgetsBinding.instance.addPostFrameCallback((_){_scrollController.jumpTo(_scrollController.position.maxScrollExtent);})` at the top of this answer, as the surrounding code is confusing. – morgwai May 29 '21 at 16:04
7
_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;
  }
}
nagoya0
  • 2,768
  • 2
  • 23
  • 28
6

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.

Andrej
  • 2,743
  • 2
  • 11
  • 28
3

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,
      );
    });
  }
}
Abanoub Hany
  • 557
  • 4
  • 7
3

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

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

Ilyas Arafath
  • 511
  • 7
  • 13
2

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))
  ),
),
fingerprints
  • 2,751
  • 1
  • 25
  • 45
  • 2
    The only problem that I'm having with this is that any scroll bars work backwards now. When I scroll down , they move up. – ThinkDigital May 23 '19 at 19:15
2

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());
2

I add one more thing: ScrollController only runs on Emulator or physical device, so if you debugging on window application or browser will not take effect. I debug on a windows app that didn't work until I figure out it only runs on Emulator and physical device. Hope this save your time.

Update: ScrollController can be using in window application, I have checked it work.

Xuân Cường
  • 81
  • 2
  • 10
1

Pros:

Here is the solution that works 100% of the time. It always scrolls to the end, unlike some of the other solutions. Also, it doesn't require reversing.

Cons:

Locked to Curves.linear animation curve.

    Future.doWhile(() {
      if (scrollController.position.extentAfter == 0)
        return Future.value(false);
      return scrollController
          .animateTo(scrollController.position.maxScrollExtent,
              duration: Duration(milliseconds: 100), curve: Curves.linear)
          .then((value) => true);
    });
Taras Mazepa
  • 604
  • 8
  • 24
1

First declare scrollController like this:

ScrollController _controller = ScrollController();

and also import

import 'package:cloud_firestore/cloud_firestore.dart' as cf;

If you are using Streambuilder then this code may help you:

StreamBuilder(
    stream: FirebaseFirestore.instance
        .collection("chatRoom")
        .doc(Get.arguments['chatRoomId'])
        .collection("chats")
        .orderBy('time',descending: false)
        .snapshots(),
    builder: (BuildContext context, AsyncSnapshot<cf.QuerySnapshot> snapshot){
      WidgetsBinding.instance.addPostFrameCallback((_) {
        if (_controller.hasClients) {
          _controller.jumpTo(_controller.position.maxScrollExtent);
        } else {
          setState(() => null);
        }
      });
        return ListView(
          controller: _controller,
          children: snapshot.data!.docs.map((document) {
            return Center(
              child:Text(document['message'])  //field from firestore 
            );
          }).toList(),
        );
    }
);

If you are just using listview then this will help you:

        return ListView(
          controller: _controller,
          children: list.length {
            if (_controller.hasClients) {
              _controller.jumpTo(_controller.position.maxScrollExtent);
            }
            return Center(
              child:Text(list[index)  
            );
          }),
        );
    }
);
Muhammad Ullah
  • 263
  • 2
  • 7
0

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

  • As it’s currently written, your answer is unclear. Please [edit] to add additional details that will help others understand how this addresses the question asked. You can find more information on how to write good answers [in the help center](/help/how-to-answer). – Community Mar 03 '22 at 15:45
0

For me the issue was that scrollController.position.maxScrollExtent always returned 0.0. The reason was that my ListView was inside a ScrollView.

Removing the ScrollView fixed the issue.

midi
  • 3,128
  • 5
  • 30
  • 47
0

According to https://github.com/flutter/flutter/issues/71742 "I believe this is expected behavior 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 sizes are variable and are lazily built as we scroll down the list."

If you want your listview to scroll to the bottom of the screen you can write this:

final ScrollController _scrollController = ScrollController();
_scrollController.animateTo(
                0x7fffffff, //Max int
                duration: const Duration(seconds: 2),
                curve: Curves.fastOutSlowIn,
              );

The problem with this approach is that it does not animate well, you can play around with the larger numbers to see if that helps with the animation or use this package https://pub.dev/packages/scrollable_positioned_list recommended in the same github issue thread.

Ryan Sneyd
  • 41
  • 3
0

The issue you're facing is likely because your list is not fully rendered when trying to scroll to the end.

You call the scroll method after adding elements to your list and changing the state. At that point, the Flutter framework might not have completed the rendering of your new items, so when you call scrollController.position.maxScrollExtent, it doesn't include the new items you've just added.

A possible solution is to add a PostFrameCallback, which will be executed after Flutter completes the current frame and all elements are rendered. You can use

WidgetsBinding.instance.addPostFrameCallback(_) to achieve this.

Here is how you can adjust your code (You can add this method in the function that updates your list. No need to specifically include it in the initState):

WidgetsBinding.instance.addPostFrameCallback((_) {
          scrollController.animateTo(
            scrollController.position.maxScrollExtent,
            duration: const Duration(seconds: 1),
            curve: Curves.fastOutSlowIn,
          );
        });
Abhi
  • 37
  • 5
0

If you add a new element just before scrolling, you may encouter unexpected behaviour, to solve that, add a tiny Future.delayed before scrolling:

      Future.delayed(Duration(milliseconds: 100)).then((_){
        scrollController.animateTo(
          scrollController.position.maxScrollExtent,
          duration: Duration(seconds: 1),
          curve: Curves.fastOutSlowIn,
        );
      });
Rami Osman
  • 137
  • 1
  • 13
-3

you could use this where 0.09*height is the height of a row in the list and _controller is defined like this _controller = ScrollController();

(BuildContext context, int pos) {
    if(pos != 0) {
        _controller.animateTo(0.09 * height * (pos - 1), 
                              curve: Curves.easeInOut,
                              duration: Duration(milliseconds: 1400));
    }
}
Erwan Daniel
  • 1,319
  • 11
  • 26
redaDk
  • 261
  • 3
  • 5