前言
昨天尝试写一个入场/出场动画的组件,写测试代码的时候用到了ListView.builder,发现在移除列表的中间项的时候,后面的元素会刷新State(触发initState),而前面的元素则不会触发initState。最直接的影响就是,被删除元素后面的元素,触发了入场动画。所以一般不用入场动画的场景,关注不到这个问题。 简单写了个动画效果,表现如图:
问题代码
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;
},
发现问题解决了:
原理解读
本着打破砂锅问到底的精神,当然不能只知道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重建了。