iOS 14之后画中画能力对WKWebView中的网页视频逐步开放,开发者可以通过AVPictureInPictureController让网页播放器悬浮在系统之上。但当产品希望画中画窗口在被拖到屏幕边缘时产生一个带旋转的甩动回弹效果时,系统默认行为就不够用了。这类交互的核心其实是碰撞时刻的角速度处理,而描述这一过程的物理模型正是角冲量定理:作用在刚体上的角冲量等于其角动量的变化量。本文把这套模型拆开讲清楚,并落到具体的实现层面。

一、角冲量定理为什么适合描述画中画窗口的边界碰撞
角冲量定理的表达式非常简洁:∫τdt = ΔL = I·Δω,其中τ是力矩,I是转动惯量,ω是角速度。它的物理含义是:在一个极短的碰撞时间内,力矩的时间积累(角冲量)直接决定了角速度的改变量。画中画窗口撞上屏幕边界这件事,本质上就是一个短时碰撞过程——窗口在极短时间内速度和角速度同时发生突变,用冲量类定理描述比直接用牛顿第二定律微分方程要稳定得多,因为碰撞瞬间力的具体波形无法测量,但其时间积分是确定的。
落到交互层面,当用户快速拖拽画中画窗口甩向屏幕边缘时,松手瞬间窗口带有一个线速度v和一个角速度ω。线速度撞墙后按恢复系数反弹,这个大家都很熟;而角速度部分,由于窗口角点先接触边界,接触点相对质心有一个力臂,碰撞冲量就会对窗口产生一个力矩,进而通过角冲量改变窗口的旋转状态。这就是为什么快速斜着甩过去的窗口,撞边后会自然地出现一个回旋,这个回旋不是随便加个动画就能以假乱真的,必须遵守角动量守恒的约束才不会显得违和。
把窗口近似为质量均匀分布的矩形薄板,转动惯量可以按 I = m(a² + b²)/12 估算,其中a、b是窗口的宽高。实际工程中不需要真实的质量,只需要一个相对量纲来控制手感,通常给m取1,然后通过调整I的系数来控制窗口看起来是轻盈还是笨重。I越大,同样的角冲量产生的Δω越小,窗口旋转变化越迟钝;I越小,窗口转起来越灵敏。这个参数建议做成可配置项,方便设计师参与调参。
二、碰撞时刻的角冲量计算与角速度更新
推导碰撞公式时,设碰撞点为P,窗口质心为C,从C指向P的向量为r,碰撞法线为n。接触点的速度由质心线速度和旋转带来的切向速度共同组成:vP = vC + ω × r。碰撞冲量J沿法线方向作用于P点,对质心产生线速度变化 Δv = J/m,同时产生角冲量 r × J,对应角速度变化 Δω = (r × J) / I。联立恢复系数e的定义(碰撞前后接触点法向速度之比为-e),可以解出冲量大小。
下面这段Swift代码演示了如何在画中画窗口的模拟层实现一次边界碰撞的角冲量更新。由于系统的AVPictureInPictureController不允许直接修改窗口frame,实际做法是在自己的容器层上做一个物理代理视图,动画结束后再把状态同步回真实窗口位置。
struct RigidBox {
var center: CGPoint // 质心位置
var angle: CGFloat // 当前旋转角
var velocity: CGVector // 质心线速度
var angularVelocity: CGFloat // 角速度 ω
let width: CGFloat
let height: CGFloat
// 矩形薄板转动惯量 I = m(a² + b²)/12,质量取1
var inertia: CGFloat {
(width * width + height * height) / 12.0
}
}
func resolveBoundaryCollision(box: inout RigidBox,
contactPoint: CGPoint,
normal: CGVector,
restitution: CGFloat) {
// r:质心指向碰撞点的向量
let rx = contactPoint.x - box.center.x
let ry = contactPoint.y - box.center.y
// 碰撞点速度 = 质心速度 + ω × r(二维下 ω × r = (-ω·ry, ω·rx))
let vpX = box.velocity.dx + (-box.angularVelocity * ry)
let vpY = box.velocity.dy + (box.angularVelocity * rx)
// 接触点沿法线的接近速度
let vn = vpX * normal.dx + vpY * normal.dy
guard vn < 0 else { return } // 正在分离则不处理
// r × n 的z分量,决定冲量的力臂效率
let rnCross = rx * normal.dy - ry * normal.dx
// 有效质量:1/m + (r×n)²/I
let invMass: CGFloat = 1.0
let k = invMass + rnCross * rnCross / box.inertia
// 角冲量定理落地:J = -(1+e)·vn / k
let j = -(1.0 + restitution) * vn / k
// 冲量改变线速度
box.velocity.dx += j * normal.dx * invMass
box.velocity.dy += j * normal.dy * invMass
// 角冲量改变角速度:Δω = (r × J) / I
box.angularVelocity += (rx * (j * normal.dy) - ry * (j * normal.dx)) / box.inertia
}这段代码里有几个值得注意的细节。首先是分离判断,只有接触点还在朝边界内部运动时才施加冲量,否则会反复触发碰撞导致窗口抖动。其次是恢复系数e的取值,画中画窗口这类轻量悬浮体建议取0.3到0.5之间,太大会让窗口像皮球一样弹跳,与iOS整体的克制风格不符。最后是k这个有效质量项,它把线性和旋转两个自由度耦合了起来,正是这一项保证了碰撞前后线速度与角速度的变化严格满足角动量关系,而不是各自独立地衰减。
三、帧循环积分与CADisplayLink的同步渲染
碰撞解决只是物理模拟的一半,另一半是时间积分。画中画窗口的拖拽模拟通常放在CADisplayLink驱动的帧循环里,每帧根据当前速度和角速度推进位置与角度。常用的半隐式欧拉积分先更新速度再更新位置,对这类带约束的模拟稳定性明显好于显式欧拉,配合一个固定的模拟步长(比如每帧最多推进1/120秒的整数倍)可以进一步避免低端机掉帧带来的模拟发散。
class PipPhysicsController {
var box = RigidBox(center: .zero, angle: 0,
velocity: CGVector(dx: 0, dy: 0),
angularVelocity: 0,
width: 320, height: 180)
let displayLink: CADisplayLink?
init() {
displayLink = CADisplayLink(target: self, selector: #selector(step))
displayLink?.add(to: .main, forMode: .common)
}
@objc func step() {
let dt: CGFloat = 1.0 / 60.0
let damping: CGFloat = 0.985 // 线性阻尼
let angularDamping: CGFloat = 0.97 // 旋转阻尼,让回旋更快停下
// 半隐式欧拉:先速度后位置
box.velocity.dx *= damping
box.velocity.dy *= damping
box.angularVelocity *= angularDamping
box.center.x += box.velocity.dx * dt
box.center.y += box.velocity.dy * dt
box.angle += box.angularVelocity * dt
// 检查四个角点是否越界,越界则用角冲量解算
checkCornersAgainstBounds()
// 把模拟结果应用到代理视图
proxyView.transform = CGAffineTransform(translationX: box.center.x,
y: box.center.y)
.rotated(by: box.angle)
}
}旋转阻尼系数要设得比线性阻尼小一些,也就是衰减更快,因为用户对窗口残留旋转的容忍度远低于对滑动的容忍度,一个不停慢转的窗口会显得有故障感。另外角速度要设上限,防止极快的甩动叠加小转动惯量产生每秒数圈的疯狂旋转,一般限制在每秒0.6弧度以内观感最佳。
四、WKWebView环境下的工程注意事项
回到WKWebView这个特定场景,还有几层工程问题要处理。第一,画中画窗口的创建依赖网页内video元素进入播放状态,iOS上需要通过JS注入拿到媒体会话,或者利用AVPictureInPictureController的contentSource关联WebView的windowScene,系统层面才会允许悬浮窗出现。第二,系统画中画窗口本身不受开发者控制,拖拽模拟只能发生在进入画中画之前的App内阶段,或者是自定义的悬浮播放器上;真正想控制PiP窗口位置,只能在窗口贴边吸附阶段介入,iOS 26之前的SDK并没有公开的PiP位置控制接口,这一点要提前和产品侧对齐预期。
第三点是坐标系换算。WKWebView的网页坐标系以逻辑像素计,而物理模拟运行在UIKit坐标系中,两边通过window内坐标转换衔接时要注意safeArea的影响——画中画窗口的边界碰撞边界不应是屏幕物理边缘,而应排除刘海和底部Home指示条区域,否则窗口会被系统手势区域遮挡。建议把碰撞边界定义为 UIScreen.main.bounds.inset(by: safeAreaInsets) 再外扩8到12个点的呼吸空间。
最后是性能与省电的平衡。带旋转模拟的帧循环虽然计算量不大,但在画中画这种长时间悬浮的场景下仍建议做静止检测:当线速度和角速度都低于阈值持续若干帧后,主动invalidate掉CADisplayLink,等下一次拖拽手势开始时再重建。这样窗口静止时不占用任何CPU,对长时间挂视频的用户更友好。整套方案把角冲量定理这一经典力学结论转化成了几十行可维护的代码,既保证了交互的物理正确性,也让参数调优有了明确的理论抓手。