画中画(Picture in Picture)是iOS上很受用户欢迎的能力,视频缩成一个小窗口悬浮在屏幕上,用户可以一边刷页面一边看视频。当这个窗口运行在我们自己的WKWebView容器里时,拖拽交互的细节就完全由我们来控制了。其中最容易被忽略但最能影响手感的一环,是窗口被拖到屏幕边界时的处理方式。这篇文章就来聊聊如何给画中画窗口加上真实感的弹性碰撞,包括完全弹性碰撞与不完全弹性碰撞两种模型的实现。

为什么边界处理决定了拖拽手感
先看最常见的实现:在拖拽回调里对窗口位置做简单钳制。也就是当窗口中心点的x坐标超出屏幕范围时,直接把x设置为边界值。这种写法功能上没问题,但手感非常僵硬——窗口碰到边缘就像撞到一堵水泥墙,瞬间静止,没有任何反馈。
真实世界里的物体撞到墙壁会弹回来,而且反弹速度和撞击速度有关。如果我们希望画中画窗口有这种物理质感,就需要引入碰撞模型。所谓弹性碰撞,核心就是两件事:一是碰撞时速度矢量的反射,二是碰撞后能量的损耗程度。前者决定窗口会不会弹回来,后者决定弹回来的速度有多快。完全弹性碰撞意味着能量没有损耗,反弹速度等于撞击速度;不完全弹性碰撞则引入一个恢复系数,让每次反弹都比上一次慢,最终自然停下。
对于画中画这种UI元素,完全弹性碰撞其实并不适合直接使用,因为窗口会一直在屏幕里弹来弹去停不下来。但它是理解模型的基础,也是实现不完全碰撞的前置知识,所以下面先实现完全弹性碰撞,再在其基础上加入恢复系数。
用速度矢量反射实现完全弹性碰撞
拖拽过程中我们通常只知道手指的位置增量,要实现碰撞需要先把位置增量转换成速度。假设每次拖拽回调间隔是dt,位置变化是dx和dy,那么速度就是vx等于dx除以dt,vy等于dy除以dt。碰撞发生在窗口某一侧越过边界的时候,此时只需把对应方向的速度分量取反,位置分量也按越界深度反射回来,就完成了一次完全弹性碰撞。
来看核心代码。这里用一个自定义的PiPContainerView来承载视频画面,dragVelocity记录当前拖拽速度,碰撞检测写在位置更新方法里:
final class PiPContainerView: UIView {
// 当前拖拽速度,单位是点每秒
var dragVelocity = CGPoint.zero
// 恢复系数,1.0 表示完全弹性碰撞
var restitution: CGFloat = 1.0
private var displayLink: CADisplayLink?
func updatePosition(_ frame: CGRect, in bounds: CGRect) {
var newFrame = frame
var velocity = dragVelocity
// 检测左右边界
if newFrame.minX < bounds.minX {
newFrame.origin.x = bounds.minX
// x方向速度反射,完全弹性碰撞时不衰减
velocity.x = -velocity.x * restitution
} else if newFrame.maxX > bounds.maxX {
newFrame.origin.x = bounds.maxX - newFrame.width
velocity.x = -velocity.x * restitution
}
// 检测上下边界
if newFrame.minY < bounds.minY {
newFrame.origin.y = bounds.minY
velocity.y = -velocity.y * restitution
} else if newFrame.maxY > bounds.maxY {
newFrame.origin.y = bounds.maxY - newFrame.height
velocity.y = -velocity.y * restitution
}
dragVelocity = velocity
self.frame = newFrame
}
}
这段代码里restitution被设为1.0,此时就是完全弹性碰撞。撞击边界时速度大小不变,只改变方向,窗口会以同样的速度弹回来。在纯物理模拟里这样没问题,但在UI场景里你会发现窗口弹个不停,因为没有任何能量损失。这就轮到不完全弹性碰撞登场了。
引入恢复系数实现不完全弹性碰撞
恢复系数(coefficient of restitution)是碰撞理论里的经典概念,取值范围是0到1。它定义为反弹速度与撞击速度的比值:系数为1就是完全弹性碰撞,系数为0则是完全非弹性碰撞,物体撞上去直接贴着边界不动。UI场景里一般取0.4到0.7之间,既保留明显的回弹感,又能让窗口在一两次反弹后自然停止。
实现上只需要把上面代码里的restitution改成小于1的值。但只有速度衰减还不够,我们还需要用CADisplayLink或者UIView动画把惯性运动和反弹过程真正驱动起来,否则拖拽结束时速度就丢失了,看不到弹跳效果。下面是完整的惯性模拟驱动逻辑:
extension PiPContainerView {
func startInertiaAnimation(in bounds: CGRect) {
displayLink?.invalidate()
displayLink = CADisplayLink(target: self, selector: #selector(step(_:)))
displayLink?.add(to: .main, forMode: .common)
}
@objc private func step(_ link: CADisplayLink) {
let dt = CGFloat(link.targetTimestamp - link.timestamp)
var frame = self.frame
// 位置按速度推进
frame.origin.x += dragVelocity.x * dt
frame.origin.y += dragVelocity.y * dt
// 边界碰撞检测,restitution 取 0.55 做不完全弹性碰撞
if frame.minX < 0 || frame.maxX > superview?.bounds.width ?? 0 {
dragVelocity.x = -dragVelocity.x * 0.55
}
if frame.minY < 0 || frame.maxY > superview?.bounds.height ?? 0 {
dragVelocity.y = -dragVelocity.y * 0.55
}
// 钳制到边界内
frame.origin.x = max(0, min(frame.origin.x, (superview?.bounds.width ?? 0) - frame.width))
frame.origin.y = max(0, min(frame.origin.y, (superview?.bounds.height ?? 0) - frame.height))
self.frame = frame
// 速度足够小且没有发生穿透时结束模拟
if abs(dragVelocity.x) < 20 && abs(dragVelocity.y) < 20 {
// 检查是否贴边静止
let nearEdge = frame.minX <= 0 || frame.maxX >= (superview?.bounds.width ?? 0)
if nearEdge { link.invalidate(); displayLink = nil }
}
}
}
这里要注意速度单位的统一。CADisplayLink的targetTimestamp和timestamp差值是秒,而拖拽手势里UIPanGestureRecognizer的velocityInView返回的恰好也是点每秒,两者可以直接配合使用,不需要额外换算。另外,除了碰撞衰减,还应该加一个持续的小阻尼,比如每帧给速度乘以0.98,模拟空气阻力,这样窗口滑动会逐渐变慢,观感更接近原生体验。
两种碰撞模式的对比与调参建议
完全弹性碰撞和不完全弹性碰撞的区别,本质上就是能量是否守恒。前者窗口撞到边界后会以原速返回,适合需要夸张效果的场景,比如小游戏里的弹球元素;后者每次碰撞都损失一部分能量,更符合真实世界的直觉,也更适合画中画这类工具型悬浮窗。下面的表格总结了两种模式的差异:
| 特性 | 完全弹性碰撞 | 不完全弹性碰撞 |
|---|---|---|
| 恢复系数 | 1.0 | 0.4 到 0.7 |
| 反弹速度 | 与撞击速度相同 | 按系数衰减 |
| 最终状态 | 理论上永不停止 | 一两次反弹后静止 |
| 适用场景 | 演示、游戏特效 | 画中画、悬浮窗 |
调参方面有几个经验可以参考。恢复系数低于0.4时回弹感会很弱,用户几乎感知不到碰撞的存在;高于0.7则窗口会在角落来回弹跳多次,显得烦躁。屏幕安全区也要纳入边界计算,iPhone的刘海和底部Home指示条区域应该用safeAreaInsets扩展边界,否则窗口可能被系统UI遮挡。此外,如果画中画窗口支持吸边停靠,建议在速度衰减到某个阈值后接管动画,用UIView的spring动画把窗口吸附到最近边缘,弹性碰撞负责过程,停靠动画负责收尾,两者衔接得当的话手感会非常顺滑。
最后提一个和WKWebView相关的细节。如果画中画是通过AVPictureInPictureController实现的系统级窗口,拖拽行为由系统接管,我们无法干预其碰撞效果;只有当画中画是自绘的悬浮视图(比如在WKWebView之上叠加快照视图或自定义播放层)时,上述碰撞模型才能生效。理解这一点可以避免在错误的方向上浪费时间。
总结
画中画窗口的边界弹性碰撞,说到底就是把物理里的速度反射和恢复系数搬到UI动画里。完全弹性碰撞对应恢复系数为1的场景,速度只改方向不改大小;不完全弹性碰撞通过小于1的恢复系数让每次反弹都损失能量,最终自然静止。用CADisplayLink驱动惯性运动,配合合理的阻尼和阈值收尾,再处理好安全区与吸边停靠的衔接,就能得到一个拖拽手感接近原生应用的画中画窗口。建议先把恢复系数设为0.55跑一遍效果,再根据实际观感微调,这是实践中比较稳妥的起点。