文章目录
触摸事件传递
触摸事件的产生
- (1)当用户点击屏幕时,会产生一个触摸事件,系统会将该事件加入到由UIApplication管理的事件队列中。为什么是队列而不是栈?因为队列的特点是先进先出,先产生的事件先处理才符合常理,所以把事件添加到队列。
- (2)UIApplication会从事件队列中取出最早的事件进行分发处理,先发送事件给应用程序的主窗口(keyWindow)
- (3)主窗口会调用hitTest方法在视图层次结构中找到一个最合适的UIView来处理触摸事件。
触摸事件的传递
hitTest方法
在UIView的头文件中存在两个方法:
//返回响应点击事件的对象
func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView?
//根据点击坐标返回事件是否发生在本视图以内
// default returns YES if point is in bounds
func point(inside point: CGPoint, with event: UIEvent?) -> Bool
hitTest是UIView的一个方法,该方法会被系统调用,是用于在视图层次结构中找到一个最合适的UIView来响应触摸事件。
hitTest的调用顺序:touch->UIApplication->UIWindow->UIViewController.view->subViews->…->view
。
hitTest的执行过程
- (1) 判断view是否能够接收触摸事件
- (2) 首先在当前视图的hitTest方法中调用point(inside:,with:)方法判断触摸点是否在当前视图内
- (3) 若pointInside方法返回no,说明触摸点不在当前视图内,则当前视图的hitTest返回nil,该视图不处理该事件
- (4) 若point(inside:,with:)方法返回true,说明触摸点在当前视图内,则从最上层的子视图开始(即subviews数组的末尾向前遍历),遍历当前视图的所有子视图,调用子视图的hitTest方法重复步骤1-3
- (5)直到所有子视图的hitTest方法返回非空对象或者全部子视图遍历完毕
- (6)若第一次有子视图的hitTest方法返回非空对象,则当前视图的hitTest方法就返回此对象,处理结束
- (7)若所有子视图的hitTest方法都返回nil,则当前视图的hitTest方法返回当前视图本身,最终由该对象处理触摸事件。
hitTest伪代码
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
//如果不允许点击,返回nil
if(!isUserInteractionEnabled || isHidden || alpha <= 0.001) {
return nil
}
//如果点击时当前view,则继续处理触摸事件
if self.point(inside: point, with: event) {
for subView in subviews.reversed() {
//将点击位置坐标转换到subView的坐标
let convertedPoint = subView.convert(point, from: self)
//调用subView的hitTest方法
if let hitTestView = subView.hitTest(convertedPoint, with: event) {
return hitTestView
}
}
return self
}
return nil
}
视图响应
什么是响应者链条
在 iOS 程序中无论是最后面的 UIWindow 还是最前面的某个按钮,它们的摆放是有前后关系的,一个控件可以放到另一个控件上面或下面,那么用户点击某个控件时是触发上面的控件还是下面的控件呢,这种先后关系构成一个链条就叫“响应者链”。
触摸事件响应
系统通过事件传递,找到合适的view之后就调用该view的touches方法处理具体的触摸事件,找不到合适的view就不会调用touches方法进行事件处理。
触摸事件响应就是顺着响应者链条向上传递,将事件交给上一个响应者进行处理(即调用super.touches方法)。
//只要点击控件,就会调用touchBegin,如果没有重写这个方法,自己处理不了触摸事件
//当然如果你添加addTarget或者addGesture是可以处理触摸事件的。
//上一个响应者可能是父控件
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
//注意不是调用父控件的touches方法,而是调用父类的touches方法
//super是父类 superview是父控件
super.touchesBegan(touches, with: event)
}
触摸事件响应基本流程
- (1)如果当前 view 是控制器的 view,那么控制器就是上一个响应者,事件就传递给控制器;如果当前 view 不是控制器的 view,那么父视图就是当前 view 的上一个响应者,事件就传递给它的父视图。
- (2)在视图层次结构的最顶级视图,如果也不能处理收到的事件或消息,则其将事件或消息传递给 window 对象进行处理。
- (3)如果 window 对象也不处理,则其将事件或消息传递给 UIApplication 对象。
- (4)如果 UIApplication 也不能处理该事件或消息,则将其丢弃。
hitTest方法的实际应用
扩大点击范围
有时候图标很小,我们想要扩大点击范围可以使用hitTest方法
方法一: 重写point(inside:with)方法
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
let touchRect = bounds.insetBy(dx: -50, dy: -50)
return touchRect.contains(point)
}
方法二:重写hitTest方法
class HitTestBtn: UIButton {
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
if !isUserInteractionEnabled || isHidden || alpha <= 0.01 {
return nil
}
//insetBy方法
/*
dx 正数是向X轴正方向移动(即像右移动) 负数是向X轴负方向移动(即像左移动)
dy 正数是向Y轴正方向移动(即像下移动) 负数是向Y轴负方向移动(即像上移动)
注意:这个方法并不仅仅是移动那么简单 移动之后他的宽高也会对应的调节
*/
//得到一个新的点击范围
let touchRect = bounds.insetBy(dx: -50, dy: -50)
//只要点击的点在这个点击范围内
if touchRect.contains(point) {
for subView in subviews.reversed() {
let convertPoint = subView.convert(point, from: self)
if let hitView = subView.hitTest(convertPoint, with: event) {
return hitView
}
}
return self
}
return nil
}
}
将触摸事件传递到下面的视图
有时候我们需要一个视图忽略touch事件并把它们传递到其下面的视图。比如我们希望hud在显示提示文字的时候,仍然可以操作.
class TestView: UIView {
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
//获取父类响应事件的对象
let hitView = super.hitTest(point, with: event)
//如果是自己返回nil,不处理
if hitView == self {
return nil
}
return hitView
}
}
让超出父View的部分仍然可以响应触摸事件
希望超出TabBar部分的半圆任然可以响应点击事件
class TabBar: UITabBar {
var centerBtnClick:(()->())?
private var centerBtn:UIButton!
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .white
//去掉TabBar的分割线
backgroundImage = UIImage()
shadowImage = UIImage()
centerBtn = UIButton()
centerBtn.setBackgroundImage(UIImage(named: "tab_launch"), for: .normal)
centerBtn.setBackgroundImage(UIImage(named:"tab_launch"), for: .highlighted)
centerBtn.bounds.size = centerBtn.currentBackgroundImage?.size ?? CGSize(width: 44, height: 44)
centerBtn.addTarget(self, action: #selector(TabBar.click), for: .touchUpInside)
addSubview(centerBtn)
}
@objc private func click() {
centerBtnClick?()
}
override func layoutSubviews() {
super.layoutSubviews()
//系统自带的按钮类型是UITabBarButton,找出这些类型,然后重新布局,空出中间位置
guard let sysClass = NSClassFromString("UITabBarButton") else {
print("未知错误")
return
}
//获取系统btn的个数
var tabBarBtns:[UIView] = []
for subView in subviews {
if (subView.isKind(of: sysClass)) {
tabBarBtns.append(subView)
}
}
//设置中心btn的位置
centerBtn.center.x = center.x
//调整中间按钮的中线点Y值
centerBtn.center.y = 0
//计算系统item的宽度
let barItemWidth = (bounds.size.width - centerBtn.bounds.size.width) / CGFloat(tabBarBtns.count)
//重新布局每个item的位置
for (index,subItem) in tabBarBtns.enumerated() {
if (index >= tabBarBtns.count / 2){
subItem.frame.origin.x = CGFloat(index) * barItemWidth + centerBtn.bounds.size.width
}else {
subItem.frame.origin.x = CGFloat(index) * barItemWidth
}
}
bringSubview(toFront: centerBtn)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
//把坐标转换为centerBtn上面的坐标
let convertPoint = convert(point, to: centerBtn)
//如果点击的点在centerBtn返回true
if centerBtn.point(inside: convertPoint, with: event) {
return true
}
//如果点击的点不在,则调用父类的方法,看看是不是点击了其他item
return super.point(inside: point, with: event)
}
}
如何使用
let tabBar = TabBar(frame: .zero)
tabBar.centerBtnClick = {
print("我被点击了")
}
setValue(tabBar, forKey: "tabBar")
不规则图形的点击
假设有一个view宽高为200,绘制成了一个半径为100的圆,如何只响应圆内的点击事件。
重写point(inside:with)方法
//一个view的宽高为200,只允许圆内响应点击事件
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
let radius:CGFloat = 100 // 圆的半径
let xOffset = point.x - 100 //获取到相对于圆心的坐标
let yOffset = point.y - 100 //获取到相对于圆心的坐标
let clickRadius = sqrt(xOffset * xOffset + yOffset*yOffset)
return clickRadius <= radius
}