我有一个可滚动的ListView,其中项目的数量可以动态变化。每当一个新项目被添加到列表的末尾时,我希望以编程的方式将ListView滚动到末尾。(例如,类似聊天消息列表的东西,可以在最后添加新消息)

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

有没有简单的方法来实现这个目标?

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


当前回答

优点:

这是一个100%有效的解决方案。它总是滚动到最后,在线上一些其他的解决方案。而且,它不需要倒车。

缺点:

锁定曲线。线性动画曲线。

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

其他回答

你可以使用0.09*height作为列表中一行的高度,_controller的定义如下:_controller = ScrollController();

(BuildContext context, int pos) {
    if(pos != 0) {
        _controller.animateTo(0.09 * height * (pos - 1), 
                              curve: Curves.easeInOut,
                              duration: Duration(milliseconds: 1400));
    }
}

如果你使用一个带有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 . 像这样定义全局键:

final lastKey = GlobalKey();

2 . 附加到最后一条消息

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

3 . 创建滚动的函数调用

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

当你想滚动到底部时调用,延迟100毫秒

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

虽然所有的答案都产生了预期的效果,但我们应该在这里做一些改进。

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.

不管怎样,说到这个问题,完成任务的正确方法是使用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);
}

然后我们需要听众:

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

然后触发_scrollToBottom,根据你的逻辑和需要,在你的setState:

setState(() {
    if (_scrollController.hasClients && _shouldAutoscroll) {
        _scrollToBottom();
    }

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

解释

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.

对我来说,问题是scrollController.position.maxScrollExtent总是返回0.0。原因是我的ListView在ScrollView里面。

删除ScrollView修复了这个问题。