22、iOS中的事件传递和视图响应

本文探讨了触摸事件的产生、传递机制,重点解析hitTest方法的工作原理,并提供实际应用案例,如扩大点击范围、处理不规则图形触达和响应者链条管理。通过重写point(inside:)和hitTest方法,优化用户体验和视图层级响应。
摘要由CSDN通过智能技术生成

触摸事件传递

触摸事件的产生

  • (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
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值