以编程方式滚动到ListView的末尾

236

我有一个可滚动的ListView, 其中的项目数量可以动态更改。每当添加一个新项目到列表末尾时,我希望能够编程滚动ListView到末尾。(例如,类似聊天消息列表的东西,新消息可以添加到末尾)

我猜想我需要在我的State对象中创建一个ScrollController并手动传递给ListView构造函数,以便稍后调用控制器上的animateTo() / jumpTo()方法。然而,由于我无法轻松确定最大滚动偏移量,因此似乎不可能简单地执行scrollToEnd()类型的操作(而我可以轻松地传递0.0使其滚动到初始位置)。

是否有一种简单的方法来实现这一点?

使用reverse: true对我来说不是完美的解决方案,因为当只有少量项目适合ListView视口时,我希望项目对齐在顶部。

23个回答

292

屏幕截图:

enter image description here

  1. 带有动画的滚动:

    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. 不带动画的滚动:

    将上面的_scrollDown方法替换为以下内容:

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

8
对于Firebase实时数据库,到目前为止我已经能够做到250毫秒的操作(这可能非常长)。我想知道我们能降低多少延迟?问题在于maxScrollExtent需要等待新项目的构建完成。如何分析从回调到构建完成的平均持续时间? - SacWebDeveloper
4
你认为使用StreamBuilder从Firebase获取数据,这个程序能正常工作吗?同时,在发送按钮上添加聊天消息也可以吗?如果网络连接缓慢,它还能正常工作吗?如果您有更好的建议,请告诉我。谢谢。 - Jay Mungara
@IdrisStack 实际上,250毫秒显然不够长,因为有时候jumpTo会在更新之前发生。我认为更好的方法是在更新后比较列表长度,如果长度增加了一,则跳转到底部。 - SacWebDeveloper
谢谢 @SacWebDeveloper,你的方法很有效,我可以在Firestore的上下文中再问一个问题吗?这可能会稍微偏离话题。问题:为什么当我向某人发送消息时,列表不会自动滚动到底部?有没有一种方法可以监听消息并滚动到底部? - Idris Stack
2
希望不是一种策略:系统可能会随意延迟执行您的应用程序。您应该按照其他答案中所述,在后帧回调中执行此操作: https://dev59.com/CFcQ5IYBdhLWcg3wA_FA#64086128 - morgwai
显示剩余2条评论

171

如果使用带有 reverse: true 属性的预设 ListView,将其滚动到 0.0 将会达到您想要的效果。

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

1
这个方法很管用。在我的特定情况下,我必须使用嵌套的列来将ListView放入扩展小部件中,但基本思路是相同的。 - yyoon
36
我来寻求解决方案,只是想提一下,另一个答案可能是实现这个目标的更好方式 - https://dev59.com/hFcP5IYBdhLWcg3wl7GY - Animesh Jain
6
我同意,那个答案(我也写了)绝对更好。 - Collin Jackson
1
@Dennis,让ListView从底部开始反向排序。 - CopsOnRoad
我正在从我的项目中删除这段代码,因为它给了我一些头痛。尝试创建一个带有消息列表的脚手架项目,然后在每个消息小部件中创建一个Timer.periodic来计算一个int变量。然后使用insert(0, message)向列表添加消息,你会看到一个bug:第一条消息计数器将重置,并且现在发送的最后一条消息将具有第一条消息的计数器。不,这不是最好的答案。尝试@CopsOnRoad的答案。他的答案比这个更好。 - Renan Coelho
显示剩余7条评论

29

listViewScrollController.animateTo(listViewScrollController.position.maxScrollExtent)是最简单的方法。


8
嗨,杰克,谢谢你的回答,我相信我写的和你一样,所以在我看来没有再加同样的解决方案的优势。 - CopsOnRoad
1
我曾经使用过这种方法,但你需要等待一些毫秒才能执行它,因为在运行animateTo(...)之前需要先渲染屏幕。也许我们有一种好的做法可以不用hack来实现。 - Renan Coelho
1
这是正确的答案。如果你在当前行中加上100,它将把你的列表滚动到底部。就像 listViewScrollController.position.maxScrollExtent+100; - Rizwan Ansar
2
在@RenanCoelho的启发下,我将这行代码包装在一个延迟100毫秒的计时器中。 - ymerdrengene

26
为了获得完美的结果,我结合了Colin Jackson和CopsOnRoad的答案。使用.position.maxScrollExtent
_scrollController.animateTo(
    _scrollController.position.maxScrollExtent,
    curve: Curves.easeOut,
    duration: const Duration(milliseconds: 500),
 );

这个能用于我需要反转列表的情况吗?或者我该如何修改它才能正常工作? - Luke Irvin

18

虽然所有答案都能产生期望的效果,但是我们应该做一些改进。

  • 首先,在大多数情况下(关于自动滚动),使用postFrameCallbacks是无用的,因为一些内容可能会在ScrollController附加(通过attach方法)之后渲染,控制器将滚动到它所知道的最后位置,而该位置可能不是视图中的最新位置。

  • 使用reverse:true是一个不错的技巧来“尾随”内容,但是物理上会被反转,因此当您尝试手动移动滚动条时,必须将其移动到相反的方向->不好的用户体验。

  • 在设计图形界面时,使用计时器是一种非常糟糕的做法->计时器在更新/生成图形造成干扰。

无论怎样,正确完成任务的方法是使用jumpTo方法和hasClients方法作为保护条件。

是否有任何ScrollPosition对象使用attach方法附加到ScrollController。

如果这是false,则不能调用与ScrollPosition交互的成员,例如position、offset、animateTo和jumpTo。

在代码中,可以简单地这样做:

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

无论如何,这段代码还不够好,即使可滚动区域没有到屏幕底部,该方法也会被触发,因此,如果您手动移动滚动条,该方法将被触发并执行自动滚动。

我们可以做得更好,借助一个监听器和一对 bool 变量就可以了。
我正在使用这个技巧来在 SelectableText 中可视化一个大小为 100000 的 CircularBuffer 的值,内容保持正确更新,自动滚动非常平滑,即使是非常非常长的内容也没有性能问题。也许正如其他答案中有人所说的那样,animateTo 方法可能会更流畅、更可定制,所以请随意尝试。

  • 首先声明这些变量:
ScrollController _scrollController = new ScrollController();
bool _firstAutoscrollExecuted = false;
bool _shouldAutoscroll = false;
  • 那么我们创建一个自动滚动的方法:
void _scrollToBottom() {
    _scrollController.jumpTo(_scrollController.position.maxScrollExtent);
}
  • 然后我们需要监听器(listener):
void _scrollListener() {
    _firstAutoscrollExecuted = true;

    if (_scrollController.hasClients && _scrollController.position.pixels == _scrollController.position.maxScrollExtent) {
        _shouldAutoscroll = true;
    } else {
        _shouldAutoscroll = false;
    }
}
  • initState中进行注册:
@override
void initState() {
    super.initState();
    _scrollController.addListener(_scrollListener);
}
  • dispose中移除监听器:
@override
void dispose() {
    _scrollController.removeListener(_scrollListener);
    super.dispose();
}
  • 根据您的逻辑和需求,在setState中触发_scrollToBottom:
setState(() {
    if (_scrollController.hasClients && _shouldAutoscroll) {
        _scrollToBottom();
    }

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

解释

  • 我们创建了一个简单的方法:_scrollToBottom() 以避免代码重复;
  • 我们创建了一个 _scrollListener() 并将其附加到_scrollController中的 initState -> 在滚动条移动后第一次触发。在此侦听器中,我们更新布尔值_shouldAutoscroll的值,以了解滚动条是否位于屏幕底部。
  • 我们在dispose中删除侦听器,以确保在小部件处置后不做无用的操作。
  • 在我们的setState中,当我们确定_scrollController已附加且该控制器位于底部(检查shouldAutoscroll的值)时,我们可以调用_scrollToBottom()
    同时,仅对第一次执行,我们通过_firstAutoscrollExecuted的值来强制短路_scrollToBottom()

感谢您抽出时间给予适当的解释并提供代码。 - Saichi Okuma
这个很好地解释了,谢谢。 - Toby William

9
不要把widgetBinding放在initState中,而是需要将它放在从数据库获取数据的方法中。例如,像这样。如果放在initState中,scrollcontroller将不会附加到任何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');
    }
   }

我会把TL;DR“WidgetsBinding.instance.addPostFrameCallback((_){_scrollController.jumpTo(_scrollController.position.maxScrollExtent);})”放在这个答案的顶部,因为周围的代码很混乱。 - morgwai

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

这些调用不能很好地处理动态大小的项目列表。因为所有的项目都是可变的,并且在向下滚动列表时会被懒惰地构建,所以在调用 jumpTo() 时我们不知道列表的长度。
这可能不是最明智的方法,但作为最后的手段,您可以执行以下操作:
Future scrollToBottom(ScrollController scrollController) async {
  while (scrollController.position.pixels != scrollController.position.maxScrollExtent) {
    scrollController.jumpTo(scrollController.position.maxScrollExtent);
    await SchedulerBinding.instance!.endOfFrame;
  }
}

6

当我使用StreamBuilder小部件从我的数据库获取数据时,遇到了这个问题。我在小部件的build方法顶部放置了WidgetsBinding.instance.addPostFrameCallback,它无法滚动到底部。我通过以下方式进行修复:

...
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
}),
...

我也尝试使用 _controller.animateTo,但好像没有起作用。

4
如果您想看到带有底部填充的最后一个可见项目,那么可以像这样添加额外的距离。
 _controller.jumpTo(_controller.position.maxScrollExtent + 200);

这里会额外增加200的距离。


3

根据这个答案,我创建了这个类,只需发送您的滚动控制器,如果您想要相反的方向,请使用reversed参数。

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

1
这种方法在使用“可见性”块时效果很好,非常棒的技巧!谢谢。 - Large

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接