我把一次点击拆开记。传递和响应各讲一遍,图下面写我看到的东西。最后回到这几个问题。
问题
- 视图的事件怎么传递、怎么响应?
- 父视图关掉交互,子视图为什么也点不到?
- 怎么扩大按钮的点击范围?
- 怎么让父视图和子视图同时响应同一次触摸?
- 子视图关掉交互、父视图开着,点在子视图上,为什么父视图还能响应?
- 普通
UIView没有手势,点击会交到父视图。UIControl同样没有手势,父视图却收不到。为什么?
试验页面只有三层:窗口、父视图、子视图。每次只改一个开关。
分析
事件我分成三类。今天只拆第一类。
| 类型 | 我遇到它的时候 |
|---|---|
| 触摸 | 手指点在屏幕上 |
| 运动 | 摇一摇、重力感应 |
| 远程 | 耳机上的按键 |
运动和远程不经过 hitTest。它们从当前第一响应者进入响应链。
触摸分两段。传递找出一个视图。响应再看它处理不处理。
传递结束时,手里只有一个命中视图。它接住了这个点,不等于它会处理这次触摸。
传递
传递在问:这个点落在哪个视图上。从窗口往子视图走,问到一个就停。
手指碰到屏幕,系统把事件交给当前 App。UIApplication 收到之后交给窗口,窗口调用 hitTest(_:with:)。
手指
│
▼
UIApplication.sendEvent
│
▼
UIWindow.hitTest
│
▼
父视图.hitTest
│
▼
子视图.hitTest
│
▼
返回子视图 ──► 父视图原样向上交 ──► 窗口原样向上交
这张图是调用顺序,不是视图嵌套。箭头向下是在找人。最底下向右的箭头不再往下问,只把已经找到的子视图交回窗口。
同一层里,后加入的视图排在 subviews 后面,画在更上面,也先被问到。点在按钮上时,树是这样:
UIWindow
├─ 背景 先加入,画在下面,最后才问
└─ 卡片 后加入,先被问
└─ 按钮 ● 点在这里
背景画在卡片下面,所以窗口先问卡片。卡片再问按钮。按钮没有更深的子视图,点又在按钮里,按钮把自己返回。卡片和窗口都把这个结果原样交上去,背景不会被问到。
问子视图之前先过四关:没有隐藏,允许交互,alpha 不小于 0.01,点在自己的 bounds 里。有一关不过,当前视图返回 nil,它下面的子视图都不会被问到。
import UIKit
// 传递时我对照的过程。真正调用的是系统的 hitTest
func illustratedHitTest(_ point: CGPoint, on view: UIView, event: UIEvent?) -> UIView? {
// 不过这一关,子视图不会被问到
if view.isHidden || !view.isUserInteractionEnabled || view.alpha < 0.01 {
return nil
}
if !view.point(inside: point, with: event) {
return nil
}
// 数组末尾先问,也就是画在更上面的那个
for subview in view.subviews.reversed() {
let local = subview.convert(point, from: view)
if let hit = illustratedHitTest(local, on: subview, event: event) {
return hit
}
}
return view
}
我点在子视图上,命中的是子视图。点在父视图空白处,子视图说点不在自己里面,父视图返回自己。
选出的视图记在这次 UITouch 上。手指接着移动、抬起,都还发给它,不会每动一次就重新找。
一次传递只选出一个视图。
响应
响应在问:这个视图处理不处理。从命中视图沿 next 向上,直到有一层把触摸留下,或者链走完。
我这次的父视图就是控制器的根视图:
子视图 touchesBegan 从这里开始
↑
父视图
↑
控制器 插在自己的根视图后面
↑
窗口
↑
UIWindowScene
↑
UIApplication
箭头向上。子视图的下一位是父视图。控制器出现在自己的根视图后面。
普通 UIView 没写触摸方法时,默认实现把消息交给 next,所以上面父视图能收到。某一层写了方法又不调用 super,链停在这一层。
不调用 super 调用 super
子视图 打印 child 子视图 打印 child
✕ 停在这里 │
▼
父视图 打印 parent
左边只有子视图的打印。右边子视图先处理自己的事,super 再把同一次触摸交给父视图,两行都会出现。
四个方法要一起这样转发:touchesBegan、touchesMoved、touchesEnded、touchesCancelled。我只转发了开始,抬起那一下父视图收不到。
UIButton 是另一条结果。它是 UIControl,自己实现了这四个方法,用来跟踪手指、发送 action。
普通 UIView UIButton
没有写触摸方法 自己实现了四个触摸方法
默认交给 next 不把 touchesBegan 交给父视图
父视图能收到 父视图没有打印
手势列表可以是空的 手势列表也是空的
两列的手势都可以是空的。父视图收不收得到,差在有没有把触摸方法留给自己。
传递向下找人。响应向上找愿意处理的人。
解答
hitTest,从窗口问到一个视图。响应从那个视图的 touchesBegan 开始,不处理就沿 next 向上。父视图 isUserInteractionEnabled = false 时,hitTest 在问子视图之前就返回。
UIWindow.hitTest
│
▼
父视图.hitTest
│
✕ 交互关着
│
└── return nil
子视图不会被调用
叉号在父视图自己身上。下面的子视图还没轮到,所以子视图上的点击也没了。隐藏,或者 alpha 小于 0.01,也是这一关。
子视图点不到,是因为查找没有走进去。
我先在 hitTest 里写:点落在按钮外侧 16pt,就返回按钮。外侧能点到,但这块地方里原来的兄弟视图也被抢走了。
后来只改 point(inside:with:)。谁先被问,仍按原来的层级。变的只是「这个点算不算在按钮上」。
┌─ 热区 ─────────┐
│ ┌─ 图标 ─┐ │
│ └────────┘ │
└────────────────┘
外框是手指能点中的范围,内框才是按钮的 frame。图标大小没变,外圈靠 point(inside:) 算进按钮。
import UIKit
final class EasyTapButton: UIButton {
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
// 四周各扩 16pt,frame 不动
bounds.insetBy(dx: -16, dy: -16).contains(point)
}
}
扩大点击范围,改 point(inside:with:)。
按钮贴着父视图边缘时,扩出去的 16pt 落在父视图 bounds 外。父视图那一关先失败,按钮不会被问到。那一圈还是点不到。
hitTest 只能返回一个视图。我把处理写进 hitTest,手指一移,这个方法被调用好几次,处理也跑了好几次。
子视图的逻辑写在 touchesBegan 里,再调用 super。默认实现会交给 next。
import UIKit
final class ParentView: UIView {
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
print("parent")
}
}
final class ChildView: UIView {
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
print("child")
// 去掉这行,控制台只剩 child
super.touchesBegan(touches, with: event)
}
}
调用 super,控制台是 child 然后 parent。不调用,只有 child。另外三个触摸方法我也同样转发了。
父子要一起收到,在子视图里调用 super。
子视图返回 nil。父视图继续问别的子视图,没有人接住,就返回自己。这个点还在父视图 bounds 里。
父视图.hitTest
│
├─ 子视图.hitTest
│ ✕ 交互关着
│ └── return nil
│
└── 没有别的子视图接住
返回父视图自己
叉号只在子视图上。父视图这一关是通过的,所以它把自己交出去。父视图 bounds 外面的视图不会因此被点到。
点击退回父视图,不会穿到父视图外面。
普通 UIView 不写触摸方法,点上去,父视图能收到。换成 UIButton,父视图没有打印。按钮上没有手势,gestureRecognizers 打出来也是空的。
UIButton 留下这次触摸,是因为它实现了触摸方法。 手势列表空不空,对不上这个差别。
参考资料
- hitTest(_:with:):哪个视图接住这个点
- Using responders and the responder chain to handle events:命中之后沿
next怎么走 - touchesBegan(_:with:):四个触摸方法里的开始
- UIGestureRecognizer:手势和视图收到触摸的先后