今天对音乐 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、歌词页这些内部内容的滚动手势,经常和关闭手势冲突。改了之后,关闭手势只绑定在顶部标题区,内部内容继续保留自己的滚动,内外不再打架。
松手之后的行为也顺了很多。之前是"达到阈值就关闭,没达到就回弹",两个动作之间有明显的切换感。现在是从当前位置继续动画,用户能看清"自己推下去的页面继续离场",而不是被某个条件突然截断。
总的来说,优化前后的核心差异很明显:优化前,用户是在“触发”一个关闭指令;优化后,用户更像是在“亲手操作”页面离场,交互感更直观、更自然。