-
概述
在原生应用使用列表的过程中,我们有时会遇到左滑删除和添加item的动画效果的需求,其实仔细想想我们可以自己封装一个包含添加删除动画效果的List组件,比如以左滑删除来说,给每个Item设置一个手势检测,当符合左滑动作的时候给当前Item执行一个移除的动画效果,在动画结束的时候按照新的数组去构建新的列表界面。
在Flutter中有一个现成的组件帮我们封装好了这样的需求,就是AnimatedList,它的内部实现的思路和我们设想的差不多,来看一下它是怎么做的。
-
基本使用
为了由浅入深,我们先不管左滑等更复杂的操作组合,我们只是单纯地给列表的添加和移除Item添加动画效果,这也是AnimatedList能做到的地方。
A scrolling container that animates items when they are inserted or removed.
基本构造:
List
data = [ "Item 11", "Item 22", "Item 33", "Item 44", "Item 55", "Item 66", "Item 77", "Item 88", "Item 99", "Item 10", ]; final animatedListKey = GlobalKey (); @override Widget build(BuildContext context) { return AnimatedList( key: animatedListKey, itemBuilder: (context, index, animation) { return buildItem(data[index], animation)); }, initialItemCount: data.length, ); } //构造Item Widget Widget buildItem(String label, Animation animation) { return SlideTransition( position: animation .drive(CurveTween(curve: Curves.easeIn)) .drive(Tween(begin: Offset(-1, 0),end: Offset(0,0))), child: SizedBox( height: 80.0, child: Card( color: Colors.primaries[item % Colors.primaries.length], child: Center( child: Text(label, style: textStyle), ), ), ), ); key必须是一个全局变量,我们会使用它来完成添加删除;itemBuilder是必须参数,AnimatedList通过它来构造页面上最终要显示的Item Widget;平时开发中我们很少直接和key打交道,这里我们在添加和移除Item的时候必须要用到它;initialItemCount表示初始化的Item数量。
如果不需要动画,buildItem方法中你可以返回任何一个Widget,但是如果要使用动画的话必须要用一个可以使用动画的Widget,即构造的Widget得把传过来的animation用上才行。
再来看添加和删除操作:
//删除 String removedText = data.removeAt(index); AnimatedList.of(context).removeItem(index, (context, _animation) { return buildItem(removedText, _animation); },); //animatedListKey.currentState!.removeItem(index, // (context, _animation) { // return buildItem(removedText, _animation); //},); //添加 data.insert(data.length, "New Item"); animatedListKey.currentState!.insertItem(data.length-1,);
未注释掉的删除Item的代码是在AnimatedList构造上下文中获取的,它会找到父级中最近的一个AnimatedList的State,在非上下文中(比如抽出来的方法中)获取AnimatedList的State则需要key来获取,可以看到,添加Item代码中用到了key来获取。
-
原理
AnimatedList是StatefulWidget,它的State—AnimatedListState中,build方法如下:
@override Widget build(BuildContext context) { return CustomScrollView( scrollDirection: widget.scrollDirection, reverse: widget.reverse, controller: widget.controller, primary: widget.primary, physics: widget.physics, shrinkWrap: widget.shrinkWrap, clipBehavior: widget.clipBehavior, slivers:
[ SliverPadding( padding: widget.padding ?? EdgeInsets.zero, sliver: SliverAnimatedList( key: _sliverAnimatedListKey, itemBuilder: widget.itemBuilder, initialItemCount: widget.initialItemCount, ), ), ], ); } 可以看到,其实返回的就是一个CustomScrollView,我们知道,CustomScrollView是Sliver模式的布局组件,我们看到它的sliver属性指定了一个数组,数组中唯一的元素是SliverPadding,它的sliver是SliverAnimatedList,所以我们得去SliverAnimatedList中看。
它的State是SliverAnimatedListState,build方法如下:
@override Widget build(BuildContext context) { return SliverList( delegate: _createDelegate(), ); }
再往里找,SliverList的createElement方法会返回一个SliverMultiBoxAdaptorElement,SliverMultiBoxAdaptorElement中有一个 _build方法,这个方法会在createChild中调用,createChild是sliver布局流程中自动调用的,用于构建新的Item,关于sliver的布局流程我们这里就点到这,我们来看 _build方法:
Widget? _build(int index) { return widget.delegate.build(this, index); }
可以看到,这里调用了delegate的build方法来构建Item Widget,我们会到构造SliverList的地方,返现delegate是通过 _createDelegate方法生成的:
SliverChildDelegate _createDelegate() { return SliverChildBuilderDelegate(_itemBuilder, childCount: _itemsCount); }
它的build方法中调用了 _itemBuilder来创建Item:
Widget? child; try { child = builder(context, index); } catch (exception, stackTrace) { child = _createErrorWidget(exception, stackTrace); }
兜了一圈我们会到了SliverAnimatedListState的 _itemBuilder函数:
Widget _itemBuilder(BuildContext context, int itemIndex) { final _ActiveItem? outgoingItem = _activeItemAt(_outgoingItems, itemIndex); if (outgoingItem != null) { return outgoingItem.removedItemBuilder!( context, outgoingItem.controller!.view, ); } final _ActiveItem? incomingItem = _activeItemAt(_incomingItems, itemIndex); final Animation
animation = incomingItem?.controller?.view ?? kAlwaysCompleteAnimation; return widget.itemBuilder( context, _itemIndexToIndex(itemIndex), animation, ); } _incomingItems和 _outgoingItems都是 _ActiveItem类型的数组,分别用来暂时保存插入的新Item和要删除的Item的创建数据,后面我们会看到为什么这么说。他们默认都是空数组,所以正常的流程中这里会走widget.itemBuilder创建Widget,也就是我们前面构造AnimatedList时指定的itemBuilder函数。
其实,这两种方式的返回就是对应删除Item和添加Item的构造,我们来看一下为什么这么说。
我们先看insertItem方法,AnimatedListState的insertItem方法就是调用SliverAnimatedListState的insertItem方法,因为AnimatedListState也是持有了一个SliverAnimatedListState的GlobalKey:
void insertItem(int index, { Duration duration = _kDuration }) { assert(index != null && index >= 0); assert(duration != null); final int itemIndex = _indexToItemIndex(index); assert(itemIndex >= 0 && itemIndex <= _itemsCount); // Increment the incoming and outgoing item indices to account // for the insertion. for (final _ActiveItem item in _incomingItems) { if (item.itemIndex >= itemIndex) item.itemIndex += 1; } for (final _ActiveItem item in _outgoingItems) { if (item.itemIndex >= itemIndex) item.itemIndex += 1; } final AnimationController controller = AnimationController( duration: duration, vsync: this, ); final _ActiveItem incomingItem = _ActiveItem.incoming( controller, itemIndex, ); setState(() { _incomingItems ..add(incomingItem) ..sort(); _itemsCount += 1; }); controller.forward().then
((_) { _removeActiveItemAt(_incomingItems, incomingItem.itemIndex)!.controller!.dispose(); }); } 调用insertItem的时候可以传入一个Duration来设置动画的时长,默认的_kDuration是300毫秒, _itemsCount表示列表中的项数,初始值就是前面传入的initialItemCount,AnimatedList就是通过它来决定显示多少Item。
_indexToItemIndex方法会跳过 _outgoingItems中位于新添加的Item之前的项,因为正在删除的项最终是不占用列表位置的,所以把它们跳过:
int _indexToItemIndex(int index) { int itemIndex = index; for (final _ActiveItem item in _outgoingItems) { if (item.itemIndex <= itemIndex) itemIndex += 1; else break; } return itemIndex; }
前面两个for循环会把待插入的Item和待删除的Item数组中排在要插入的Item后面的Item给整体往后移一个位置,避免在其他Item插入或删除的时候引起位置混乱。
然后这里会构造一个AnimationController,然后会构造一个_ActiveItem对象, _ActiveItem就是该项的构造数据,内部持有AnimationController,然后调用setState方法,在setState方法中会把构造的 _ActiveItem添加到 _incomingItems中,同时 _itemsCount加1。
我们知道,调用setState之后会重新构造组件数,所以再次调用build方法后又会走到 _itemBuilder函数中,上面我们可以看到, _itemBuilder函数中会通过 _activeItemAt方法找到之前添加的 _ActiveItem,然后把它传给widget的itemBuilder函数,这样就把animation给透传出去了,从而外面自定义的Item组件可以应用它。
动画设置好了就需要开启,在insertItem方法的最后我们看到调用了controller的forward方法,then方法保证会在动画结束后移除掉 _incomingItems中的 _ActiveItem,上面创建的AnimationController的lowerBound和upperBound都没有设置,所以都是默认值,所以forward会从0到1变化。
同理,再来看removeItem方法:
void removeItem(int index, AnimatedListRemovedItemBuilder builder, { Duration duration = _kDuration }) { assert(index != null && index >= 0); assert(builder != null); assert(duration != null); final int itemIndex = _indexToItemIndex(index); assert(itemIndex >= 0 && itemIndex < _itemsCount); assert(_activeItemAt(_outgoingItems, itemIndex) == null); final _ActiveItem? incomingItem = _removeActiveItemAt(_incomingItems, itemIndex); final AnimationController controller = incomingItem?.controller ?? AnimationController(duration: duration, value: 1.0, vsync: this); final _ActiveItem outgoingItem = _ActiveItem.outgoing(controller, itemIndex, builder); setState(() { _outgoingItems ..add(outgoingItem) ..sort(); }); controller.reverse().then
((void value) { _removeActiveItemAt(_outgoingItems, outgoingItem.itemIndex)!.controller!.dispose(); // Decrement the incoming and outgoing item indices to account // for the removal. for (final _ActiveItem item in _incomingItems) { if (item.itemIndex > outgoingItem.itemIndex) item.itemIndex -= 1; } for (final _ActiveItem item in _outgoingItems) { if (item.itemIndex > outgoingItem.itemIndex) item.itemIndex -= 1; } setState(() => _itemsCount -= 1); }); } 不一样的是,这里会尝试先调用_removeActiveItemAt方法从 _incomingItems中获取要移除的项的构造数据,这个是针对有正在被插入的项同时此时需要移除时的情况,AnimationController也会尝试从这个里面取,如果不是这种情况则创建新的AnimationController,需要注意的是,这里指定了value的值为1,结合最后会调用controller的reverse方法来看就不难理解,这里默认是以插入动画的反向动画作为移除动画的。
在动画结束后会把正在插入和正在移除的且在当前要移除项之前的项的构造数据的位置整体往前移一个,这和前面插入的时候一样的目的,最后动画结束还会把 _itemsCount减1,这些操作放在动画结束之后是因为动画结束之后才算移除完成,才会刷新成新界面。
_outgoingItems中添加该移除项的 _ActiveItem之后回到 _itemBuilder函数中,这时候会通过第一种方式创建,即通过outgoingItem的removedItemBuilder函数,这个就是调用removeItem方法时传入的builder函数,在上面我们可以看到,我们通常插入Item和移除Item都会调用同一个方法创建,这样也会保证插入动画和移除动画在视觉上是彼此对应的反向动画,这也和框架默认设计是一致的,当然你完全可以在插入和移除时创建不同的widget,这取决于你,只不过这样的需求我想象不到有什么应用场景。
-
AnimatedList总结
AnimatedList的插入动画会把动画和新界面更新同时进行,因为它们不会干预到彼此,而移除动画不一样,它是先构造出移除之前的界面,然后执行动画,等动画结束后再刷新一遍新界面,这和Android的RecyclerView的动画原理如出一辙。
其次,AniamtedList内部不会维护任何的数据集,这和外部的数据集是解耦的,它只会根据 _itemCount去调用 _itemBuilder函数多次来创建Item,所以具体Item Widget需要展示什么数据都是外部通过index设置的,所以我们使用AnimatedList只需要保证插入和删除前后的 _itemCount和数据集的长度是对应的就好,上面可以看到,我在Item Widget的构造方法buildItem里传入的都是具体要显示的数据而不是index,如果传递的是index,那么Widget中引用数据集肯定会用data[index]去获取,而data数据集要手动根据插入删除情况随时调整,动画执行中或者结束后的刷新时引用的data就会不一样,所以此时使用index获取就会出错。
-
左滑删除
左滑删除其实和AnimatedList的原理是相互独立的,这里贴一下我是怎么实现的:
GestureDetector( onHorizontalDragEnd: (dragEndDetails) { print(dragEndDetails.velocity); if (dragEndDetails.velocity.pixelsPerSecond.dx < -1200) { String removedText = data.removeAt(index); AnimatedList.of(context).removeItem(index, (context, _animation) { return buildItem(removedText, _animation); },); } }, child: buildItem(data[index], animation));
可以看到,我们只需要给Item套一个手势监听,判断一下左滑事件和速度即可。