【Flutter】关于ListView子元素状态保持的一点细节

845 阅读3分钟

前言

昨天尝试写一个入场/出场动画的组件,写测试代码的时候用到了ListView.builder,发现在移除列表的中间项的时候,后面的元素会刷新State(触发initState),而前面的元素则不会触发initState。最直接的影响就是,被删除元素后面的元素,触发了入场动画。所以一般不用入场动画的场景,关注不到这个问题。 简单写了个动画效果,表现如图:

list_r1.gif

问题代码

import 'package:flutter/material.dart';

class ListTestPage extends StatefulWidget {
  const ListTestPage({Key? key}) : super(key: key);

  @override
  State<ListTestPage> createState() => _ListTestPageState();
}

class _ListTestPageState extends State<ListTestPage> {
  final lists = <int>[];
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('List Test'),
        actions: [
          TextButton.icon(
            onPressed: () {
              setState(() {
                lists.clear();
              });
            },
            style: TextButton.styleFrom(primary: Colors.white),
            icon: const Icon(Icons.delete_outline),
            label: const Text('Clear'),
          ),
          TextButton.icon(
            onPressed: () {
              setState(() {
                lists.add(lists.isEmpty ? 0 : lists.last + 1);
              });
            },
            style: TextButton.styleFrom(primary: Colors.white),
            icon: const Icon(Icons.add),
            label: const Text('Add'),
          ),
        ],
      ),
      body: lists.isEmpty
          ? const Center(
              child: Text('Click Add to add a new item'),
            )
          : ListView.builder(
              padding: const EdgeInsets.only(bottom: 8),
              itemCount: lists.length,
              itemBuilder: (context, i) {
                final value = lists[i];
                return Dismissible(
                  key: ValueKey<int>(value),
                  onDismissed: (direction) {
                    lists.remove(value);
                    setState(() {});
                  },
                  child: AnimatedItem(value),
                );
              },
            ),
    );
  }
}

class AnimatedItem extends StatefulWidget {
  const AnimatedItem(this.value, {Key? key}) : super(key: key);

  final int value;

  @override
  State<AnimatedItem> createState() => _AnimatedItemState();
}

class _AnimatedItemState extends State<AnimatedItem> {
  double height = 0;
  Color color = Colors.white;
  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
      setState(() {
        height = 50;
        color = Colors.blue[(widget.value % 9 + 1) * 100]!;
      });
    });
  }

  @override
  Widget build(BuildContext context) {
    return AnimatedContainer(
      duration: const Duration(milliseconds: 300),
      curve: Curves.bounceOut,
      height: height,
      margin: const EdgeInsets.only(top: 8, left: 8, right: 8),
      alignment: Alignment.center,
      color: color,
      child: Text('${widget.value}'),
    );
  }
}

尝试

首先想到是不是没有加key的原因 (上面演示代码是加了key的,因为Dismissible必须,最初尝试的时候是用其它方法删除的)?然而尝试了一下,并没有用(key是和元素内容对应的)。 然后想到Stack中不显示的元素保持状态使用了 AutomaticKeepAliveClientMixin 来保持状态,试了下,当然也没有用,实际上场景并不一样。

解决方案

遇到问题第一时间肯定是查文档,看看有没有相关的参数或者说明。然后就发现 ListView.builder/separated 有一个findChildIndexCallback参数,根据名称看,是查child序号的,但是,build里不就是根据序号创建Widget吗?虽然有点疑问,还是试了下这个参数

findChildIndexCallback: (key) {
  final index = lists.indexOf((key as ValueKey<int>).value);
  return index > -1 ? index : null;
},

发现问题解决了:

list_r2.gif

原理解读

本着打破砂锅问到底的精神,当然不能只知道How ,而不知道Why了。 翻了下源码,发现findChildIndexCallback 是传到了SliverChildDelegate里(所以ListView.custom不是直接传findChildIndexCallback,而是传到delegate里),最终由SliverMultiBoxAdaptorElement 的 performRebuild 里调用。 相关代码

for (final int index in _childElements.keys.toList()) {
  final Key? key = _childElements[index]!.widget.key;
  final int? newIndex = key == null ? null : adaptorWidget.delegate.findIndexByKey(key);
  final SliverMultiBoxAdaptorParentData? childParentData =
    _childElements[index]!.renderObject?.parentData as SliverMultiBoxAdaptorParentData?;

  if (childParentData != null && childParentData.layoutOffset != null)
    indexToLayoutOffset[index] = childParentData.layoutOffset!;

  if (newIndex != null && newIndex != index) {
    // The layout offset of the child being moved is no longer accurate.
    if (childParentData != null)
      childParentData.layoutOffset = null;

    newChildren[newIndex] = _childElements[index];
    if (_replaceMovedChildren) {
      // We need to make sure the original index gets processed.
      newChildren.putIfAbsent(index, () => null);
    }
    // We do not want the remapped child to get deactivated during processElement.
    _childElements.remove(index);
  } else {
    newChildren.putIfAbsent(index, () => _childElements[index]);
  }
}

其中 findIndexByKey 是 findChildIndexCallback 的wrapper,未指定会直接返回null 可以看到,未指定callback的情况下,newIndex是null,会直接返回 _childElements对应的index的,所以被删除元素之后的元素是不会对应到原来的Element的,而被删除之前的就可以对应到。这就是为什么被删除元素之前的不会重建,而之后的会重建。 指定findChildIndexCallback之后,所有存在的Widget和Element都可以对应到,就不会触发State重建了。