乐趣区

Flutter自定义实现神奇的卡片切换视图

前言
这一段时间,Flutter 的势头是越来越猛了,作为一个 Android 程序猿,我自然也是想要赶紧尝试一把。在学习到动画的这部分后,为了加深对 Flutter 动画实现的理解,我决定把之前写的一个卡片切换效果的开源小项目,用 Flutter“翻译”一遍。
废话不多说,先来看看效果吧:

Android
iOS

Github 地址:https://github.com/BakerJQ/Fl…
思路
首先,关于卡片的层叠效果,在原 Android 项目中,是通过 Scale 差异以及 TranslationY 来体现的,Flutter 可以继续采用这种方式。
其次,对于自定义卡片的内容,原 Android 项目是通过 Adapter 实现,对于 Flutter,则可以采用 IndexedWidgetBuilder 实现。
最后,就是自定义动效的实现,原 Android 项目是通过一个 0 到 1 的 ValueAnimator 来定义动画的展示过程,而 Flutter 中,正好有与之对应的 Animation 和 AnimationController,如此我们就可以直接自定义一个动画过程中,具体的视图展示方式。
组件总览
由于卡片视图需要根据动画情况进行渲染,所以显然是一个 StatefulWidget。
同时,我们给出三种基本的动画模式:
enum AnimType {
TO_FRONT,// 被选中的卡片通过自定义动效移至第一,其他的卡片通过通用动效补位
SWITCH,// 选中的卡片和第一张卡片互换位置,并都是自定义动效
TO_END,// 第一张图片通过自定义动效移至最后,其他卡片通过通用动效补位
}
并通过 Helper 和 Controller 来处理所有的动画逻辑
其中 Controller 由构造方法传入
InfiniteCards({
@required this.controller,
this.width,
this.height,
this.background,
});
Helper 在 initState 中进行构建,并初始化,同时将 Helper 绑定给 Controller:
@override
void initState() {

_helper = AnimHelper(
controller: widget.controller,
// 传入动画更新监听,动画时调用 setState 进行实时渲染
listenerForSetState: () {
setState(() {});
});
_helper.init(this, context);
if (widget.controller != null) {
widget.controller.animHelper = _helper;
}
}
而 build 过程中,则通过 Helper 返回具体的 Widget 列表,而 Stack 则是为了实现层叠效果。
Widget build(BuildContext context) {

return Container(

child: Stack(
children: _helper.getCardList(_width, _height),
),
);
}
如此,基本的初始化等操作就算是完成了。下面我们来看看 Controller 和 Helper 都是怎么工作的。
Controller
我们先来看看 Controller 所包含的内容:
class InfiniteCardsController {
// 卡片构造器
IndexedWidgetBuilder _itemBuilder;
// 卡片个数
int _itemCount;
// 动画时长
Duration _animDuration;
// 点击卡片是否触发切换动画
bool _clickItemToSwitch;
// 动画 Transform
AnimTransform _transformToFront,_transformToBack,…;
// 排序 Transform
ZIndexTransform _zIndexTransformCommon,…;
// 动画类型
AnimType _animType;
// 曲线定义(类 Android 插值器)
Curve _curve;
//helper
AnimHelper _animHelper;

void anim(int index) {
_animHelper.anim(index);
}
void reset(…) {

// 重设各参数
setControllerParams();
_animHelper.reset();

}
}
由此可以看到,Controller 基本上就是作为参数配置器和 Helper 的方法代理的存在。由此童鞋们肯定就知道了,对于动效的自定义和动效的触发等操作,都是通过 Controller 来完成,demo 如下:
// 构建 Controller
_controller = InfiniteCardsController(
itemBuilder: _renderItem,
itemCount: 5,
animType: AnimType.SWITCH,
);
// 调用 reset
_controller.reset(
itemCount: 4,
animType: AnimType.TO_FRONT,
transformToBack: _customToBackTransform,
);
// 调用展示下一张卡片动画
_controller.reset(animType: AnimType.TO_END);
_controller.next();
关于具体的自定义,我们稍后再聊,咱们先来看看 Helper。
Helper
Helper 是整个动画效果实现的核心类,我们先看几个它所包含的核心成员:
class AnimHelper {
final InfiniteCardsController controller;
// 切换动画
AnimationController _animationController;
Animation<double> _animation;
// 卡片列表
List<CardItem> _cardList = new List();
// 需要向后切换的卡片,和需要向前切换的卡片
CardItem _cardToBack, _cardToFront;
// 需要向后切换的卡片位置,和需要向前切换的卡片位置
int _positionToBack, _positionToFront;
}
现在我们来看看,如果要触发一个切换动画,这些成员是如何相互配合的。
当选中一张卡片进行切换时,这张卡片就是需要向前切换的卡片(ToFront),而第一张卡片,就是需要向后切换的卡片(ToBack)。
void _cardAnim(int index, CardItem card) {
// 记录要切换的卡片
_cardToFront = card;
_cardToBack = _cardList[0];
_positionToBack = 0;
_positionToFront = index;
// 触发动画
_animationController.forward(from: 0.0);
}
由于设置了 AnimationListener,在动画过程中,setState 就会被调用,如此就会触发 Widget 的 build,从而触发 Helper 的 getCardList 方法。我们来看看在切换动画的过程中,是如何返回卡片 Widget 列表的。
List<Widget> getCardList(double width, double height) {
for (int i = 0; i < controller.itemCount; i++) {

if (_isSwitchAnim) {
// 处理切换动画
_switchTransform(width, height, i);
}

}
// 根据 zIndex 进行排序渲染
List<CardItem> copy = List.from(_cardList);
copy.sort((card1, card2) {
return card1.zIndex < card2.zIndex ? 1 : -1;
});
return copy.map((card) {
return card.transformWidget;
}).toList();
}
如上代码所示,先进行动画处理,后根据 zIndex 进行排序,因为要保证在前面的后渲染。
而动画是如何处理的呢,以切换到前面的卡片为例:
void _toFrontTransform(double width, double height, int fromPosition, int toPosition) {
CardItem cardItem = _cardList[fromPosition];
controller.zIndexTransformToFront(
cardItem, _animation.value,
_getCurveValue(_animation.value),
width, height, fromPosition, toPosition);
cardItem.transformWidget = controller.transformToFront(
cardItem.widget, _animation.value,
_getCurveValue(_animation.value),
width, height, fromPosition, toPosition);
}
原来,正是在这一步,Helper 通过 Controller 中配置的自定义动画方法,得到了卡片的 Widget。
由此,动画展示的基本流程就描述完了,下面我们进入最关键的部分 – 如何自定义动画。
自定义动画
我们以通用动画为例,来看看自定义动画的主要流程。
首先,AnimTransform 为如下方法的定义:
typedef AnimTransform = Transform Function(
Widget item,// 卡片原始 Widget
double fraction,// 动画执行的系数
double curveFraction,// 曲线转换后的系数
double cardHeight,// 整体高度
double cardWidth,// 整体宽度
int fromPosition,// 卡片开始位置
int toPosition);// 卡片要移动到的位置
该方法返回的是一个 Transform,专门用于处理视图变换的 Widget,而我们要做的,就是根据传入的参数,构建相应系数下的 Widget。以 DefaultCommonTransform 为例:
Transform _defaultCommonTransform(Widget item,
double fraction, double curveFraction, double cardHeight, double cardWidth, int fromPosition, int toPosition)
// 需要跨越的卡片数量 {
int positionCount = fromPosition – toPosition;
// 以 0.8 做为第一张的缩放尺寸,每向后一张缩小 0.1
//(0.8 – 0.1 * fromPosition) = 当前位置的缩放尺寸
//(0.1 * fraction * positionCount) = 移动过程中需要改变的缩放尺寸
double scale = (0.8 – 0.1 * fromPosition) + (0.1 * fraction * positionCount);
// 在 Y 方向的偏移量,每向后一张,向上偏移卡片宽度的 0.02
//-cardHeight * (0.8 – scale) * 0.5 对卡片做整体居中处理
double translationY = -cardHeight * (0.8 – scale) * 0.5 –
cardHeight * (0.02 * fromPosition – 0.02 * fraction * positionCount);
// 返回缩放后,进行 Y 方向偏移的 Widget
return Transform.translate(
offset: Offset(0, translationY),
child: Transform.scale(
scale: scale,
child: item,
),
);
}
对于向第一位移动的选中卡片,也是同理,只不过是根据该卡片对应的转换器来进行自定义动画的转换。
最后的效果,就像演示图中第一次点击,图片向前翻转到第一位的效果一样。
总结
由于 Flutter 采用的是声明式的视图构建方式,在编码初期,多少会受到原生编码方式的思维影响,而觉得很难受。但是在熟悉了之后,就会发现其实很多思想都是共通的,比如 Animation,比如插值器的概念等等。
另外,研读源码仍然是最有效的解决问题的方式,比如相比 Android 中直接对 ScrollView 进行 animateTo 操作,在 Flutter 中需要通过 ScrollController 进行 animateTo 操作,正是这一点让我找到了在 Flutter 中实现 InfiniteCards 效果的方法。
更具体的 Demo 请前往 Github 的 Flutter-InfiniteCards Repo,欢迎大家 star 和提 issue。
再次贴一下 Github 地址:https://github.com/BakerJQ/Fl…

退出移动版