在 iOS 应用内实现视频画中画窗口时,WKWebView 经常被用作播放页面的容器。开发者通常会通过 UIPanGestureRecognizer 让悬浮窗跟随手指移动,并在接近屏幕边缘时施加阻尼或弹簧约束。但实际测试下来,窗口在边界处要么像撞上一堵墙瞬间停顿,要么带着剩余动能来回滑动,手感与物理世界相去甚远。差异的根源在于真实的接触面并不是只有一个阻尼系数,而是同时存在静摩擦力、动摩擦力和粘滞阻力。本文将三者引入边界碰撞检测,用状态切换和实时速度计算改善拖拽表现,并给出可直接落地的 Swift 代码。

一、边界碰撞需要区分三种摩擦状态
静摩擦力描述的是物体刚要滑动前的那一个临界阻力。对画中画窗口来说,当用户把窗口拖到屏幕边缘,手指速度非常低,窗口尚未穿透边界时,应该让窗口停在边界线上,而不是因为手指的微小抖动就进入边界区域。这个阶段可以用一个最大静摩擦阈值来表示,当外力分量低于阈值时,窗口保持静止。阈值越高,窗口越不容易越过边界,但过高会让人感觉拖不动。
动摩擦力则发生在窗口已经越过边界并继续滑动的阶段。它的方向始终与当前速度相反,大小可以是恒定值,也可以随速度变化。动摩擦力的作用是把窗口越界时的那部分动能尽快消耗掉,防止它滑出太远。如果没有动摩擦力,光靠弹簧约束,窗口会在边界外做长距离衰减振荡,体验非常不自然。
粘滞阻力主要用于低速段。窗口回到边界附近时,速度已经很小,此时弹簧力和数值误差很容易造成来回抖动。粘滞阻力与速度成正比,速度越小阻力越小,能够在不影响正常拖拽的前提下,把最后一丝振动压下去。三种力不是同时作用,而是根据速度和穿透深度在不同状态间切换,这样才能模拟真实的接触手感。
二、速度估计与摩擦力计算公式
UIPanGestureRecognizer 的 velocity(in:) 方法返回值是瞬时速度,直接使用噪声较大,容易导致摩擦力频繁换向。实际处理时,可以用 CADisplayLink 维护一组位置序列,通过相邻两帧的位移除以时间差得到速度,再做一阶低通滤波。滤波公式可以写成 v = oldVelocity × alpha + instantVelocity × (1 - alpha),alpha 取 0.8 到 0.9 之间,兼顾响应速度和平滑度。
静摩擦力的判断条件通常写作:当速度的绝对值小于速度阈值 v_eps 且窗口边界穿透量小于 delta 时,不进行越界位置更新。这里可以把最大静摩擦力折算为一个位移阈值,例如 2 像素。穿透量的计算取决于边界定义,比如屏幕左右边界是 x 方向,上下边界是 y 方向,用 min(max(target, minX), maxX) 夹逼后求出超出的部分。
动摩擦力与粘滞阻力可以合并到同一个速度更新里。设当前速度 v,动摩擦系数 μ_k,粘滞系数 c,则合力产生的速度衰减为 a = -μ_k × sign(v) - c × v。每帧根据 a 更新速度,再更新位置。由于窗口质量抽象为 1,系数的大小直接对应像素级的减速能力。μ_k 决定滑出距离,c 决定低速收敛速度。
三、WKWebView 画中画窗口的代码实现
下面的代码基于一个自定义的 PiPContainerView,内部持有 WKWebView。手势使用 UIPanGestureRecognizer,边界设置为屏幕 bounds 减去安全区域。状态枚举 staticFriction、kineticFriction、viscousDamping 分别对应静摩擦力、动摩擦力和粘滞阻力阶段。为了简化,位置更新直接操作 center 属性。
import UIKit
import WebKit
enum FrictionState {
case staticFriction
case kineticFriction
case viscousDamping
}
class PiPContainerView: UIView {
let webView = WKWebView()
var state: FrictionState = .staticFriction
var boundaryInsets = UIEdgeInsets(top: 44, left: 16, bottom: 44, right: 16)
var dragStartCenter: CGPoint = .zero
var smoothedVelocity: CGPoint = .zero
static let velocityThreshold: CGFloat = 15.0
static let penetrationThreshold: CGFloat = 2.0
static let kineticFriction: CGFloat = 0.35
static let viscousDamping: CGFloat = 6.0
override init(frame: CGRect) {
super.init(frame: frame)
addSubview(webView)
webView.frame = bounds
let pan = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:)))
addGestureRecognizer(pan)
let displayLink = CADisplayLink(target: self, selector: #selector(updateFriction))
displayLink.add(to: .main, forMode: .common)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@objc func handlePan(_ pan: UIPanGestureRecognizer) {
switch pan.state {
case .began:
dragStartCenter = center
smoothedVelocity = .zero
case .changed:
let translation = pan.translation(in: superview)
let target = CGPoint(x: dragStartCenter.x + translation.x,
y: dragStartCenter.y + translation.y)
let clamped = clampToBounds(target)
let penetration = distanceBetween(target, clamped)
let instantVelocity = pan.velocity(in: superview)
smoothedVelocity = CGPoint(x: smoothedVelocity.x * 0.8 + instantVelocity.x * 0.2,
y: smoothedVelocity.y * 0.8 + instantVelocity.y * 0.2)
if abs(smoothedVelocity.x) < Self.velocityThreshold &&
abs(smoothedVelocity.y) < Self.velocityThreshold &&
penetration < Self.penetrationThreshold {
state = .staticFriction
center = clamped
} else {
state = .kineticFriction
center = target
}
case .ended, .cancelled:
state = .viscousDamping
default:
break
}
}
@objc func updateFriction() {
guard state != .staticFriction else { return }
let clamped = clampToBounds(center)
if state == .kineticFriction {
let frictionX = Self.kineticFriction * (smoothedVelocity.x > 0 ? 1 : -1)
let frictionY = Self.kineticFriction * (smoothedVelocity.y > 0 ? 1 : -1)
smoothedVelocity.x -= frictionX
smoothedVelocity.y -= frictionY
} else if state == .viscousDamping {
smoothedVelocity.x *= (1 - Self.viscousDamping / 60.0)
smoothedVelocity.y *= (1 - Self.viscousDamping / 60.0)
}
center = clampToBounds(CGPoint(x: center.x + smoothedVelocity.x / 60.0,
y: center.y + smoothedVelocity.y / 60.0))
let penetration = distanceBetween(center, clamped)
let speed = hypot(smoothedVelocity.x, smoothedVelocity.y)
if speed < 1.0 && penetration < 0.5 {
state = .staticFriction
center = clamped
smoothedVelocity = .zero
} else if speed < Self.velocityThreshold {
state = .viscousDamping
}
}
func clampToBounds(_ point: CGPoint) -> CGPoint {
guard let superview = superview else { return point }
let minX = boundaryInsets.left
let maxX = superview.bounds.width - boundaryInsets.right
let minY = boundaryInsets.top
let maxY = superview.bounds.height - boundaryInsets.bottom
return CGPoint(x: min(max(point.x, minX), maxX),
y: min(max(point.y, minY), maxY))
}
func distanceBetween(_ a: CGPoint, _ b: CGPoint) -> CGFloat {
return hypot(a.x - b.x, a.y - b.y)
}
}
代码在手势 changed 阶段根据穿透量和速度阈值决定进入静摩擦状态还是动摩擦状态。当手指释放时,状态切换为粘滞阻尼,由 CADisplayLink 每帧衰减速度。updateFriction 中还对低速且几乎无穿透的情况做了收敛,直接将窗口吸附回边界并清零速度,避免在边界线上的微小漂移。
四、参数调优与手感测试
三个系数的调节没有一个万能值,需要根据悬浮窗尺寸和产品预期来定。静摩擦阈值建议从 2 像素开始,手指速度阈值可以设在 10 到 20 点每秒。如果窗口在边缘处很难推动,说明静摩擦阈值过大,可以降低到 1 像素;如果窗口稍微一碰就越界,说明阈值太小。动摩擦系数一般取 0.2 到 0.5,它决定窗口越界后能滑出多远。动摩擦越大,滑出距离越短,但过大也会让窗口像被粘在边界外。
粘滞阻力系数 c 影响低速收敛速度。c 越大,回弹抖动消失得越快,但过大会让正常的慢速拖拽变得生硬。实际测试时可以把 c 从 2 调到 10 观察差异。另一个容易被忽略的是速度估计的滤波系数,alpha 太高会让状态切换迟滞,太低则噪声大。通常 0.8 左右比较均衡。
另外需要说明的是,系统 AVPlayerViewController 提供的原生画中画窗口,其拖拽和边界行为由系统管理,开发者无法插手脚。本文的方案适用于应用内自定义悬浮窗,即用 WKWebView 承载视频内容并自己实现拖拽的场景。若遇到设备性能较差导致 CADisplayLink 掉帧,可以在 updateFriction 中根据实际时间差缩放位移,而不是固定除以 60。