Flutter 滚动组件内容更新时自动定位到底端

问题:

在使用Flutter的ListView等滚动组件做历史记录或日志记录时,需要在更新内容后立即定位到内容底部(默认是在顶部的)。


寻找解决方案:

  1. ListView有一个reverse属性,如果把内容倒置,倒是可以总显示新内容。

    但问题是内容少的时候,是直接靠底部显示的,这样看起来比较怪。

  2. ListView的ScrollController本身提供了像素滚动的方法,可以计算视口高度和内容总高度,然后调用Controller的jumpTo或animateTo

    这是一个临时解决方案,需要测量出当前视口的高度,然后写在代码里,内容的高度根据子元素数目计算,需要子元素内容没太大差异,等高的情况就好控制

    在Windows下效果尚可,但到苹果设备上由于物理滚动的特性,会很跳脱

  3. 找到一个新Controller可以控制滚动到当前选中的组件FixedExtentScrollController

        原来是场误会,这个控制器是专门给新组件ListWheelScrollView用的,这是一个齿轮滚动选择的组件,被选中项总是在中间


最终方案: 

    最后通过print ScrollController.position这个属性,发现它在运行中其实是ScrollPositionWithSingleContext的实例,这个实例可以直接获取到滚动组件的视口高度,可滚动范围等参数。

微信截图_20210506111848.png

演示代码

import 'package:flutter/material.dart';

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: MyHomePage(title: 'Flutter Demo For ListView'),
    );
  }
}

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

  final String title;

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

class _MyHomePageState extends State<MyHomePage> {
  int _counter = 0;
  ScrollController controller;

  @override
  void initState() {
    super.initState();
    controller = ScrollController();
  }

  void _incrementCounter() {
    setState(() {
      _counter++;
    });
    Future.delayed(Duration(milliseconds: 16)).then((value) =>
        controller.animateTo(
            controller.position.maxScrollExtent,
            duration: Duration(milliseconds: 200),
            curve: Curves.easeOutQuart));
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: Container(
          width: 150,
          height: 300,
          decoration: BoxDecoration(
              border: Border.all(color: Colors.black26, width: 0.5)),
          child: ListView(
            controller: controller,
            padding: EdgeInsets.all(10),
            children: List<Widget>.generate(
                _counter, (index) => Text('This is row $index')),
          ),
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _incrementCounter,
        tooltip: 'Increment',
        child: Icon(Icons.add),
      ), // This trailing comma makes auto-formatting nicer for build methods.
    );
  }
}


这里要注意的一点是,在加入新元素中调用滚动,是不能立即滚动的(代码中采用了延迟16ms,一帧的时间),否则就会出现滚动不到底的情况。因为加入元素是一个数据操作,要更新到界面需要等待下一次build,如果这个新元素还有入场动画,那么调用滚动的延迟最好delay到动画结束,否则获取的maxScrollExtend是不准确的。

这里有一种更精确的逻辑,是在当前State中混入一个Ticker ,在加入新元素后给一个私有值确定需要滚动到底部,每次Ticker调用都获取最新的maxScrollExtend,这样只要滚动的动画比新元素入场动画慢一帧以上,都是可以保障最终的滚动位置准确的。限于篇副此处不再示例,有兴趣的朋友可以自行实验。