今天对音乐 App 有进行了一波优化,修了一个交互问题,感觉挺值得记录一下。
关闭播放器界面,我新增了一个手势下滑关闭,本来预期是页面跟着手指往下走。结果手指一松开,页面上会重新上滑到顶部,然后才开始下滑关闭。
问题出在哪
旧逻辑是这样的:用户往下滑,页面跟着移动一小段距离,松手的时候判断有没有达到阈值,达到了就关闭,没达到就回弹。
功能上没问题,页面确实能被关掉。但问题是用户下滑的第一帧,页面不是从手指位置开始响应的。
更具体地说,页面在某个状态下被重置了。这个重置动作在视觉上就呈现为"上来一点"。
我在代码里找了半天,发现原来手势开始时,页面先把内部状态归零,然后才去计算用户拖了多远。用户在下滑,结果页面的第一个动作是往上跳一下再开始下滑。这感觉就像你在推一扇门,结果门先往后让了一下再被你推走。
旧代码的问题
旧实现大致是这样的:
GestureDetector( onVerticalDragStart: (details) { // 记录起始位置 }, onVerticalDragUpdate: (details) { // 根据增量更新偏移量 final delta = details.primaryDelta ?? 0; _offset += delta; setState(() {}); }, onVerticalDragEnd: (details) { // 判定是否关闭 if (_offset > threshold) { Navigator.pop(context); } else { _offset = 0; setState(() {}); } }, child: Transform.translate( offset: Offset(0, _offset), child: pageContent, ),)问题在哪?用手势的增量累加偏移量,而不是用手指的真实位移。当手势仲裁发生时,增量累加的起点和手指真实位置不同步,第一帧就会出现跳变。
怎么改的
核心思路变了:不再把下滑手势当成"触发关闭的信号",而是把它当成"用户直接控制页面位置"。
手势开始的时候,直接从手指按下的位置开始计算,不去重置任何内部状态。手势结束的时候,从当前位置继续下滑关闭,或者从当前位置回弹。不存在"重新开始"这个概念。
手势层用 GestureDetector 监听垂直拖动,更新时直接用手指的真实位移,而不是增量累加。动画层用 AnimationController.unbounded() 表达像素级位移,拖动时直接更新控制器的值,结束时从当前位置继续动画。
具体是这样:
首先定义控制器和必要的变量:
late final AnimationController _dismissController = AnimationController.unbounded( vsync: this,);
double _startY = 0;double _currentOffset = 0;static const double _dismissThreshold = 200.0;static const Duration _dismissDuration = Duration(milliseconds: 300);手势开始时,记录手指按下的真实位置:
onVerticalDragStart: (details) { _startY = details.globalPosition.dy;},手势更新时,用当前 globalPosition.dy 减去起始位置得到真实位移,这个值直接赋给控制器:
onVerticalDragUpdate: (details) { _currentOffset = details.globalPosition.dy - _startY; _dismissController.value = _currentOffset.clamp(0.0, screenHeight);},手势结束时,判断是继续下滑还是回弹:
onVerticalDragEnd: (details) { final velocity = details.primaryVelocity ?? 0; final current = _dismissController.value;
if (current > _dismissThreshold || velocity > 500) { // 从当前位置继续向下完成关闭动画,动画完再 pop _dismissController.animateTo( screenHeight, duration: _dismissDuration, curve: Curves.easeOut, ).then((_) => Navigator.pop(context)); } else { // 从当前位置弹回原位 _dismissController.animateBack( 0.0, duration: _dismissDuration, curve: Curves.easeOut, ); }},页面的位移通过 AnimatedBuilder 绑定到控制器:
AnimatedBuilder( animation: _dismissController, builder: (context, child) { return Transform.translate( offset: Offset(0, _dismissController.value), child: child, ); }, child: pageContent,)改完之后
下滑的第一帧就对上了,页面从手指真实位置开始响应,不再有那个往上跳的动作。
还有一个问题也顺便解决了:之前播放器里的 PageView、歌词页这些内部内容的滚动手势,经常和关闭手势冲突。改了之后,关闭手势只绑定在顶部标题区,内部内容继续保留自己的滚动,内外不再打架。
松手之后的行为也顺了很多。之前是"达到阈值就关闭,没达到就回弹",两个动作之间有明显的切换感。现在是从当前位置继续动画,用户能看清"自己推下去的页面继续离场",而不是被某个条件突然截断。
总的来说,优化前后的核心差异很明显:优化前,用户是在“触发”一个关闭指令;优化后,用户更像是在“亲手操作”页面离场,交互感更直观、更自然。