如何使用SwiftUI DragGesture自定义实现ScrollView并匹配原生滚动缓动?
匹配原生ScrollView惯性滚动手感的实现方案
核心认知误区
很多人一开始会尝试找一个固定参数的通用缓动函数套上去,本质是搞错了原生滚动的运动逻辑:
原生iOS的UIScrollView滚动惯性根本不是靠固定时间曲线的UI动画实现的,而是恒定阻力的物理摩擦模型——手指抬起后,滚动速度随时间按固定比例衰减,直到速度降为0才停下,整个过程没有弹簧震荡、没有固定动画时长。
这也是为什么直接用predictedEndTranslation配合普通withAnimation永远对不上手感的原因:predictedEndTranslation本身就是苹果基于这套摩擦模型算出来的停下时的位置,但普通SwiftUI动画的运动逻辑和这个模型不匹配。
近似匹配的简易实现方案
如果不需要做到1:1还原,只想用SwiftUI内置动画凑出足够像的手感,可以按照下面的参数调整:
- 不要用固定时长的动画:动画持续时长要和手指抬起瞬间的速度正相关
- 读取DragGesture自带的
value.velocity.height,也就是抬手瞬间的纵向速度(单位pt/秒) - 时长计算参考公式:
duration = clamp(abs(velocity) * 0.0002, min: 0.15, max: 0.6) - 实际测下来这个区间的时长和原生手感几乎一致:低速抬手时最短0.15s就停下,快速甩动时最长持续0.6s,速度再快也不会拖更长的时间
- 读取DragGesture自带的
- 选择对应匹配的缓动曲线
摩擦模型的速度是随时间线性衰减的,对应的位移曲线是二次方减速,刚好对应SwiftUI内置的贝塞尔曲线参数curve(0, 0, 0.5, 1),用这个曲线跑出来的速度变化和原生摩擦减速的差异人眼几乎感知不到。 - 避坑提示
- 不要直接用默认
spring动画:Spring的弹性过冲特性会让平滑滚动段出现“滑过头再弹回来”的违和感,原生只有碰到滚动边界触发回弹的时候才会有弹性效果,普通滚动段没有过冲 - 不要用固定时长的
easeOut动画:手指慢拖轻轻抬手时,原生滚动几乎立刻停下;快速甩动时滚动会持续接近半秒,固定时长的动画完全匹配不了这种随抬手速度变化的节奏 - 拿到
predictedEndTranslation之后一定要加边界判断:如果预测的停下位置超出了内容可滚动范围,要把动画目标值改成边界值,再单独触发弹性回弹动画
- 不要直接用默认
调整后的代码片段:
.onEnded { value in let handVelocity = abs(value.velocity.height) // 计算匹配原生手感的动画时长 let duration = min(max(handVelocity * 0.0002, 0.15), 0.6) // 匹配摩擦减速的贝塞尔曲线 let inertiaAnimation = Animation.curve( 0, 0, 0.5, 1, duration: duration ) // 边界判断:把超出范围的目标值夹在允许的滚动区间内 let clampedTargetY = clamp(value.predictedEndTranslation.height, min: -maxOffsetY, max: 0) withAnimation(inertiaAnimation) { offsetY = clampedTargetY } }
1:1还原的进阶提示
如果要做到完全和原生无差的手感,不要依赖SwiftUI的withAnimation:
- 用CADisplayLink做逐帧驱动,每帧根据当前速度乘以阻尼系数(推荐值0.98,也就是每帧速度降为原来的98%)计算当前的offset位置
- 速度降到1pt/s以下的时候停止逐帧计算
- 这种实现的额外好处是:惯性滚动过程中如果手指再次按下,可以立刻打断动画,和原生的打断行为完全一致,用
withAnimation做不到这种顺滑的打断效果
内容的提问来源于stack exchange,提问作者Roman Mahotskyi
相关产品推荐
相关产品推荐

