在 iOS 应用开发中,当使用 WKWebView 加载视频页面并启用系统画中画功能后,很多团队都会遇到一个棘手的问题:画中画悬浮窗口上的手势操作十分不稳定。具体表现为用户尝试拖动小窗、双击小窗进行全屏切换时,手势有时无响应,有时又会触发错误的行为,甚至与 App 内自定义的边缘滑动手势、下拉刷新手势产生冲突。排查到最后,几乎都会指向同一个源头——手势识别器优先级设置不当。

本文将从 UITouch 事件传递机制出发,解释为什么 WKWebView 的画中画窗口会与普通视图的手势识别发生竞争,然后给出几种经过验证的解决方案,包括调整手势代理、设置手势依赖关系、重写关键判断方法等。读者可以直接将这些思路应用到自己的项目中,避免上线后因手势冲突导致的用户投诉。
问题现象与根因分析
WKWebView 在 iOS 上支持画中画模式,当用户点击视频上的画中画按钮后,系统会创建一个独立的悬浮窗口。这个窗口由系统进程管理,但触摸事件仍然会经过当前 App 的 UIWindow 和 UIApplication 事件分发体系。也就是说,画中画窗口并不是一个完全隔离的视图,它同样受到 UIWindow 中手势识别器的影响。
默认情况下,WKWebView 内部为了支持视频播放器控制条、画中画悬浮窗拖拽等操作,已经注册了多种系统手势识别器,例如 UIPanGestureRecognizer、UIPinchGestureRecognizer、UITapGestureRecognizer 等。如果开发者在 App 的根视图或某个容器视图上添加了自己的手势识别器,比如全屏返回手势、边缘滑动菜单手势、长按手势等,这些手势会和系统的手势同时监听同一个触摸序列。UIKit 默认的手势竞争规则是:当多个手势识别器同时识别一个触摸序列时,系统会根据视图层级、手势类型以及代理方法返回的布尔值来决定哪个手势最终生效。如果开发者没有明确设置优先级,系统手势往往因为更靠近触摸目标或者具有更高的默认优先级而抢占事件,导致自定义手势失效;但有时候又会出现相反的情况,自定义手势拦截了画中画窗口的拖拽,使得小窗无法被移动。
更隐蔽的一个原因是,画中画窗口本身由一个远程视图控制器承载,它的触摸事件传递路径与普通视图不同。部分系统手势识别器并不存在于当前 App 的视图层级中,而是由系统窗口直接处理,这就导致开发者使用常规的 hitTest 或者 gestureRecognizer 遍历方法无法直接访问到这些手势。要解决冲突,必须在事件分发的更早阶段介入,或者利用手势代理的优先级策略间接影响系统手势的行为。
手势识别器优先级设置方案
解决 WKWebView 画中画手势冲突的核心思路有两条:一是让自定义手势主动让路给画中画窗口的系统手势,二是强制提升自定义手势的优先级,避免被系统手势抢占。具体实现需要根据业务场景选择不同的策略。
第一种方案使用 UIGestureRecognizerDelegate 中的 gestureRecognizer(_:shouldRequireFailureOf:) 方法来建立手势之间的依赖关系。这个方法允许一个手势在另一个手势失败后才能开始识别。例如,如果希望画中画窗口的拖拽手势优先于自定义的边缘滑动手势,可以让自定义手势要求系统拖拽手势先失败。由于系统手势对象不容易直接获取,可以遍历 UIWindow 上的所有手势识别器,通过类名或视图关联特征来匹配画中画相关的手势。下面的代码展示了如何在 WKWebView 所在的控制器中遍历根窗口的手势识别器,并为自定义手势设置依赖。
import UIKit
import WebKit
class VideoWebViewController: UIViewController, UIGestureRecognizerDelegate {
let webView = WKWebView()
var customEdgePan: UIScreenEdgePanGestureRecognizer!
override func viewDidLoad() {
super.viewDidLoad()
// 配置 WKWebView 并加载视频页面
webView.frame = view.bounds
webView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
view.addSubview(webView)
if let url = URL(string: "https://ippipp.com/video") {
webView.load(URLRequest(url: url))
}
// 添加自定义边缘滑动手势
customEdgePan = UIScreenEdgePanGestureRecognizer(target: self, action: #selector(handleEdgePan(_:)))
customEdgePan.edges = .left
customEdgePan.delegate = self
view.addGestureRecognizer(customEdgePan)
// 延迟执行,等待画中画窗口可能的手势完成注册
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in
self?.setupGesturePriorities()
}
}
func setupGesturePriorities() {
guard let window = view.window else { return }
// 获取当前 UIWindow 中所有手势识别器
let allGestures = window.allGestureRecognizers()
// 筛选可能是画中画窗口拖拽手势的对象:包含 PiP 或 AVKit 相关类名前缀
let systemPipGestures = allGestures.filter { gesture in
let className = String(describing: type(of: gesture))
return className.contains("PIP") || className.contains("PictureInPicture") || className.contains("AVKit")
}
// 将自定义边缘手势与系统画中画手势建立失败依赖关系
for systemGesture in systemPipGestures {
customEdgePan.require(toFail: systemGesture)
}
}
@objc func handleEdgePan(_ gesture: UIScreenEdgePanGestureRecognizer) {
// 自定义侧滑返回逻辑
print("自定义边缘滑动被触发")
}
// 允许同时识别,但通过 require(toFail:) 控制优先级
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
// 对于画中画相关手势,允许同时识别,但优先级已通过 require 处理
let otherClass = String(describing: type(of: otherGestureRecognizer))
if otherClass.contains("PIP") || otherClass.contains("PictureInPicture") {
return true
}
return false
}
}
// 扩展 UIWindow 方便获取所有手势(包括系统内部的手势)
extension UIWindow {
func allGestureRecognizers() -> [UIGestureRecognizer] {
var results: [UIGestureRecognizer] = []
func extract(from view: UIView) {
if let gestures = view.gestureRecognizers {
results.append(contentsOf: gestures)
}
for subview in view.subviews {
extract(from: subview)
}
}
extract(from: self)
return results
}
}
上述方法通过遍历 UIWindow 的视图层级,获取所有手势识别器,筛选出名字中包含 PIP、PictureInPicture 或 AVKit 的对象,这些很可能就是画中画窗口使用的系统手势。然后使用 require(toFail:) 建立依赖,让自定义边缘手势必须等待这些系统手势失败后才能开始识别。这样当用户拖动的是画中画小窗时,系统手势优先;当用户从屏幕边缘滑动且没有触碰到画中画区域时,系统手势因为位置不对而失败,自定义手势就可以正常触发。
第二种方案是反向设置依赖,即让系统画中画手势等待自定义手势失败。这种场景适用于自定义手势具有更高业务优先级,例如用户在全屏视频页面需要先响应自己的缩放手势,再允许系统处理画中画窗口的拖拽。实现方式与上面类似,但在筛选出系统手势后调用 systemGesture.require(toFail: customEdgePan)。不过要注意,一旦设置错误会导致画中画窗口无法拖动,所以必须谨慎测试每一种交互路径。
第三种方案是重写自定义手势的 gestureRecognizerShouldBegin(_:) 方法,根据触摸点是否落在画中画窗口区域内来判断是否应该开始识别。由于画中画窗口的 frame 可以通过 WKWebView 的 pictureInPictureController 相关 API 获取,但在实际开发中更常用的做法是通过 KVO 监听 WKWebView 的 value(forKey:) 私有属性,这有审核风险,因此不推荐。更优雅的方式是利用系统提供的通知或者通过观察画中画状态变化来维护一个全局的悬浮窗区域标记,从而在自定义手势开始时做出判断。
完整代码实现与验证
为了更系统地解决手势冲突,我们可以封装一个专门的手势管理器,在 App 启动时注入到主窗口中,统一处理所有自定义手势与画中画手势的优先级关系。下面是一个可复用的管理器类,包含手势注册、优先级调整以及动态更新策略。
import UIKit
final class GesturePriorityManager {
static let shared = GesturePriorityManager()
private var registeredCustomGestures: [UIGestureRecognizer] = []
private var hasSetup = false
private init() {}
/// 注册需要与画中画手势协调的自定义手势
func register(customGesture gesture: UIGestureRecognizer) {
registeredCustomGestures.append(gesture)
if hasSetup {
setupPriorities()
}
}
/// 在 App 启动后调用一次,开始监听窗口变化
func startMonitoring() {
NotificationCenter.default.addObserver(self,
selector: #selector(windowDidBecomeVisible(_:)),
name: UIWindow.didBecomeVisibleNotification,
object: nil)
NotificationCenter.default.addObserver(self,
selector: #selector(windowDidBecomeHidden(_:)),
name: UIWindow.didBecomeHiddenNotification,
object: nil)
setupPriorities()
hasSetup = true
}
@objc private func windowDidBecomeVisible(_ notification: Notification) {
setupPriorities()
}
@objc private func windowDidBecomeHidden(_ notification: Notification) {
setupPriorities()
}
private func setupPriorities() {
guard let keyWindow = UIApplication.shared.windows.first(where: { $0.isKeyWindow }) else { return }
let allGestures = keyWindow.allGestureRecognizers()
let pipGestures = allGestures.filter { gesture in
let className = String(describing: type(of: gesture))
return className.contains("PIP") || className.contains("PictureInPicture") || className.contains("AVKit")
}
guard !pipGestures.isEmpty else { return }
for customGesture in registeredCustomGestures {
for pipGesture in pipGestures {
// 默认让自定义手势让位于画中画系统手势
customGesture.require(toFail: pipGesture)
}
}
}
}
// 使用示例:
// 在 AppDelegate 中调用 GesturePriorityManager.shared.startMonitoring()
// 在需要注册手势的地方调用 GesturePriorityManager.shared.register(customGesture: panGesture)
这个管理器利用了 UIWindow.didBecomeVisibleNotification 和 UIWindow.didBecomeHiddenNotification,当画中画远程窗口显示或隐藏时重新调整优先级。因为画中画窗口出现时,系统会添加新的手势识别器,此时注册的自定义手势需要重新绑定依赖关系。同时,通过 KVC 或私有 API 获取的手势列表在 iOS 版本升级后可能发生变化,因此需要定期重新扫描并调整。
验证时,可以编写一个简单的 UI 测试:打开包含视频的 WKWebView 页面,启动画中画,然后分别测试拖拽画中画窗口、双击画中画窗口、在 App 内进行自定义边缘滑动。记录每种操作的结果是否符合预期。如果发现自定义手势仍然抢占画中画窗口,检查 require(toFail:) 是否成功调用,以及手势对象的引用是否有效。如果发现画中画窗口的某些系统手势无法被遍历到,可以考虑在 UIWindow 的私有子视图上使用 NSInvocation 或 Mirror 反射来获取更全面的手势列表,但这种方式有审核风险,仅建议在调试阶段使用。
注意事项与兼容性处理
在实际项目中应用上述方案时,有几个容易忽略的细节需要特别注意。首先是 WKWebView 的配置项:必须确保 allowsPictureInPictureMediaPlayback 属性为 true,否则画中画按钮不会出现,也就谈不上手势冲突。其次,画中画窗口在 iOS 13 及更高版本中的实现方式发生了变化,旧版本(iOS 9 到 12)中画中画窗口的手势识别器可能存在于不同的视图层级中,遍历 UIWindow 的 gestureRecognizers 属性不一定能获取到,此时需要检查 UIWindow 的 subviews 中包含 AVPlayerView 或 AVPlayerLayer 的视图,这些视图上往往会挂载系统手势。
另一个常见问题是,当 App 进入后台后画中画窗口仍然存在,此时如果 App 在后台执行手势相关代码可能会引发异常。建议在 applicationDidEnterBackground 中暂停手势管理器的调整操作,在 applicationWillEnterForeground 中恢复。同时,不要在主线程中频繁调用 allGestureRecognizers 并遍历全部视图层级,这会引起性能问题,尤其是在视图树比较复杂的应用中。可以缓存已经找到的系统手势对象,仅在窗口变化通知时更新。
关于 iOS 版本兼容性,目前主流的做法是使用 if #available(iOS 13.0, *) 分支来分别处理画中画手势。在 iOS 14 及以上,系统引入了更加完善的多任务画中画交互,拖拽手势和双击手势的默认行为已经经过优化,冲突情况有所减少,但仍然可能与应用内的长按手势或双指手势冲突。此时可以结合 UIGestureRecognizerDelegate 的 gestureRecognizer(_:shouldReceive:) 方法,根据触摸点坐标判断是否处于画中画窗口区域,从源头过滤掉不必要的触摸。
最后提醒一点,手势优先级设置没有万能的解决方案,必须结合具体的业务交互来权衡。如果 App 内自定义手势对用户体验至关重要,可以采用第二种方案(让系统手势等待自定义手势失败),但一定要在真机上充分测试所有路径。建议添加日志记录每次手势识别器的状态变化,方便线上排查问题。