16 用滑动列表展示记录

1. 功德记录的功能需求

现在想要记录每次点击时的功德数据,以便让用户了解功德增加的历史。对于一个需求而言,最重要的是分析其中需要的数据,以及如何维护这些数据。

效果如下,点击左上角按钮,跳转到功德记录的面板。功德记录界面是一个可以滑动的列表,每个条目展示当前功德的 木鱼图片音效名称时间功德数 信息:

标题
img img

根据界面中的数据,可以封装一个类来维护,如下 MeritRecord 类。其中 id 是一条记录的身份标识,就像人的身份证编号,就可以确保他在社会中的唯一性。

class MeritRecord {
  final String id; // 记录的唯一标识
  final int timestamp; // 记录的时间戳
  final int value; // 功德数
  final String image; // 图片资源
  final String audio; // 音效名称

  MeritRecord(this.id, this.timestamp, this.value, this.image, this.audio);
}

对于功德记录的需求而言,需要在 _MuyuPageState 中新加的数据也很明确: 对 MeritRecord 列表的维护。接下来的任务就是,在点击时,为 _records 列表添加 MeritRecord 类型的元素。

---->[_MuyuPageState]----
List<MeritRecord> _records = [];

2. 列表数据的维护

对于生成唯一 id , 这里使用 uuid 库,目前最新版本 3.0.7。记得在 pubspec.yaml 的依赖节点配置:

dependencies:
  ...
  uuid: ^3.0.7

如下代码是 _onKnock 函数处理点击时的逻辑,其中创建 MeritRecord 对象,构造函数中的相关数据,在状态类中都可以轻松的到。

---->[_MuyuPageState]----
final Uuid uuid = Uuid();

void _onKnock() {
  pool?.start();
  setState(() {
    _cruValue = knockValue;
    _counter += _cruValue;
    // 添加功德记录
    String id = uuid.v4();
    _records.add(MeritRecord(
      id,
      DateTime.now().millisecondsSinceEpoch,
      _cruValue,
      activeImage,
      audioOptions[_activeAudioIndex].name,
    ));
  });
}

这里来分析一下上一篇中切换木鱼样式,会导致动画触发的问题。原因非常简单,因为动画控制器会在 didUpdateWidget 中启动。而切换木鱼样式时,通过触发了 setState 更新主界面的木鱼图片。于是会连带触发动画器的启动,解决方案有很多:

  • 解决方案 1 : 将动画控制器交由上层维护,仅在点击时启动动画。
  • 解决方案 2 :为 didUpdateWidget 回调中动画启动添加限制条件。
  • 解决方案 3 :切换木鱼样式时,使用局部更新图片,不重新构建整个 _MuyuPageState

image.png

上面的三个方案中各有优劣,综合来看,方案 1 是最好的;方案 2 次之;方案 3 虽然可以解决当前问题,但治标不治本。就当前代码而言,使用 方案 2 处理最简单,那么动画启动的限制条件是什么呢?

当功德发生变化时,才需要启动动画。

而现在每个功德对应一个 MeritRecord 对象,且 id 可以作为功德的身份标识。功德发生变化 ,也就意味着新旧功德 id 的不同。那么具体的方案就呼之欲出了,如下所示:让 AnimateText 持有 MeritRecord 数据

image.png

在状态类 didUpdateWidget 回调中,可以访问到旧的组件,通过对比新旧 AnimateText 组件持有的 record 记录 id ,当不同时才启动动画。这样就避免了在功德未变化的情况下,启动动画的场景。通过个问题的解决,想必大家对状态类的 didUpdateWidget 方法,有更深一点的了解。

@override
void didUpdateWidget(covariant AnimateText oldWidget) {
  super.didUpdateWidget(oldWidget);
  if (oldWidget.record.id != widget.record.id) {
    controller.forward(from: 0);
  }
}

小练习: 自己尝试使用解决方案 1 ,解决动画误启动问题。


3. ListView 的简单使用

我们已经知道,通过 Column 组件可以实现若干个组件的竖向排列。但当组件数量众多时,超越 Column 组件尺寸范围后,就无法显示;并且在 Debug 模式中会出现越界的异常(下左图)。很多场景中,需要支持视图的滑动,可以使用 ListView 组件来实现 (下右图):

Column ListView
img 106.gif

这里历史记录界面通过 RecordHistory 组件构建,构造函数中传入 MeritRecord 列表。主体内容通过 ListView.builder 构建滑动列表,通过 _buildItem 方法,根据索引值返回条目组件。

class RecordHistory extends StatelessWidget {
  final List<MeritRecord> records;

  const RecordHistory({Key? key, required this.records}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar:_buildAppBar(),
      body: ListView.builder(
        itemBuilder: _buildItem, 
        itemCount: records.length,
      ),
    );
  }

  PreferredSizeWidget _buildAppBar() =>
      AppBar(
        iconTheme: const IconThemeData(color: Colors.black),
        centerTitle: true,
        title: const Text(
          '功德记录', style: TextStyle(color: Colors.black, fontSize: 16),),
        elevation: 0,
        backgroundColor: Colors.white,
      );

_buildItem 方法中,使用 ListTile 组件构建条目视图;其中:

  • leading 表示左侧组件,使用 CircleAvatar 展示圆形图形;
  • title 表示标题组件,展示功德数;
  • subtitle 表示副标题组件,展示音效名称;
  • trailing 表示尾部组件,展示日期。 注: 这里使用 DateFormat 来格式化时间,需要在依赖中添加 intl: ^0.18.1 包。
DateFormat format = DateFormat('yyyy年MM月dd日 HH:mm:ss');

  Widget? _buildItem(BuildContext context, int index) {
    MeritRecord merit = records[index];
    String date = format.format(DateTime.fromMillisecondsSinceEpoch(merit.timestamp));
    return ListTile(
      leading: CircleAvatar(
        backgroundColor: Colors.blue,
        backgroundImage: AssetImage(merit.image),
      ),
      title: Text('功德 +${merit.value}'),
      subtitle: Text(merit.audio),
      trailing: Text(
        date, style: const TextStyle(fontSize: 12, color: Colors.grey),),
    );
  }
}

最后在 _MuyuPageState 中处理 _toHistory 点击事件,通过 Navigator 跳转到 RecordHistory

---->[_MuyuPageState]----
void _toHistory() {
  Navigator.of(context).push(
    MaterialPageRoute(
      builder: (_) => RecordHistory( records: _records.reversed.toList()),
    ),
  );
}

4.本章小结

本章主要介绍了如何使用可滑动列表展示非常多的内容,ListView 组件可以很便捷地帮我们让记录列表支持滑动。滑动视图使用起来并不是很复杂,但真的能用好,理解其内部的原理,还有很长的路要走。对于新手而言,目前简单能用就行了,以后可以基于小册来深入研究。

到这里,电子木鱼的基本功能完成了,当前代码位置 muyu 。不过现在的数据都是存储在内存中的,应用退出之后无论是选项,还是功德记录都会重置。想要数据持久化存储,在后面的 数据的持久化存储 一章中再继续完善,木鱼项目先告一段落。