Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to avoid automatic scrolling in ListView.builder

I'm building a chat application and I've reached the part where I'm creating the actual chat interface. I decided to use the ListView.builder() constructor, as I'm working with potentially a great amount of data (messages). The layout, and my code in general, look something like this:

// Copyright (c) 2019, the Dart project authors.  Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.

import 'package:flutter/material.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: MyHomePage(title: 'Chat test'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  MyHomePage({Key key, this.title}) : super(key: key);

  final String title;

  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  List<String> items;
  ScrollController _controller;
  TextEditingController _eCtrl;

  @override
  initState() {
    super.initState();
        super.initState();
    items =
        items = List<String>.generate(100, (i) => "Item $i").reversed.toList();
    _controller = ScrollController(keepScrollOffset: false);
        _eCtrl = TextEditingController();
  }
  
  @override
  dispose() {
    super.dispose();
    _controller.dispose();
    _eCtrl.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      resizeToAvoidBottomInset: false,
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
      children: <Widget>[
        Expanded(
                child: ListView.builder(
                  controller: _controller,
                  itemCount: items.length,
                  itemBuilder: (context, index) => bubble(context, index),
                  reverse: false,
                ),
              ),
        Container(
          padding: EdgeInsets.all(10),
          child: TextField(minLines: 1, maxLines: 5)
        )
      ],
      ))
    );
  }
  
  Widget bubble(BuildContext context, int index) {
    final own = index % 2 == 0;
    final width = MediaQuery.of(context).size.width;

    return Padding(
      key: ValueKey(index),
      padding: const EdgeInsets.all(10.0),
      child: Container(
        child: Row(
          mainAxisAlignment:
              own ? MainAxisAlignment.end : MainAxisAlignment.start,
          children: [
            Container(
                constraints: BoxConstraints(maxWidth: width * 0.6),
                padding: const EdgeInsets.all(10),
                color: own ? Colors.orange : Colors.grey,
                child: Text(
                  items[index],
                  style: TextStyle(
                      color: own ? Colors.white : Colors.black, fontSize: 18),
                ))
          ],
        ),
      ),
    );
  }
}

I want to preserve the scrolling position at any time, unless the user explicitly scrolls away. Here comes the first issue: when the software keyboard appears, or the size of the input field changes, I would like the bottom of the messages area to stay visible, and the older (ie. above) messages to get pushed out of the screen. You know, the way it works in Messenger, etc. However, it doesn't work like that. The older messages stay visible, and the newer ones get pushed put.

To achieve this, I tried to set the reverse argument to true. While it indeed solved the above issues, it created a newer one. Whenever a new message is added (since it is "reversed", I have to prepend the messages list), the whole messages area appears to "jump". It is, I believe, due to the fact that the since ListView is reversed, the item with index 0 is always the newest message.

However, I don't like this behavior. It doesn't seem intuitive to the user, who either expects to be scrolled down to the latest message, or be shown a floating UI element indicating that there are new messages.

Question #1: can I somehow disable/change the above behavior for reversed list? I'm thinking of a callback that calculates the "old" offset and instantly scrolls to that location when a new message is added. Not sure if it's possible or if it fits the pattern.

Question #2: I though about not using a reversed list, and instead add something like a SizeChangedLayoutNotifier, listening to the size changes of the ListView, and scroll to position accordingly. This, too, looks hacky, though.

Generally speaking, I would like to achieve a Messenger-like (or really, any other similar app) behavior. Opening the software keyboard should "push up" the messages, and when a new message arrives, there shouldn't be any jump or automatic scrolling.


UPDATE #1: found a workaround, but I would rather not go with it. Whenever an item gets added, I get the position.maxScrollExtent value of the ScrollController, then, after updating the state, I get the new value. Subtract the new value from the old, then subtract the difference from the current offset, and finally jumpTo this value. And voilà, we are at the same message. This has multiple drawbacks, though:

  • By the nature of this workaround, it's jumpy. You have to know that the new max. scroll extent will be to calculate the difference, because you have no way of knowing how long the new message is, and what the height of its bubble's going to be
  • Race condition? You have to await for an arbitrary duration (for the lack of a better method, any takers?) in order to see the value update. It works in the emulator, but will it work on users' devices?
  • It's not declarative/reactive. In my local example, I know when an item/message gets added. However, when using streams, I would need a listener to capture each time this happens, and it would be a lot of headache.

For the reasons above, I decided not to use this workaround.


Thanks.

like image 319
Riwen Avatar asked Mar 18 '26 03:03

Riwen


1 Answers

You can achieve this by using a ScrollController for smooth scrolling, Expanded for the message area, and a dynamic TextField with proper padding to handle the keyboard. Here's the code:

import 'package:flutter/material.dart';

void main() => runApp(MyApp());

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

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

class _MyHomePageState extends State<MyHomePage> {
  List<String> items = List.generate(20, (i) => "Message $i");
  final ScrollController _scrollController = ScrollController();
  final TextEditingController _textController = TextEditingController();

  void _addMessage(String message) {
    if (message.isNotEmpty) {
      setState(() => items.add(message));
      WidgetsBinding.instance.addPostFrameCallback((_) {
        if (_scrollController.hasClients) {
          _scrollController.animateTo(
            _scrollController.position.maxScrollExtent,
            duration: Duration(milliseconds: 300),
            curve: Curves.easeOut,
          );
        }
      });
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text("Chat Test")),
      body: Column(
        children: [
          Expanded(
            child: ListView.builder(
              controller: _scrollController,
              itemCount: items.length,
              itemBuilder: (context, index) {
                final isOwnMessage = index % 2 == 0;
                return Align(
                  alignment: isOwnMessage ? Alignment.centerRight : Alignment.centerLeft,
                  child: Container(
                    margin: EdgeInsets.symmetric(vertical: 4, horizontal: 8),
                    padding: EdgeInsets.all(12),
                    decoration: BoxDecoration(
                      color: isOwnMessage ? Colors.blue : Colors.grey[300],
                      borderRadius: BorderRadius.circular(12),
                    ),
                    child: Text(
                      items[index],
                      style: TextStyle(
                        color: isOwnMessage ? Colors.white : Colors.black,
                      ),
                    ),
                  ),
                );
              },
            ),
          ),
          Container(
            padding: EdgeInsets.all(8),
            child: Row(
              children: [
                Expanded(
                  child: TextField(
                    controller: _textController,
                    decoration: InputDecoration(
                      hintText: "Type a message",
                      border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
                    ),
                  ),
                ),
                IconButton(
                  icon: Icon(Icons.send),
                  onPressed: () {
                    _addMessage(_textController.text.trim());
                    _textController.clear();
                  },
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }
}
like image 104
Faiz Ahmad Dae Avatar answered Mar 20 '26 21:03

Faiz Ahmad Dae



Donate For Us

If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!