iOS端在使用WKWebView播放视频并开启画中画功能后,很多团队会在原生层再包一层自定义的悬浮小窗,让用户可以自由拖拽。但拖到屏幕边缘松手时,常见的问题是窗口要么直接贴边停住,要么猛地弹出去一段距离,手感非常生硬。这背后的本质是碰撞发生时的动量传递没有被正确计算:碰撞前后的速度向量发生了变化,如果只是简单地把位置clamp到边界内,而不去处理速度的法向反弹与切向衰减,动画就会显得不自然。本文围绕碰撞后的动量传递计算,从速度采集、法线求解到公式落地,完整讲一遍实现思路。

一、先搞清楚碰撞前后速度分量的物理意义
画中画窗口撞上屏幕边界,可以抽象为一个二维刚体与固定墙面的碰撞。碰撞的关键在于把松手瞬间的速度向量v沿着碰撞法线分解成两个分量:法向分量和切向分量。对于屏幕左边界,法线方向是(1, 0);对于上边界,法线方向是(0, 1)。碰撞后,法向分量按照恢复系数e反转并衰减,切向分量按照摩擦系数衰减,这就是动量传递计算的核心。
恢复系数e的取值范围是0到1。e等于1表示完全弹性碰撞,窗口会以相同速度弹回,视觉上非常跳跃;e等于0表示完全非弹性碰撞,窗口贴边后法向速度归零。实际产品中,画中画小窗一般取0.3到0.5之间,既能感知到轻微回弹,又不会显得失控。切向衰减系数则模拟墙面摩擦,通常取0.7到0.9,让窗口贴着边缘滑动一段距离后自然减速。
需要注意的是,WKWebView自带的AVPictureInPictureController本身不支持自定义拖拽,如果是系统原生画中画,只能通过preferredContentSize做有限调整。要做真正的自定义拖拽边界与动量,一般是自建悬浮容器视图,把WKWebView或者渲染层放进去,再接管手势系统。下文的实现都基于这个自建窗口的前提。
二、手势速度采集与碰撞检测的实现
动量计算的输入是松手瞬间的速度,UIPanGestureRecognizer提供了velocity(in:)方法,返回的是点每秒的速度向量,正好可以直接用。但单次采样噪声较大,建议在手势结束时取最近几帧速度的加权平均,避免偶发的抖动导致窗口突然加速。
碰撞检测则发生在动画的每一帧。使用CADisplayLink驱动动画时,每帧根据当前速度积分出下一帧位置,判断窗口frame是否越过安全边界。一旦越界,就要立刻执行动量传递计算,把修正后的速度写回,并把位置投影回边界内。示例代码如下:
// 碰撞后的动量传递计算
// velocity: 当前速度向量 ( CGPoint )
// normal: 碰撞法线,指向屏幕内侧,如左边界为 (1, 0)
// e: 恢复系数,建议 0.3 ~ 0.5
// mu: 切向摩擦衰减系数,建议 0.7 ~ 0.9
func resolveCollision(velocity: CGPoint, normal: CGPoint,
e: CGFloat, mu: CGFloat) -> CGPoint {
// 法向投影:v · n
let vn = velocity.x * normal.x + velocity.y * normal.y
guard vn < 0 else { return velocity } // 正在离开边界,无需处理
// 切向向量 = v - (v · n) * n
let tx = velocity.x - vn * normal.x
let ty = velocity.y - vn * normal.y
// 法向分量按恢复系数反转,切向分量按摩擦衰减
let newVx = -e * vn * normal.x + mu * tx
let newVy = -e * vn * normal.y + mu * ty
return CGPoint(x: newVx, y: newVy)
}
这段代码里最容易被忽略的是guard vn < 0这一行。如果窗口已经在往边界内运动,说明这一帧的越界是位置修正造成的,而不是真实碰撞,此时再做一次反弹会导致窗口在边缘反复抖动。只有速度方向确实朝向边界外时,才执行动量反转。
另外,每帧积分时建议加入全局阻尼,比如速度每帧乘以0.98,这样窗口滑行会逐渐停下,不会无限漂移。当速度模长低于某个阈值(比如每秒20点)时,直接结束动画并把窗口对齐到最近的吸附位置。
三、边界投影与多角碰撞的处理
窗口撞到屏幕角落时,会同时满足两条边界的越界条件,也就是发生二次碰撞。处理方式有两种:一种是顺序处理,先解X轴碰撞再解Y轴碰撞,简单但可能在极端速度下引入误差;另一种是把两次碰撞的法线合成一个归一化对角法线,一次性求解。实践中推荐顺序处理,因为屏幕角落的对角碰撞在正常拖拽速度下极少出现速度畸变,代码也更清晰。
位置投影本身很简单,用min和max把frame钳制到安全区域内即可。但要注意iOS的安全区域问题:如果窗口允许贴到刘海或底部Home指示条区域,视觉上会被系统UI遮挡。正确做法是用window.safeAreaInsets扩展边界约束,同时在吸附时把"贴边"理解为贴到安全区边缘而不是物理屏幕边缘。
// 每帧动画回调中的位置积分与碰撞处理
func step(dt: CFTimeInterval) {
var frame = pipWindow.frame
frame.origin.x += velocity.x * CGFloat(dt)
frame.origin.y += velocity.y * CGFloat(dt)
let bounds = safeBounds() // 已扣除安全区域的可用范围
var normal: CGPoint? = nil
if frame.minX < bounds.minX {
frame.origin.x = bounds.minX
normal = CGPoint(x: 1, y: 0)
} else if frame.maxX > bounds.maxX {
frame.origin.x = bounds.maxX - frame.width
normal = CGPoint(x: -1, y: 0)
}
if let n = normal {
velocity = resolveCollision(velocity: velocity, normal: n,
e: 0.4, mu: 0.8)
// 触发一次轻微的挤压动画,增强碰撞反馈
}
if frame.minY < bounds.minY {
frame.origin.y = bounds.minY
velocity = resolveCollision(velocity: velocity,
normal: CGPoint(x: 0, y: 1),
e: 0.4, mu: 0.8)
} else if frame.maxY > bounds.maxY {
frame.origin.y = bounds.maxY - frame.height
velocity = resolveCollision(velocity: velocity,
normal: CGPoint(x: 0, y: -1),
e: 0.4, mu: 0.8)
}
pipWindow.frame = frame
velocity.x *= 0.98 // 全局阻尼
velocity.y *= 0.98
}
顺序处理的写法里,Y轴碰撞用的是经过X轴反弹修正后的速度,这正是动量传递链式生效的体现:一次角落碰撞中,X方向的反弹会先发生,Y方向的碰撞则基于新速度计算,物理上更接近真实的多刚体求解顺序。
四、常见回弹异常的排查思路
第一个常见异常是松手后窗口完全不动。排查方向通常是手势速度单位问题,velocity(in:)返回的是点每秒,而如果动画用毫秒做积分,速度会被缩小一千倍,看起来就像没动。第二个异常是弹出去特别远,多半是恢复系数取太大,或者速度采样时把快速甩动那一帧的瞬时速度直接拿来用了,建议对最后三到五帧速度做指数加权平均,权重往最新帧倾斜。
第三个异常是窗口在边缘高频抖动,原因基本都是缺少vn < 0的判断,或者每帧都在重复触发反弹。可以在每次碰撞后加一个很短的冷却时间(比如两帧),期间只做位置钳制不做速度反转,抖动基本就能消除。此外,如果画中画窗口内部是WKWebView,要注意把WKWebView的allowsBackForwardNavigationGestures关掉或做好手势冲突处理,否则系统手势会抢占pan手势,导致速度采样中断,松手时拿到的是残缺的速度值,动量计算自然就不准了。
最后,碰撞反馈不只在速度层面,视觉上配合一次轻微的scale压缩动画(碰撞瞬间窗口沿法线方向压缩到0.96再恢复),能让用户直观感受到撞击,这套物理加视觉的组合才是画中画拖拽体验的关键。