做 iOS 弹窗的人应该都遇到过这种需求:present 出一个底部菜单或居中提示的控制器,要求点击半透明背景区域时自动关闭,而点击弹窗内容区域时不要有任何反应,按钮、列表、滚动照常工作。我见过不少刚接触这块的同学,直接在 presentedViewController 的整个 view 上挂一个 UITapGestureRecognizer,结果点击内容区也把弹窗给关了,要么就是背景怎么点都没反应。问题不在手势,而是很少有人先搞清楚 present 的模态样式和视图层级到底是怎么工作的。这篇内容把 popup 交互拆开讲:先说 modalPresentationStyle 对点击路径的影响,再给一个通用实现方案,然后把手势判定、键盘避让、动画细节这些容易翻车的点全过一遍。不管你是做通用组件库还是临时写个引导弹窗,这套思路都适用。
1. 现象与原理:点击背景关闭背后是模态样式决定一切
1.1 三种常见模态样式的点击差异
很多 iOS 开发者第一次做弹窗时,都会想当然地认为 present 出来的控制器天然支持“点外面关闭”。但真实情况是,UIKit 从来没有把“点击背景关闭”作为 present 的默认行为,能不能做到、怎么做,完全取决于你选的模态样式。
| 模态样式 | 视图覆盖范围 | presenting 控制器视图是否保留 | 点击背景默认行为 | 常见用途 |
|---|---|---|---|---|
| .fullScreen | 全屏,不透明 | 被系统移出显示层级 | 无,背景被盖住 | 全屏页面、登录页 |
| .overFullScreen | 全屏,允许透明 | 仍然保留在下一层 | 无,需要自己添加手势 | 居中弹窗、半透明蒙层 |
| .automatic / .pageSheet | 卡片式,不完全全屏 | 保留,可看到上一页 | 无,iPad 上可下滑关闭,iPhone 上不默认 | 表单、设置页 |
在实际项目中,绝大多数“点击背景消失”的弹窗都采用.overFullScreen。原因很简单:它让 presented 控制器的 view 覆盖全屏,但你可以把它的背景色设为透明,这样底下的页面仍然可见,然后把蒙层和弹窗内容都画在 presented 控制器的 view 内部。点击手势也挂在这个 view 内部的一个“蒙层视图”上,而不是挂在真正的下一页控制器上。
这里有一个非常容易踩的认知误区:很多人以为点击背景时,事件是被“后面那个页面”收到的,所以应该去 nextResponder 链上找 presentingViewController 的 view。实际上,窗口的 hitTest 会优先命中最上层的弹窗视图,所谓“后面的页面”在点击那一下根本不会成为事件响应者。你点击的是弹窗视图里的透明区域,只是视觉上看起来像点在背景上。
1.2 从视图树看透点击事件的命中路径
用一句话概括 present 之后的视图关系:presenting 控制器的 view 没有消失,presented 控制器的 view 被添加到当前 window 的最上层。.fullScreen之所以背景看不见,是因为 presented 的 view 既覆盖全屏又不透明,下面那一层被彻底遮住了;.overFullScreen则允许 presented 的 view 半透明,底层页面通过透明区域透出来。
点击事件在 window 上的命中逻辑是:从最上层的 view 开始,自顶向下做 hitTest,找到第一个能接收触摸的 view,把整个触摸交给它。弹窗控制器里的 view 做了三件事:整层覆盖、蒙层覆盖、内容区域覆盖。点击事件落在内容区域时,系统会命中 contentView;落在蒙层区域时,才会命中 maskView。因此,“点击背景关闭、点击内容不关闭”本质上就是依靠视图层级和 hitTest 的天然行为实现的。
搞懂了这一点,后面所有实现都会变得非常清晰。弹窗的核心不是“怎么让当前控制器在点击时关闭自己”,而是“如何组织子视图的层级,让点击事件只在指定区域触发关闭逻辑”。这也是为什么我一向不推荐在整个 view 上挂手势去手动判断坐标,那是事情已经做拧巴了之后的补救手段。
2. 搭建一个关闭可控的通用弹窗控制器
2.1 modalPresentationStyle 必须在初始化阶段设置
先说一个细节:modalPresentationStyle一定要在控制器初始化的时候就设好,不要写在viewDidLoad里。present 的逻辑在系统创建 presentation context 的时候就已经确定了,你在 viewDidLoad 阶段去改,有时候不生效,有时候会有诡异的分屏行为和转场异常。
我习惯在 init 方法里统一设置:
final class PopupViewController: UIViewController { init() { super.init(nibName: nil, bundle: nil) modalPresentationStyle = .overFullScreen modalTransitionStyle = .crossDissolve } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } }modalTransitionStyle这里我把默认的.coverVertical改成了.crossDissolve,后面 5.3 小节会专门讲为什么这么做。如果你弹窗是从 Storyboard 跳转过来的,也同样需要在prepare(for:sender:)里设置,而不是依赖 segue 属性的默认值。
还有一个容易被忽略的点:.overFullScreen不会把 presenting 控制器的 view 从窗口移除,所以弹窗 present 和 dismiss 时底层的页面不会走viewWillDisappear/viewDidDisappear。如果你依赖这些生命周期回调去做一些“页面不可见时停止播放”的逻辑,需要自己评估处理方式。
2.2 用“蒙层视图 + 内容视图”两层结构管理点击
我推荐的通用弹窗结构是:presented 控制器的 view 作为全屏容器,里面放两个兄弟子视图。先添加一个全屏的 maskView,再在它上面添加居中或居底的内容容器 contentView。点击手势只挂在 maskView 上,contentView 不挂任何额外手势。
final class PopupViewController: UIViewController { private let maskView = UIView() private let contentView = UIView() init() { super.init(nibName: nil, bundle: nil) modalPresentationStyle = .overFullScreen modalTransitionStyle = .crossDissolve } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .clear view.isOpaque = false maskView.backgroundColor = UIColor.black.withAlphaComponent(0.4) maskView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(maskView) NSLayoutConstraint.activate([ maskView.leadingAnchor.constraint(equalTo: view.leadingAnchor), maskView.trailingAnchor.constraint(equalTo: view.trailingAnchor), maskView.topAnchor.constraint(equalTo: view.topAnchor), maskView.bottomAnchor.constraint(equalTo: view.bottomAnchor) ]) contentView.backgroundColor = .white contentView.layer.cornerRadius = 12 contentView.layer.shadowColor = UIColor.black.cgColor contentView.layer.shadowOpacity = 0.2 contentView.layer.shadowRadius = 16 contentView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(contentView) NSLayoutConstraint.activate([ contentView.centerXAnchor.constraint(equalTo: view.centerXAnchor), contentView.centerYAnchor.constraint(equalTo: view.centerYAnchor), contentView.widthAnchor.constraint(equalToConstant: 280) ]) let tap = UITapGestureRecognizer(target: self, action: #selector(handleMaskTap)) maskView.addGestureRecognizer(tap) } @objc private func handleMaskTap() { dismiss(animated: true, completion: nil) } }这套结构的关键在于:maskView 在视图层级中位于 contentView 之下。点击 contentView 内部区域时,hitTest 会命中 contentView 本身的子视图,maskView 上的手势压根无法收到点击;只有点击 contentView 之外、maskView 覆盖的区域时,maskView 才会成为响应者。也就是说,需求里“点击背景消失,点击弹出的位置接收点击事件”被视图层级天然实现了,根本不需要坐标判断。
如果你把手势加在弹窗控制器的view上,而不是加在 maskView 上,那么点击 contentView 也会被同一个手势捕获。因为手势是加在父视图上的,子视图上的触摸会沿着响应链上报。这是网上很多“点哪都消失”代码的根源。
2.3 让内容区域正常响应用户操作
内容区域里的按钮和滚动视图通常不需要做任何特殊处理,只要默认的userInteractionEnabled = true,点击就会由 contentView 及其子视图接管。但有几个边界情况要注意。
如果 contentView 内部是一个纯展示型文案,没有交互控件,你只要不把 contentView 的isUserInteractionEnabled设成 false,整块区域仍然会吸收点击,不会穿透到 maskView。所以我平时写的内容容器一律默认保持交互开启,除非产品明确说“点内容区域空白也要关闭弹窗”。
另一个场景是内容区域内部有独立的点击热区,比如一个表格或者 collectionView。它们内部自身的点击事件和 maskView 上的手势互不干扰,因为手势不在同一个视图上。唯一要留意的是,如果你把 contentView 的尺寸约束设得比实际内容小,或者内部控件溢出到了 contentView 边界之外,点击溢出区域时就会落在 maskView 上,导致弹窗意外关闭。这种情况下要检查内容控件的 frame,不能只盯着 contentView 本身。
当弹窗要支持上下左右不同位置的展示时,这套分层结构也可以直接用。把 contentView 的约束改成 bottom、top、leading 或 trailing 对应布局即可,maskView 的手势逻辑完全不用改。
3. 点击区域判定:内容不误判成背景的三种思路
3.1 坐标系落点判断
尽管分层结构已经解决了绝大多数场景,但有时候你确实会遇到不得不在整个 view 上挂手势的情况。比如需求是“点击内容区域的空白关闭,但点击按钮不关闭”,这种规则已经超出了纯层级能表达的范围,必须手动判断落点。
最直接的写法是拿到手势在指定坐标系里的位置,然后判断是否落在内容区域内:
@objc private func handleTap(_ gesture: UITapGestureRecognizer) { let location = gesture.location(in: view) if contentView.frame.contains(location) { return } dismiss(animated: true, completion: nil) }这段代码能工作,但有两个隐患。第一,contentView.frame是它在父视图坐标系下的矩形,如果 contentView 自身有transform动画,或者正在做缩放位移,frame会跟着变换,判断结果可能和你预期不一致。第二,矩形判断无法处理圆角,点击 contentView 圆角外侧、但仍在外接矩形内的区域时,照样会被当成“内容区域”而不关闭。
因此,我通常把坐标判断限制在那些弹窗内容是简单矩形、且手势挂在 view 自身的场景下。项目里如果有复杂圆角、不规则内容,更推荐分层结构或下面这套 delegate 思路。
3.2 用 gestureRecognizer(_:shouldReceive:) 在源头拦截
更干净的做法是给手势设置 delegate,在gestureRecognizer(_:shouldReceive:)里决定某个触摸是否应该交给这个手势处理。系统在 hitTest 之后、手势开始识别之前会先调用这个方法,从源头拦截。
extension PopupViewController: UIGestureRecognizerDelegate { func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { let location = touch.location(in: contentView) return !contentView.bounds.contains(location) } }注意看坐标转换:touch.location(in: contentView)得到的是触摸点在 contentView 坐标系下的位置,然后用contentView.bounds判断是否在内部。即便手势挂在 maskView 上,这个坐标转换也是有效的,因为 UIKit 会根据视图层级把坐标换算好。
这个方案的优点是判断时机早。如果你用 action 里的坐标判断,手势已经被识别了,所有准备阶段的状态都已经建立;而shouldReceive直接拒绝了触摸源,contentView 区域内的点击根本不会进入手势识别流程,也不会有任何竞争延迟。当 contentView 内部有按钮时,按钮的点击响应会比加了外部手势时更跟手。
3.3 处理圆角、内边距和伸缩布局下的热区
回到比较容易忽略的热区细节。contentView 自身虽然有cornerRadius = 12,但 touch 判断用的是矩形 bounds,四角区域(圆角挖掉的部分)从视觉上属于背景,从几何上却属于内容区。如果你希望“圆角外侧的点击也关闭弹窗”,需要自己补一个圆角路径判断:
let path = UIBezierPath(roundedRect: contentView.bounds, cornerRadius: contentView.layer.cornerRadius) if path.contains(location) { return }这个写法支持标准圆角,遇到不规则图形、异形弹窗,老老实实用分层结构会更省心。唯一的例外是当你需要“点击 contentView 空白处关闭,点击其中按钮不关闭”这种混合需求:在 contentView 本身上加一个 tap 手势,然后在 action 里遍历按钮 frame,命中按钮就跳过,否则关闭。
内容高度动态变化时,自动布局会驱动 contentView 更新 frame,热区也跟着变化。这也是我尽量不用手动 frame 判断的原因,布局一变,坐标代码就要同步维护,而分层结构完全不需要。
4. 键盘弹出与动态尺寸场景下的细节修正
4.1 键盘通知驱动弹窗上移
弹窗里有输入框时,键盘会盖住内容区域。处理的常规套路是监听键盘的显示、隐藏通知,计算 contentView 底部和键盘顶部的交叠量,然后移动 contentView 的位置。
override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) NotificationCenter.default.addObserver( self, selector: #selector(keyboardWillShow), name: UIResponder.keyboardWillShowNotification, object: nil ) NotificationCenter.default.addObserver( self, selector: #selector(keyboardWillHide), name: UIResponder.keyboardWillHideNotification, object: nil ) } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) NotificationCenter.default.removeObserver(self) } @objc private func keyboardWillShow(_ notification: Notification) { guard let keyboardFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return } let convertedRect = view.convert(keyboardFrame, from: nil) let overlap = contentView.frame.maxY - convertedRect.minY guard overlap > 0 else { return } contentViewCenterYConstraint.constant = -overlap - 12 UIView.animate(withDuration: 0.25) { self.view.layoutIfNeeded() } } @objc private func keyboardWillHide(_ notification: Notification) { contentViewCenterYConstraint.constant = 0 UIView.animate(withDuration: 0.25) { self.view.layoutIfNeeded() } }这里有个特别容易写错的地方:keyboardFrameEndUserInfoKey的值是屏幕坐标系下的矩形,必须经过view.convert(_:from:)转成弹窗视图的坐标系,再和 contentView.frame 比较。很多人直接拿这个矩形和 contentView.frame 做运算,一遇到键盘从屏幕底部弹出、TabBar 存在等情况,算出来的高度全是错的。
动画时间和曲线应该尽量跟随键盘的keyboardAnimationDurationUserInfoKey和keyboardAnimationCurveUserInfoKey,如果偷懒固定写 0.25 秒,通常也能看,但在键盘动画和转场动画叠加时会有一小段跳变。
4.2 内容高度变化后触碰热区自动跟随
弹窗内容经常不是写死的,比如用户选择了一个选项后,展示区域变高;或者异步拿到数据后要求重算弹窗高度。用自动布局时,只要 contentView 内部的约束能收紧,contentView.frame 会自动伸缩,蒙层和手势热区不需要额外处理。
手写 frame 布局则必须小心,设置完 contentView 的 frame 之后一定要重新布局中心点,否则弹窗会偏移。我会在负责更新内容的 setter 里统一调用:
private func refreshContentLayout() { contentView.sizeToFit() contentView.center = view.center }这个写法适用于简单弹窗,但是 sizeToFit 依赖内容视图正确实现了intrinsicContentSize或者内部 subview 的 frame 是完整的,不然高度会算少。复杂内容建议还是回到自动布局,让约束系统去保证热区跟随。
4.3 旋转、分屏下的布局与手势失效
present 出来的控制器如果支持旋转,或者 iPad 上处于分屏状态,弹窗的尺寸会随 windowScene 变化。用自动布局约束 maskView 到父视图四条边,旋转后它会自动更新;手写 frame 的代码需要在viewDidLayoutSubviews里刷新 maskView.frame,不然转屏后蒙层只遮住一半屏幕。
判断全屏尺寸时要谨慎。iPad 分屏场景下,UIScreen.main.bounds表达的是整块物理屏幕,不是当前 app 可用的窗口区域。这类逻辑一律使用view.bounds或view.window?.bounds更安全,避免在分屏两边宽度差异很大的时候出现弹窗错位。
还有一点,present 过程中如果系统触发旋转,viewDidLayoutSubviews可能会在弹窗动画中间被调用多次。此时不要在动画里叠加 frame 赋值,建议用约束或者UIView.performWithoutAnimation包裹一次布局更新,减少视觉跳动。
5. 手势冲突与交互副作用的排查清单
5.1 内容区滚动视图的边缘点击问题
当 contentView 内部是一个 UIScrollView 或 UITableView 时,用户可能从 scrollView 边缘开始滑动,而边缘处已经从 contentView 区域滑出到了 maskView 区域。由于滑动手势是 UIPanGestureRecognizer,触摸一开始落在 maskView 上,但 scrollView 的 pan 手势仍然可以通过手势识别机制接管,最终表现为“从边缘可以滚动”。对大多数弹窗来说这是想要的,但如果出现滚动不完全跟手的情况,可以考虑检查 hitTest 的返回视图,确认 touch 的 initial location 是否真的在 contentView 内。
如果你采用了“整个 view 加手势 + 手动判断坐标”的写法,外部 tap 手势会和 scrollView 的点击识别产生竞争,常见表现是点击 cell 有明显延迟,或者滚动时偶尔触发弹窗关闭。此时不要直接把外部手势删掉,而是让它在 UIScrollView 的 pan 手势失败后再识别:
scrollView.panGestureRecognizer.require(toFail: externalTapGesture)require(toFail:) 的意思是:除非 pan 手势失败,否则外部点击手势不能进入成功状态。这样点按 cell 时 tap 可以先识别,滑动时 pan 优先,互不干扰。不过这种方案要小心循环,同一对手势不能互相 require,系统会直接断言崩溃。
5.2 连续点击导致重复 dismiss
弹窗 dismiss 动画本身有执行时间,但 maskView 在动画期间并不会立刻从窗口移除。用户快速双击背景,第二次点击会再次触发 handleMaskTap,导致 dismiss 被调用两次,可能出现动画重复或者控制器状态错乱。用标志位挡一下最稳妥:
private var isDismissing = false @objc private func handleMaskTap() { guard !isDismissing else { return } isDismissing = true dismiss(animated: true) { [weak self] in self?.isDismissing = false } }这个标志位在 dismiss 完成后重置,可以放心复用。如果你的弹窗还有“点击确定按钮后自动关闭”的逻辑,建议同样走一个统一的close()方法,避免多处 dismiss 时漏加保护。
还有一种情况是 dismiss 的 completion 里做了资源清理,比如移除观察者、重置输入框。因为有标志位保护,就不会出现同时回调两次清理逻辑的问题。
5.3 透明蒙层动画与 isOpaque 造成的视觉残留
我在 2.1 小节把 modalTransitionStyle 改成了.crossDissolve,核心原因是默认的.coverVertical会让弹窗从底部滑入,半透明蒙层配合白色内容框在滑动过程中会露出明显的“内容框影子”移动轨迹,观感比较粗糙。淡入淡出则能把背景和内容作为一个整体呈现,和“点击背景关闭”的提示型弹窗气质更匹配。
另外,presented 控制器的根 view 如果背景是透明的,一定要把isOpaque显式设为 false。UIKit 默认认为 view 是不透明的,透明视图配合一个 isOpaque 误值,会在动画过程中出现黑色闪烁,尤其是在低端机或者 present 转场刚开始的那几帧。
如果页面里某些卡片本身设计了阴影和圆角,contentView 设置 cornerRadius 之后记得开启masksToBounds,但开启它同时会裁掉 shadow。常见做法是给 contentView 包一层容器:外层容器负责阴影,内层 view 负责圆角和裁剪,这样既能保住投影又不会露出方形边角。
6. 更轻量的选择:不 present,直接使用 UIWindow 覆盖层
6.1 用 UIWindow 挂一个宿主控制器
并不是所有弹窗都适合 present。一次性全屏引导、全局广告位、与 App 主界面导航完全无关的浮层,用 UIWindow 覆盖层更轻量,不污染现有控制器栈。
基本模式是创建一个 window,把弹窗控制器作为它的 rootViewController:
final class PopupWindowManager { private var window: UIWindow? func show() { let popupVC = PopupViewController() let newWindow = UIWindow(frame: UIScreen.main.bounds) newWindow.windowLevel = .alert + 1 newWindow.rootViewController = popupVC newWindow.makeKeyAndVisible() window = newWindow } func dismiss() { window?.isHidden = true window = nil } }windowLevel设置为.alert + 1保证覆盖在应用主窗口之上。window 必须被强引用,否则局部变量释放后窗口立即消失。弹窗关闭时把 window.hidden 设为 true,再置空引用,让 UIKit 释放相关对象。
这套方案在实现“点击背景关闭”时,弹窗控制器内部的 maskView 和 contentView 分层逻辑和 present 完全一致。不同的地方是系统不会自动处理转场动画和交互环境,需要自己决定加不加 fade 动画,也要自行处理窗口创建时的键状态。
6.2 两种方案的适用边界
很多同学纠结到底用 present 还是 UIWindow。我按实际项目经验列一个对比,方便按场景取舍:
| 对比项 | present 弹窗 | UIWindow 覆盖层 |
|---|---|---|
| 导航栈 | 弹窗作为独立模态,presenting 场景自然 | 不影响导航栈,但生命周期全靠自己管理 |
| 转屏和分屏 | 系统根据 windowScene 自动适配 | 需要手动监听窗口变化并刷新 frame |
| 键盘处理 | 键盘通知作用在弹窗 view 上,逻辑清晰 | window 层级高,键盘所在 window 可能是另一个实例 |
| 遮罩层级 | 只在当前 window 之上 | 可以做到全局最强覆盖 |
| 内存管理 | presentation controller 持有弹窗 | 必须手动强引用并释放 window |
| 代码复用 | 适合作为通用组件库的基类 | 适合一次性浮层,不适合频繁复用 |
我自己的习惯是:凡是那种需要支持键盘、需要跟随导航栈旋转、或者被多个页面复用的弹窗,一律用 present + overFullScreen 的方案,因为系统的 presentation controller 会帮我处理很多边界情况。UIWindow 方案只留给“无论如何都要压住所有界面”的强提示,比如全局断网提示、强制升级弹层。
写在最后的实操建议
最后补充一个实际操作中的小细节。在做模拟项目X的“隐私协议弹窗”时,我一开始把 modalTransitionStyle 设为.coverVertical,弹窗从底部升起时,白色内容框的阴影会在透明蒙层上留下一条明显的“滑入痕迹”。换成.crossDissolve之后整个弹窗变成了淡入淡出,干净很多。如果你也遇到类似问题,可以先检查是不是过渡动画和透明度没有匹配好。
另外一个习惯是把所有弹窗都收敛到一个 BasePopupViewController,把背景、圆角、入场动画、点击关闭逻辑都封装在基类里,新业务只传入内容 view。这样既统一体验,也少踩很多坑。如果你现在还在用“全屏加手势 + 坐标判断”的老办法,建议尽快切成 maskView + contentView 的分层思路,后面维护起来会省力非常多。