1. 手势动画从哪开始:先分清"手势识别"和"动画驱动"
前阵子有个朋友问我,Flutter里做一个"按住卡片、跟着手指动"的效果,是不是加个GestureDetector然后onPanUpdate里setState就够了?他说他做了,但实测下来画面一顿一顿的,松手以后也没有任何惯性,跟原生体验差了一大截。
这个问题很有代表性,几乎每个Flutter开发者到某个阶段都会卡在"手势动画"这道坎上。你以为你在做动画,其实你只是在响应事件;你以为框架帮你处理了所有跟手逻辑,其实它只给了你一个事件流,剩下的物理感、节奏感、回弹与惯性,全都要自己搭。
先把话说清楚:Flutter的手势动画,核心不是动画,而是手势数据怎么变成界面状态。开发者的核心工作不是"写一个动画",而是构造一条从手指到画面的数据管道。这条管道分三段:手势识别层、状态更新层、动画渲染层。任何一段出问题,最终表现就是"不跟手""卡顿""手感生硬"。
这篇我按自己实际开发中沉淀下来的路径来讲,先理清手势识别层的三个组件,再讲数据管道的构建,最后给一个完整的卡牌堆叠案例,外加我踩过的坑和调参经验。适合已经会用GestureDetector做基础点击、但对"手势驱动动画"还停留在setState阶段的开发者,也适合那些在列表里做拖拽交互总翻车的读者。
1.1 很多初学者把GestureDetector当成了万能药
GestureDetector确实是Flutter手势体系里最常用的入口,但它本质上是一个声明式的识别器封装,帮你把"点按""滑动""缩放"这些常见手势做了封装,然后通过回调告诉你结果。问题在于,它给的回调是事件级的,不是状态级的。onPanUpdate每次返回的DragUpdateDetails里带着当前的全局/局部位移量,但你拿到这个位移量之后要做什么,框架完全不管。
大多数新手会这样写:
dart复制Offset _position = Offset.zero;
GestureDetector(
onPanUpdate: (details) {
setState(() {
_position += details.delta;
});
},
child: Transform.translate(
offset: _position,
child: card,
),
)
这段代码能跑,但有两个隐患:第一,setState会让整个State的build方法重新执行,如果卡片周围还有列表、图片、阴影等重组件,帧开销会很难看;第二,松手之后没有任何"结束动画",卡片就停在原地,体验上像一个没有惯性的网页拖拽,而不是一个原生App里该有的手势交互。
1.2 核心思路:手势是输入,动画是输出
我把手势动画拆成三个环节:
- 输入阶段:通过
GestureDetector、Listener或RawGestureDetector拿到手指的按下、移动、抬起事件。 - 数据阶段:把这些事件转换成界面状态,通常是一个
Offset、一个角度值、一个缩放比例。 - 输出阶段:让这些状态以"可动画的方式"作用到Widget上,并且松手后继续通过物理模拟或插值动画驱动状态变化。
如果只说记住一句话,那就是:**手势过程的跟手要实时,手势结束之后的动作要动画。**实时跟手可以用setState或ValueNotifier,但"结束后的动作"必须交给AnimationController。
1.3 适用读者与前置知识
在继续往下读之前,你需要对Flutter的基础Widget(Container、Transform、Stack、Positioned)、StatefulWidget的生命周期、GestureDetector的基本用法有概念。如果这些还不太熟,建议先跑一遍官方文档的交互教程再回来。接下来我会以"卡牌拖拽"这个场景为主线,把手势动画从识别到渲染全链路拆开讲。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 手势层三兄弟:GestureDetector、Listener、GestureRecognizer怎么分工
Flutter的手势体系其实有三层,很多人只用过第一层,遇到复杂场景就卡住了。先把这三层搞明白,后面碰到拖拽冲突、多手势竞争这类问题时,才知道该去哪一层做文章。
2.1 GestureDetector:声明式常用手势的瑞士军刀
GestureDetector是最高层封装,适合识别离散型手势和方向型手势。所谓的离散型手势包括onTap、onDoubleTap、onLongPress,它们的特点是手指离开屏幕后才会判定结果;方向型手势则包括onHorizontalDragStart、onVerticalDragStart、onPanStart以及对应的Update/End回调,它们从手指按下之后就开始进入判定流程。
日常开发中,GestureDetector够用到什么程度呢?按钮点击、长按删除、图片放大缩小、列表里一行数据左右滑删除,这些都能做。它的回调用起来也直观,比如onTapDown会给你一个TapDownDetails,里面有globalPosition和localPosition,足以应付绝大多数需求。
但它有个天生的短板:它不给你原始事件流。你想知道手指在屏幕上每一帧的像素级移动轨迹,GestureDetector给不了那么细,它给的是经过"手势竞技场"裁定之后的结果。这个"裁定"过程会引入延迟,在个别场景下会让人感觉跟手不够直接。
2.2 Listener:绕过识别,直接拿原始事件
Listener是更底层的东西,它不关心你是点击、滑动还是缩放,它只负责把PointerDownEvent、PointerMoveEvent、PointerUpEvent、PointerCancelEvent这些原始指针事件一层层往上抛。
在Listener的回调里,你可以拿到event.localPosition和event.delta,而且这个回调几乎是即时的,没有手势竞技场的等待过程。拖拽卡片这种"必须跟手"的场景,用Listener反而比GestureDetector更直接。
dart复制Listener(
onPointerDown: (event) {
// 记录按下位置
},
onPointerMove: (event) {
// 直接更新卡片位移,实时性最高
},
onPointerUp: (event) {
// 触发结束动画
},
child: card,
)
有人问,那GestureDetector的onPanUpdate和Listener的onPointerMove到底有什么区别?onPanUpdate在"手势判定成功"之后才会持续回调,判定过程中的移动量会被吞掉一部分(这部分叫touch slop,下面细讲);Listener则是一次不落,每个move事件都给你。所以"跟手度"上,Listener更硬核,但代价是你得自己处理滑动方向判断、点击与拖拽的区分,天然没有GestureDetector省心。
2.3 GestureRecognizer与手势竞技场:Flutter怎么判断"谁该响应"
第三层是GestureRecognizer。它负责把原始指针事件解析成具体的手势语义。Flutter为每种基础手势都实现了对应的Recognizer,比如TapGestureRecognizer、LongPressGestureRecognizer、PanGestureRecognizer、ScaleGestureRecognizer。
多个Recognizer同时存在时,Flutter会通过一个叫**手势竞技场(GestureArena)**的机制来裁决到底谁胜出。你可以想象一个擂台,每次手指按下,所有可能的手势成员都上台,系统观察手指后续的移动、抬起动作,一旦某种手势的特征足够明确,就宣布它胜出,其余的全部失败。
比如一个GestureDetector同时配置了onTap和onVerticalDrag,但你的手指按下后迅速上下移动了30像素,竞技场会判定这是一次纵向拖拽,onTap就输掉,不会触发。反过来手指按下后很快抬起,onTap胜出,拖拽赢不了。这套机制的好处是开发者不用自己写"如果移动超过N像素就算拖拽"的逻辑,框架帮你做了。
但它也带来一个问题:当你想同时识别"拖拽卡片"和"列表自身的滚动"时,竞技场的裁决结果不一定是你想要的。这种情况下,要么给手势配置GestureRecognizer的team,要么直接用RawGestureDetector接管底层。这部分后面第5章我会详细展开。
2.4 做手势动画,我建议用哪个
我的选择是:跟手阶段用Listener,结束阶段用AnimationController,复杂情况下用RawGestureDetector。GestureDetector不是不能用,而是你要清楚它背后的裁定延迟。如果你的场景是"卡片跟随手指移动"这种强跟手需求,我更推荐用Listener拿原始移动数据;而如果场景是"双击点赞""长按呼出菜单",用GestureDetector会省很多事。
另外提一个经常被忽略的点:Android和iOS的手势系统差异。Android系统层的GestureDetector有touch slop的概念,也就是说手指必须移动超过一定阈值(通常是8dp)才会判定为拖拽,Flutter也沿用了这套逻辑。这意味着你用onPanUpdate的时候,如果手指只移动了3个像素,系统认为这还是"按下未决定"状态,不会触发你想要的拖动回调。而Listener没有这个问题,它从一开始就在发事件,所以强跟手场景下用Listener的体感更"跟手"。
3. 从手指坐标到动画帧:一条清晰的数据管道
理解了手势识别层的分工,接下来就是重头戏:怎么把手势数据变成动画。
3.1 手势动画的三个阶段:down、update、up
任何一只手指的完整操作,都可以抽象成三个阶段:按下(down)、移动(update)、抬起(up)。手势动画的逻辑,本质上就是给这三个阶段分别设置对应的处理策略:
- 按下阶段:记录起始状态,暂停正在进行的动画。比如卡片正在回弹,你一把按住它,必须先取消回弹动画,让卡片处于"被你控制"的状态。
- 移动阶段:把手指的位移实时转换为视觉状态。这个阶段不需要动画控制器,要的是实时、平滑、不过度延迟地刷新。
- 抬起阶段:根据当前速度和位置,决定接下来的动画去向。比如回到原位、飞出屏幕、弹一下后停在某个位置。
这是手势动画最核心的思维模型。你把每个阶段想清楚,代码写起来就条例清晰;想不清楚,就容易写出"按住了但卡片还在自动回弹""抬起来之后卡顿一下才动"这种问题。
3.2 为什么不能每次都setState重建整棵树
我在第1章提到过,onPanUpdate里直接setState会导致整棵Widget树从当前State的build重新执行。如果页面结构简单,影响不大;但如果是一个照片卡片流,卡片上叠加了图片、渐变、文字、阴影,每次移动都触发全量build,帧率掉到40甚至30以下是很正常的。
正确的做法是缩小重建范围。常见的方案有两种:
- 用
ValueNotifier<Offset>保存位置数据,然后用ValueListenableBuilder只重建需要跟随手势的Widget。 - 用
AnimationController的addListener直接更新一个可变的Transform,压根不走build。
对绝大多数场景来说,方案1已经够用且更符合Flutter哲学。写法也很简单:
dart复制final ValueNotifier<Offset> positionNotifier = ValueNotifier(Offset.zero);
ValueListenableBuilder<Offset>(
valueListenable: positionNotifier,
builder: (context, position, _) {
return Transform.translate(
offset: position,
child: card,
);
},
)
手指移动时,你只需要更新positionNotifier.value,只有Transform.translate那一小块会重建,卡片周围的列表、背景、其他子Widget都不会受到影响。
3.3 动画控制器和Ticker:让手势动起来
提到动画就绕不开AnimationController。它的底层是一个Ticker,这个Ticker会在每一帧VSync信号到来时回调,让你有机会根据时间推进动画值。
手势动画里,AnimationController的典型用法是:
- 在
down阶段,把controller.stop()掉,让控制器不再驱动位置。 - 在
move阶段,直接通过ValueNotifier更新位置,或者更新controller.value(如果你把controller的value当作位置的话)。 - 在
up阶段,根据"当前速度+当前位置"计算目标值,然后controller.animateTo(target, curve: ...)。
这里有个容易困惑的点:为什么移动阶段不用AnimationController?因为AnimationController天生是"时间驱动"的,它每一帧根据时间推进插值;而手势移动阶段是"事件驱动"的,手指移动多少,我们就跟着更新多少。把事件驱动硬塞进时间驱动里,会引入一帧左右的延迟,而且需要额外处理插值曲线,得不偿失。
3.4 手势结束后的"惯性"处理
抬起手指之后,动画系统才开始真正接管。最朴素的结束动画是"回到原位":从当前位置用Curves.easeOut插值回到Offset.zero。这个实现最简单,但体验比较生硬——因为真实世界的物体被推动后不会瞬间停住,它会滑行、减速,甚至反弹。
Flutter为这类效果准备了物理模拟器,核心有FrictionSimulation(摩擦力减速)、SpringSimulation(弹簧回弹)、ClampingScrollSimulation(滑动列表的钳制运动)。这些模拟器的输入往往是"当前位置+当前速度",输出是"未来每个时间点的位置序列"。你可以用AnimationController驱动一个虚拟的Animation<double>,然后在此基础上叠加物理模拟,也可以直接使用AnimatedBuilder监听物理模拟每一步产生的值去更新UI。
以最简单的回弹为例:
dart复制controller.animateTo(
0,
duration: Duration(milliseconds: 400),
curve: Curves.easeOutBack, // 带一点过冲回弹
);
Curves.easeOutBack的特点是在到达终点时会出现轻微的"越过再回弹",就像被拉住的橡皮筋松开后的感觉。这个在和卡片类手势配合时非常好看。
4. 实战拆解:做一个Tinder风格的照片卡片拖拽动画
前面讲的都是原理,可能有点抽象,这章我们直接做一个完整的案例:**一个照片卡片,可以跟手拖动,拖动过程中会倾斜旋转,松手时如果位移超过阈值就飞出屏幕,否则回弹到中心。**这是很多交友类、学习类、打卡类App里常见的交互,也是手势动画的经典考题。
4.1 需求分析与数据结构设计
先想清楚我们需要哪些数据:
dragOffset:卡片的水平/垂直位移。rotationAngle:卡片旋转角度,通常与水平位移成正比。isDragging:当前是否处于拖拽状态。settleController:松手后驱动回弹/飞出的动画控制器。
我给卡片定义一个简单的状态类:
dart复制class CardGestureState {
CardGestureState({
required this.offset,
required this.rotation,
});
final Offset offset;
final double rotation;
}
用ValueNotifier<CardGestureState>来保存状态,每次手势更新时生成新状态并赋值给notifier。ValueListenableBuilder监听并驱动卡片渲染。
4.2 跟手拖拽与旋转联动
卡片跟手拖拽的实时逻辑可以这样写:
dart复制class DraggableCard extends StatefulWidget {
const DraggableCard({super.key, required this.child});
final Widget child;
@override
State<DraggableCard> createState() => _DraggableCardState();
}
class _DraggableCardState extends State<DraggableCard>
with SingleTickerProviderStateMixin {
late final ValueNotifier<CardGestureState> _stateNotifier;
// 松手后的回弹/飞出动效
late final AnimationController _settleController;
Animation<Offset>? _settleAnimation;
@override
void initState() {
super.initState();
_stateNotifier = ValueNotifier(const CardGestureState(offset: Offset.zero, rotation: 0));
_settleController = AnimationController.unbounded(vsync: this)
..addListener(_onSettleTick);
}
void _onSettleTick() {
final value = _settleController.value;
final offset = _settleAnimation!.value;
_stateNotifier.value = CardGestureState(
offset: offset,
rotation: offset.dx / 200, // 角度与位移联动
);
}
void _onPointerDown(PointerDownEvent event) {
_settleController.stop(); // 停掉正在进行的回弹
}
void _onPointerMove(PointerMoveEvent event) {
final baseOffset = _stateNotifier.value.offset;
final newOffset = baseOffset + event.delta;
_stateNotifier.value = CardGestureState(
offset: newOffset,
rotation: newOffset.dx / 200,
);
}
void _onPointerUp(PointerUpEvent event) {
_startSettle();
}
...
}
这段代码里最关键的是rotation: newOffset.dx / 200,它让卡片的倾斜角度和水平位移成正比。除数是200,表示卡片水平移动200像素时旋转约1弧度(约57度)。我在实际项目里一般控制在0.8到1.2弧度之间,看起来更自然,不会旋转得头重脚轻。
4.3 松手后的决策:回弹还是飞出
这里需要一个"临门一脚"的判定:如果卡片当前位移已经超过了某个阈值(比如屏幕宽度的三分之一,或者当前速度超过某个值),就让它飞出屏幕;否则就回弹到中心。
用代码表达就是:
dart复制void _startSettle() {
final state = _stateNotifier.value;
final shouldFlyOut =
state.offset.distance > 200 || state.offset.dx.abs() > 200;
if (shouldFlyOut) {
_flyOut();
} else {
_springBack();
}
}
回弹我用的是Curves.easeOutBack,飞出去用的则是直线加一点点旋转增强动感。飞出的目标点不再是中心,而是卡片宽度乘以1.5的某个方向,这样看起来像是被甩出屏幕外。
dart复制void _flyOut() {
final state = _stateNotifier.value;
final direction = state.offset.dx > 0 ? 1.0 : -1.0;
final targetOffset = Offset(direction * 600, state.offset.dy + 100);
_settleAnimation = Tween<Offset>(
begin: state.offset,
end: targetOffset,
).animate(CurvedAnimation(
parent: _settleController,
curve: Curves.easeIn,
));
_settleController
..duration = const Duration(milliseconds: 500)
..forward(from: 0);
}
注意这里我把_settleController定义成unbounded,因为AnimationController默认的value范围是0到1,而我们要控制的偏移量往往远超这个范围。使用unbounded之后,我们手动通过Tween把0到1的进度映射到偏移量的起始与终点。
4.4 完整代码与关键参数
把所有零件拼起来,一个核心可复用的拖拽卡片组件就完成了。这里我把关键参数整理成一个表格,方便你按自己的场景微调:
| 参数 | 建议初始值 | 作用 | 调参思路 |
|---|---|---|---|
| 旋转除数 | 200 | 位移与角度的比例 | 值越大旋转越小,值越小越"飘" |
| 飞出阈值 | 200像素 | 判断回弹还是飞出 | 越小越容易飞出,越大越难甩走 |
| 回弹时长 | 400ms | 松手到回弹完成的时间 | 350~500ms区间手感最好 |
| 回弹曲线 | easeOutBack | 回弹过程的插值曲线 | 想要大过冲就用Curves.easeOutBack |
| 飞出时长 | 500ms | 飞出动画时长 | 越快越"干脆",越慢越"黏手" |
| 飞出曲线 | easeIn | 飞出过程的插值曲线 | 飞出适合easeIn,先慢后快 |
实际开发中有一个我特别想强调的点:Listener的onPointerMove里event.delta在个别设备上会发生"跳动",尤其是手写笔或部分低端安卓机。解决方式是先给event.delta做一个低通滤波,也就是把新的delta和上一次的delta做加权平均,让轨迹更平滑:
dart复制final smoothedDelta = (event.delta * 0.3) + (_lastDelta * 0.7);
这样的代价是灵敏度稍微降低,但跟手度和稳定性会好很多。需要注意,这个滤波的逻辑放在手势状态类里,别混进UI层。
5. 手势冲突与局部刷新:这里最容易翻车
卡片拖拽你以为到上一章就结束了?太天真了。真实项目里,卡片往往不在一个空页面上,而是在列表里、Tab里、甚至嵌在ScrollView里。这时候,手势冲突才是最大的坑。我在这上面翻过好几次车,把经验教训都写下来。
5.1 GestureArena的真实裁决流程
第2章提过手势竞技场,但真正理解它需要知道完整的裁决流程。
当手指按下时,Flutter会把所有"可能识别该手势"的GestureRecognizer拉进竞技场。这个动作是从命中测试(HitTest)的结果里收集的,也就是说,你的Widget树里每一层的GestureDetector都可能参与竞技场。然后,手指移动过程中,竞技场成员会不断地根据移动数据判断自己是否符合特征。比如HorizontalDragGestureRecognizer发现水平位移超过阈值,就宣布自己胜出并拒绝对手;TapGestureRecognizer在手指按下的那一刻暂时hold住,但只要手指开始移动,它就会放弃。
问题是,竞技场的裁定是默认方向优先的。比如在垂直列表中,VerticalDragGestureRecognizer通常会在水平拖拽之前胜出,因为列表滚动是垂直方向。如果你的拖拽卡片也使用了PanGestureRecognizer(没有方向限制),那么它和列表的VerticalDragGestureRecognizer就会产生竞争,谁胜出不一定。
表现就是:你在列表里拖卡片,有时候列表先动了,卡片的拖拽压根没生效;有时候卡片先动了,列表就滚不动了。两种结果都很抓狂。
5.2 垂直列表里放拖拽卡片:冲突怎么解决
我踩了几次坑之后,总结了三个有效方案:
方案A:把手势判定限制在卡片自己的区域内,并取消卡片自身的PanGestureRecognizer,改用Listener直接处理指针事件。
Listener不走竞技场,它不参与手势识别,因此不会和列表滚动冲突。但问题在于,如果卡片在列表中,而列表仍然会响应拖拽,你怎么判断用户是想滚动列表还是拖拽卡片?我的思路是:在卡片内做手势方向的判定。
比如,用户按在卡片上快速上下滑动,这是"列表滚动"的意图;用户按住卡片横向拖动,这是"拖拽卡片"的意图。在Listener的回调里,我们根据自己的判定决定是消费掉事件还是放行给列表。这个模式在iOS原生叫"gestureRecognizer delegate",在Flutter里则需要手动管理。
dart复制Offset _downPosition;
void _onPointerDown(PointerDownEvent event) {
_downPosition = event.position;
}
void _onPointerMove(PointerMoveEvent event) {
final dx = event.position.dx - _downPosition.dx;
final dy = event.position.dy - _downPosition.dy;
// 如果水平位移明显大于垂直位移,判定为拖拽卡片
if (dx.abs() > 12 && dx.abs() > dy.abs()) {
// 拖拽逻辑
}
}
方案B:用单个GestureDetector包裹整个列表,手动控制onVerticalDrag和onHorizontalDrag的行为。
这个方案适合"整页就是一个可拖拽卡片栈"的场景,比如交友软件的主界面。你不需要列表滚动,只需要卡片拖拽,那就在页面上直接放一个GestureDetector做onPanUpdate或onHorizontalDragUpdate,同时内置向上滑的判定。
方案C:改造ScrollView的physics,在某些状态下禁用列表滚动。
如果卡片拖拽只发生在特定区域,可以监听手势阶段,在卡片被拖拽时把列表的NeverScrollableScrollPhysics临时替换为ClampingScrollPhysics,松手后再切回来。这个方案代码上比较暴力,但胜在简单直接。
5.3 RepaintBoundary与AnimatedBuilder:性能优化三板斧
手势动画的另一大坑是性能。Listener已经只更新局部了,但如果你把Transform.translate放在一个大Container里,Container的decoration和child都会被波及。这时候你需要一个RepaintBoundary来隔离重绘区域。
RepaintBoundary的作用是告诉Flutter:这块区域如果发生了变化,不需要重绘整棵RenderObject树,只需要单独重绘我自己。对于卡片手势这种高频重绘的场景,给卡片套一层RepaintBoundary是常规操作。
dart复制RepaintBoundary(
child: ValueListenableBuilder<CardGestureState>(
valueListenable: _stateNotifier,
builder: (context, state, child) {
return Transform.rotate(
angle: state.rotation,
child: Transform.translate(
offset: state.offset,
child: child,
),
);
},
child: widget.child, // 这里child是不变的,避免每次都重建
),
)
注意这里我用了一个小技巧:把不变的内容通过ValueListenableBuilder的child参数传入,builder里只处理变换。这样即使手势状态每秒更新一百次,卡片内部的图片、文字这些重逻辑不会跟着重建。这是一个非常实用的优化,很多Flutter性能问题都是从这个细节上潜移默化缓解的。
另外,Transform本身是图层(Layer)级变换,它只是改变了绘制时的矩阵,不会触发Paint的重新布局。所以优先用Transform而不是Align、Padding这类会重新布局的组件。
6. 物理与手感:把"跟手"调成"顺手"
手势动画做完、能跑、不卡,只能算及格。真正拉开体验差距的,是"手感"。同样是一张卡片,有的App拖起来像拖一块磁铁,有的像拖一片羽毛,差异全在物理模拟的参数调校上。
6.1 Flutter自带的物理模拟器
Flutter在physics包里有几个很有用的类:
FrictionSimulation:模拟摩擦力造成的减速运动。你给一个初始位置和速度,它会生成一条位置随时间递减的曲线。SpringSimulation:模拟理想的弹簧运动。你可以设置弹簧的刚度(spring constant)、阻尼(damping),从而模拟从"慢慢悠悠地晃"到"干脆地弹回"的各种手感。ClampingScrollSimulation:模拟滚动列表的钳制运动,Android的ClampingScrollPhysics底层就是它。BouncingScrollSimulation:模拟弹性滚动,iOS的BouncingScrollPhysics用它。
使用物理模拟器的最佳姿势是:把手势的结束速度传给模拟器,然后驱动一个AnimationController去渲染模拟的结果。比如手指松开的瞬间,我们从Listener的onPointerUp里能拿到event.delta,结合一个时间间隔估算出速度,然后把这个速度传给SpringSimulation,让弹簧把卡片弹回原位。
一个简化版的手势结束速度估算:
dart复制void _onPointerMove(PointerMoveEvent event) {
final now = DateTime.now();
final dt = now.difference(_lastMoveTime).inMilliseconds.clamp(1, 32) / 1000.0;
_velocity = Offset(event.delta.dx / dt, event.delta.dy / dt);
_lastMoveTime = now;
_lastDelta = event.delta;
}
这样在onPointerUp时,_velocity就是一个可用的速度向量。这个值对后面的飞出距离、回弹力度都有直接影响。
6.2 常用参数参考与手感调校
这里给出一组我在实际项目中常用并验证过的手感参数:
| 手感类型 | 使用场景 | 推荐参数 |
|---|---|---|
| 硬脆 | 卡片快速飞出/关闭 | 飞出时长500ms,曲线easeIn |
| 柔弹 | 卡片回弹/弹窗关闭 | 回弹时长400ms,曲线easeOutBack |
| 粘滞 | 长按拖拽/移动列表项 | 拖动时阻尼0.7,rotation除数200 |
| 轻快 | 整版卡片切换 | 飞出时长450ms,回弹时长350ms |
还有几个细节:
- 曲线只用于定性,实际还要配合距离动态决定时长。如果卡片已经拖到屏幕边缘,松手后飞出时长应该短一些;如果只拖了一点点,回弹可以稍微慢一点,营造"慢慢滑回去"的从容感。
- 不要在所有平台上用同一套参数。iOS用户习惯略微带一点阻尼和弹性,Android用户则习惯干脆一点的反馈。当然这个差异是细枝末节,但在竞品测评中会成为"细节控"的说辞。
- 手势过程中不要加Curves。跟手阶段必须线性——手指在哪儿,卡片就在哪儿。任何曲线插值放在跟手阶段都会造成视觉偏差,这就是"不跟手"的最大来源。
6.3 平台差异:iOS和Android的触摸手感差距
触摸手感差异的背后是系统触摸采样率和事件投放策略的不同。Android主流设备的触摸采样率普遍在120Hz到240Hz之间,而iOS设备通常锁在120Hz;但iOS的触摸预测(touch prediction)做得更好,Flutter也提供了PointerEvent的predictedEvents机制,你可以从onPointerMove的event.predictedEvents里读取系统预测的下一帧位置。
这个读取方法在iOS上效果不错,但在部分Android设备上并不可靠。我的建议是:**做通用拖拽时先不依赖predictedEvents,如果真需要极致跟手,再用它做位置预判并夹带在滤波逻辑里。**否则,在个别设备上反而会出现"位置跳变"的副作用。
最终的手感调校没有捷径,只能是真机、多机型、反复调。我在公司里都是准备一台iPhone和一台千元安卓机,切换着试,因为Android机触摸采样率低时,很多在iOS上完美的参数都会露馅。
最后分享几个在项目里沉淀下来的习惯
做手势动画这几年,有几个习惯让我少踩了很多坑。最后集中分享一次:
第一,手势动画的数据管道不要和业务状态混在一起。卡片拖多远、旋转多少度,是"界面表现状态",和业务里的"是否已点赞""当前处于第几章"没半毛钱关系。把它们分开,页面复杂度会直线下降。
第二,给手势上锁。有些卡片在飞出动画进行中时,不应该再响应新的拖拽。你可以在状态类里加一个isAnimating字段,在onPointerDown里判断,如果正在动画就忽略这次按下。这个细节不做,会出现"动画中乱拖"的交互bug,反复出现还不好定位。
第三,测试手势动画别用模拟器。模拟器的鼠标事件和真实触摸的采样率、压力模型完全不同。我用模拟器测试时一切正常,一到真机就发现onPointerMove的触发频率远高于模拟器,导致滤波逻辑和速度估算全乱套。手势动画的调试,从第一天就一定要用真机。
如果把手势动画比作做菜,GestureDetector是给你配好的预制菜包,一加热就能吃;而Listener加AnimationController加物理模拟,才是你真正掌勺的过程。手艺好不好,就看你愿不愿意从预制菜包里走出来,去理解火候和调味。Flutter给了你完整的工具链,这个自由度,恰恰是它比很多跨端方案更适合做交互动效的原因。
