Riwen
Riwen

Reputation: 5200

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:

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


Thanks.

Upvotes: 11

Views: 2147

Answers (2)

Faiz
Faiz

Reputation: 6960

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

Upvotes: 0

You can change your default scrollPhysics to this class to retain the current position when a new message is added. And the user can still scroll through his messages.

I'll just put the code here for someone who still search for a solution to this.

class PositionRetainedScrollPhysics extends ScrollPhysics {
    final bool? shouldRetain;
    const PositionRetainedScrollPhysics({
       super.parent,
       this.shouldRetain = true,
    });

   @override
   PositionRetainedScrollPhysics applyTo(ScrollPhysics? ancestor){
       return PositionRetainedScrollPhysics(
       parent: buildParent(ancestor),
       shouldRetain: shouldRetain,
       );
     }

   @override
   double adjustPositionForNewDimensions({
       required ScrollMetrics oldPosition,
       required ScrollMetrics newPosition,
       required bool isScrolling,
       required double velocity,
    }) {
       final position = super.adjustPositionForNewDimensions(
          oldPosition: oldPosition,
          newPosition: newPosition,
          isScrolling: isScrolling,
          velocity: velocity,
       );

       // Check if the new position exceeds the scrollable extents
       if (newPosition.maxScrollExtent < oldPosition.maxScrollExtent) {
          return position.clamp(
          oldPosition.minScrollExtent,
          oldPosition.maxScrollExtent,
       );
      }

      final diff = newPosition.maxScrollExtent - oldPosition.maxScrollExtent;

      if (!isScrolling && shouldRetain) {
        return position + diff;
      } else {
        return position;
      }
    }
  }

Upvotes: -1

Related Questions