iOS 端使用 WKWebView 加载 H5 视频时,如果开启系统画中画,视频悬浮窗由 AVPictureInPictureController 管理,其拖拽边界回弹效果属于系统私有行为。产品若希望悬浮窗在拖拽到屏幕边缘时呈现类似橡皮筋的阻尼和贴合动画,仅靠系统画中画无法做到。所以需要采用自定义悬浮窗容器,在原生层接管拖拽手势,并通过弹性系数与 spring 动画模拟边界回弹。本文围绕这个思路展开实现。

系统画中画与自定义悬浮窗的边界行为差异
WKWebView 通过 configuration 的 allowsPictureInPictureMediaPlayback 属性控制 HTML5 视频是否能进入系统画中画,但系统画中画窗口的拖拽和回弹完全由 UIKit 内部处理。开发者无法拿到拖拽开始、移动或结束的回调,也不能调整弹簧阻尼、边界吸附距离等参数。这意味着如果 App 的整体动效使用了自定义 spring 曲线,而系统画中画窗口的动画参数与之不一致,会显得割裂。
自定义悬浮窗的思路是:在 WKWebView 之上叠加一个原生容器视图,当视频开始播放并需要小窗显示时,把视频画面通过某种方式转移到容器中。常见做法有两种:一是用原生 AVPlayer 重新加载同一视频地址,与 WKWebView 中的播放进度同步;二是在 WKWebView 上对视频区域进行截图或使用媒体服务,但为了保证清晰度和连续性,通常选择原生播放器接管。这样悬浮窗的拖拽就完全由原生代码控制,可以实现任意想要的边界回弹效果。
如果不想重新加载视频,也可以保留 WKWebView 的播放,仅把 WKWebView 整体移入悬浮容器中,但这样会带来布局和手势冲突。无论哪种方案,核心都在于用 UIPanGestureRecognizer 驱动悬浮窗位置,并计算弹性边界。
拖拽手势与弹性边界计算
拖拽手势的核心是 UIPanGestureRecognizer。将它添加到悬浮窗容器上,在 changed 状态中读取 translation(in:) 或者直接根据手指在屏幕上的位置计算视图中心。更推荐使用 translation 累积方式,因为可以避免初始中心与手指位置之间的偏移问题。每次手势变化时,先重置 translation 为 .zero,再更新 center。
弹性边界计算是回弹效果的关键。当使用者把悬浮窗拖到屏幕边缘之外时,不能直接让窗口停在越界位置,也不能立即把中心拉回边界,而是施加一个阻尼系数,让位移随超过边界的距离逐渐减小。比如设定竖向阻尼为 0.3,横向阻尼为 0.35,表明手指移动 10pt 时视图只向外移动 3pt 到 3.5pt。这样就会产生橡皮筋拉扯感。计算时需要区分上下左右四条边界,并保留窗口尺寸和安全区。
下面这段 Swift 代码演示了在 changed 状态下如何应用阻尼系数更新视图中心位置:
@objc private func handlePan(_ gesture: UIPanGestureRecognizer) {
guard let superview = view.superview else { return }
let translation = gesture.translation(in: superview)
gesture.setTranslation(.zero, in: superview)
var newCenter = CGPoint(
x: view.center.x + translation.x,
y: view.center.y + translation.y
)
let halfWidth = view.bounds.width / 2
let halfHeight = view.bounds.height / 2
let safeFrame = superview.safeAreaLayoutGuide.layoutFrame
let minX = safeFrame.minX + halfWidth
let maxX = safeFrame.maxX - halfWidth
let minY = safeFrame.minY + halfHeight
let maxY = safeFrame.maxY - halfHeight
let damping: CGFloat = 0.35
if newCenter.x < minX {
let overflow = minX - newCenter.x
newCenter.x = minX - overflow * damping
} else if newCenter.x > maxX {
let overflow = newCenter.x - maxX
newCenter.x = maxX + overflow * damping
}
if newCenter.y < minY {
let overflow = minY - newCenter.y
newCenter.y = minY - overflow * damping
} else if newCenter.y > maxY {
let overflow = newCenter.y - maxY
newCenter.y = maxY + overflow * damping
}
view.center = newCenter
}
这个阻尼系数并不是固定的,可以根据距离调整,越往外阻尼越小,形成渐弱弹性。也可以使用非线性函数如 overflow / (1 + overflow / 120) 来获得更自然的极限。同时注意保持中心点不超出安全区过远,避免手势结束后回弹距离过大导致视觉跳跃。
回弹动画与手势结束处理
手势结束时,需要根据当前悬浮窗的位置和手势速度决定最终落脚点。如果悬浮窗未越界且速度较小,回到距离最近的边缘;如果速度较大,则按速度方向吸附到对应边缘。这里提供一种通用做法:先计算窗口中心相对屏幕安全区的横向位置,如果中心点更靠近左半屏,就吸附到左边缘,否则吸附到右边缘。竖向保持在上一次吸附的位置,或者根据使用习惯固定在屏幕上下三分之一处。
实现回弹动画时,UIViewPropertyAnimator 是比 UIView.animate 更灵活的选择,因为可以设置初始速度。右边缘吸附的最终 center 可以计算为 maxX,左边缘为 minX。动画的 spring 参数推荐 dampingRatio 在 0.7 到 0.85 之间,duration 约 0.35 到 0.5 秒。较高的 dampingRatio 能减少回弹次数,与系统画中画的干脆回弹更接近;较低的 dampingRatio 则会有更明显的弹性抖动。
下面的示例展示了手势结束后根据当前位置和速度执行回弹动画:
func animateBackToEdge(currentCenter: CGPoint,
velocity: CGPoint,
in superview: UIView) {
let safeFrame = superview.safeAreaLayoutGuide.layoutFrame
let halfWidth = view.bounds.width / 2
let targetCenterX: CGFloat
if currentCenter.x < safeFrame.midX {
targetCenterX = safeFrame.minX + halfWidth
} else {
targetCenterX = safeFrame.maxX - halfWidth
}
let targetCenter = CGPoint(x: targetCenterX, y: currentCenter.y)
let timing = UISpringTimingParameters(
dampingRatio: 0.78,
initialVelocity: CGVector(dx: velocity.x, dy: velocity.y)
)
let animator = UIViewPropertyAnimator(duration: 0.45, timingParameters: timing)
animator.addAnimations {
self.view.center = targetCenter
}
animator.startAnimation()
}
在结束处理中还应该考虑手势被系统取消的状态 .cancelled 和 .failed,这两种情况通常直接回弹到最近的合法边界,避免悬浮窗停留在半越界状态。另外,如果用户正在横屏或分屏模式下拖拽,安全区布局会变化,需要在 viewDidLayoutSubviews 或旋转回调中重新计算边界并更新约束。
与WKWebView手势协调和实际项目注意事项
在 WKWebView 上层覆盖自定义悬浮窗时,手势冲突往往是最先出现的问题。WKWebView 内部的 UIScrollView 会拦截滑动操作,如果悬浮窗本身较小,拖拽动作可能被误判为网页滚动。解决方式是实现 UIGestureRecognizerDelegate,让悬浮窗的 pan 手势与 WKWebView 的 scrollView 手势同时识别,但需设置 pan 手势的 delegate 返回 true 来允许同时识别,并保证悬浮窗位于最上层且 userInteractionEnabled 为 true。
视频状态同步同样重要。使用原生 AVPlayer 接管播放时,需要从 WKWebView 的 JavaScript 层获取当前播放时间和播放状态。可以通过 WKScriptMessageHandler 注入监听 <video> 的 timeupdate 和 pause 事件,再通过 JavaScriptCore 桥接给原生。同步逻辑要处理缓冲、拖动进度条等场景,否则悬浮窗画面与网页播放进度不一致。
还有内存和释放问题。自定义悬浮窗通常持有 AVPlayer 和 AVPlayerLayer,退出小窗时要及时移除观察者、暂停播放并置空引用,否则会导致音频继续播放或内存泄漏。在页面销毁时,建议调用 stopPictureInPicture 或移除所有子视图,并移除通知监听。
综上,实现自定义画中画窗口拖拽边界回弹的核心在于合理设置阻尼系数和 spring 动画参数,同时处理好手势冲突与播放同步。这样可以弥补系统 PiP 不可定制的短板,让 WKWebView 中的视频小窗交互与 App 整体动效保持一致。