walker's code blog

coder, reader

hitTest示例

往窗口里添加两个自定义的view,这样每个view的hitTest方法被访问的时候我们就能log一下:

import UIKit

class ViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()

        let view1 = View1(frame: CGRect(x: 110, y: 110, width: 150, height: 150))
        let view2 = View2(frame: CGRect(x: 170, y: 170, width: 150, height: 150))
        view1.backgroundColor = .yellow
        view2.backgroundColor = .red

        self.view.addSubview(view1)
        self.view.addSubview(view2)
    }
}

class View1 : UIView {
    override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
        print("enter v1 \(point)")
        return super.hitTest(point, with: event)
    }
}

class View2 : UIView {
    override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
        print("enter v2, \(point)")
        return super.hitTest(point, with: event)
    }
}

运行:

image-20220219014246803

在空白处(bottom view)点了一下,输出:

enter v2, (-48.66007995605469, 306.0133361816406)
enter v1, (11.339920043945312, 366.0133361816406)
enter v2, (-48.66007995605469, 306.0133361816406)
enter v1, (11.339920043945312, 366.0133361816406)
enter v2, (-43.33333333333334, 325.3333333333333)
enter v1, (16.666666666666657, 385.3333333333333)
enter v2, (-43.33333333333334, 325.3333333333333)
enter v1, (16.666666666666657, 385.3333333333333)

在红框上(top view)点一下,输出:

enter v2, (38.66666666666666, 48.66666666666666)
enter v2, (38.66666666666666, 48.66666666666666)

在黄框(middle view)点一下,输出:

enter v2, (-31.210678100585938, -27.8685302734375)
enter v2, (-31.210678100585938, -27.8685302734375)
enter v2, (-25.0, -22.333333333333343)
enter v1, (35.0, 37.66666666666666)
enter v2, (-25.0, -22.333333333333343)
enter v1, (35.0, 37.66666666666666)
  • 我们知道hitTest机制是事件传递链由底向上,响应链由上到下,
  • 所以最底层的bottom view最先接到事件就开始找响应者
  • 它开始从它的最顶层subview开始找响应者(v2),然后再往下(v1),均没找到,所以就是自己了
  • 为何调了四次呢?(未深究)
    Yes, it’s normal. The system may tweak the point being hit tested between the calls. Since hitTest should be a pure function with no side-effects, this should be fine.
  • 第二次测试,直接在最顶层view就找到了(所谓的找到,就是点击的位置在这个view的bounds内,后面说)
  • 至于为什么坐标会变?(未深究)
  • 第三次测试,点了v1,可以看到日志,在v2那里跑了3次,再跑了v1,不知道为什么还是没返回,还跑了一次v2后才认定v1

大体可以知道hitTest的机制了吧?以传递链的终点那个view为基础,在subviews逆向遍历(自顶向下),一直到自己。

应用1

来个简单场景,如果黄色的view是有触摸事件的,并且要求被覆盖的区域也能响应,该怎么做呢?

思考:

  • 我们知道点击的位置,肯定是在黄框范围内的,所以要的就是一个入口,用来判断这个点与黄框的关系,一旦确认点的范围是在黄框里,就把认为黄框是事件响应者。
  • 因此改下demo,加了事件,还加了一个parent view(这样才能在用属性的方式把黄框引用出来)
class ViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()


        let view1 = UIView(frame: CGRect(x: 110, y: 110, width: 150, height: 150))
        let view2 = UIView(frame: CGRect(x: 170, y: 170, width: 150, height: 150))
        view1.backgroundColor = .yellow
        view2.backgroundColor = .red

        let tap = UITapGestureRecognizer(target: self, action: #selector(tap(_:)))
        view1.addGestureRecognizer(tap)
        view1.isUserInteractionEnabled = true

        let view = View(a: view1, b: view2)
        self.view.addSubview(view)
    }

    @objc func tap(_ sender: UIGestureRecognizer) {
        print("view1 taped")
    }
}

class View: UIView {
    var view1: UIView
    var view2: UIView
    init(a: UIView, b: UIView) {
        self.view1 = a
        self.view2 = b
        super.init(frame: UIScreen.main.bounds)
        self.addSubview(a)
        self.addSubview(b)
    }
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
      let loc_v1 = self.convert(point, to: self.view1)
          // 主要就是这一句
        if(self.view1.point(inside: loc_v1, with: event)) {
            return self.view1
        }
        return super.hitTest(point, with: event)
    }
}
  • let loc_v1 = self.convert(point, to: self.view1)意思是这个点以view1为坐标系的位置
  • if(self.view1.point(inside: loc_v1, with: event)) 这就是判断这个点在不在view1的bounds里面了

简单来说,就是简单粗暴地“逮住每一个机会”,问是不是你,是不是你。

应用2

如果一个按钮很小,你要扩大他的点击区域怎么做?网上有很多方法,关联属性啊,交换方法啊,可以去搜搜,我们这里继续上面的例子,知道有一个point(inside:with)方法,顾名思义,就是这个点在不在我的视图区域内

它当然也是可以被重写,自定义在什么样的范围内,都算inside,下面是网上抄的一段代码

- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent*)event {
   //获取当前button的实际大小
    CGRect bounds = self.bounds;
    //若原热区小于44x44,则放大热区,否则保持原大小不变
    CGFloat widthDelta = MAX(44.0 - bounds.size.width, 0);
    CGFloat heightDelta = MAX(44.0 - bounds.size.height, 0);
    //扩大bounds
    bounds = CGRectInset(bounds, -0.5 * widthDelta, -0.5 * heightDelta);
    //如果点击的点 在 新的bounds里,就返回YES
    return CGRectContainsPoint(bounds, point);
}

这个例子主要就是利用底层的CGRectContainsPoint方法,传入了新的bounds,可以理解为人为修改入参吧。代码也很明确了,自己根据当前的bounds合理做一个大一点的bounds,交给系统去做就行了。