Search code examples
iosswifttouch-eventhittest

Why does clipsToBounds prevent the subviews to be touched?


Let's say you have a superview that has a smaller size than its subview. You set the clipsToBound property of the superview to false. If you tap on the protruding area of the subview that is outside of the bounds of the superview, why does the hit test return nil?

My understanding is that the hit test starts from the subview and work its way up to the superview. So why does the property of the superview that is to be tested later than the subview matter? Or does the hit test start from the root to tree views like the view controller -> the main view -> subviews?

I found a custom hit-test from here, which does allow you to tap on the subview's area outside of the bounds of the superview, but I'm not sure why reversing the order of subviews make a difference(it works, I'm just not sure why). My example even only has one subview.

class ViewController: UIViewController {
    let superview = CustomSuperview(frame: CGRect(origin: .zero, size: .init(width: 100, height: 100)))
    let subview = UIView(frame: CGRect(origin: .zero, size: .init(width: 200, height: 200)))

    override func viewDidLoad() {
        super.viewDidLoad()
        self.view.addSubview(self.superview)
        self.superview.backgroundColor = .red
        self.superview.clipsToBounds = false
        
        self.superview.addSubview(self.subview)
        self.subview.backgroundColor = .blue
         
        let tap = UITapGestureRecognizer(target: self, action: #selector(tapped))
        self.subview.addGestureRecognizer(tap)
    }
    
    @objc func tapped(_ sender: UIGestureRecognizer) {
        print("tapped")
    }
}

class CustomSuperview: UIView {
    override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {

        if clipsToBounds || isHidden || alpha == 0 {
            return nil
        }

        for subview in subviews.reversed() {
            let subPoint = subview.convert(point, from: self)
            if let result = subview.hitTest(subPoint, with: event) {
                return result
            }
        }

        return nil
    }
}

Solution

  • My understanding is that the hit test starts from the subview and work its way up to the superview.

    Then your understanding is completely wrong. Let’s fix that. First, let's clear up some other misunderstandings:

    • The reversed is a total red herring; it has nothing to do with it. The subviews are always tested in reverse order, because a subview in front needs to take precedence over a subview behind.

    • The clipsToBounds is a total red herring too. All it does is change whether you can see a subview outside its superview; it does not have any effect on whether you can touch a subview outside its superview.

    Okay, so how does this work? Let's take view V which contains a subview A. Let's suppose that A is outside V. Assume you can see A, and you tap A.

    Now hit-testing begins. But here's the thing: it starts at the level of the window, and works its way down the view hierarchy. So the window starts by interrogating its subviews; there is just one, the main view of the view controller.

    So now we recurse, and the main view of the view controller interrogates its subviews. One of those is V. "Hey, V, was the tap inside you?" "No!" (You have to agree that that is the correct answer, because we already said that A is outside V.)

    So the main view of the view controller gives up on V, and never finds out that the tap was on A, because we never recursed down that far. So it reports back up the chain: "The tap was not on any of my subviews, so I have to report that the tap was on me." The tap has fallen through to the view controller's main view.

    But you can change that behaviour by overriding the implementation of hitTest:

    override func hitTest(_ point: CGPoint, with e: UIEvent?) -> UIView? {
        if let result = super.hitTest(point, with:e) {
            return result
        }
        for sub in self.subviews.reversed() {
            let pt = self.convert(point, to:sub)
            if let result = sub.hitTest(pt, with:e) {
                return result
            }
        }
        return nil
    }