Search code examples
ios12swift4.2xcode10.1

How Do I Hit Test A SubView's Layers?


I have encountered a challenge in my application. I want to be able to tap in a subview and determine which of the subview's layers was tapped. I (naively?) chose to use the hitTest(:CGPoint) method of the subview's root layer. The results were not what I expected. I have created a playground to illustrate the difficulty.

The playground (code below):

  1. Creates a view controller and its root view
  2. Adds a subview to the root view
  3. Adds a sublayer to the subview's root layer

Here is what it looks like:

enter image description here

  1. The root view's root layer is black
  2. The subview's root layer is red
  3. The subview's sublayer is translucent gray
  4. The white line is there for illustrative purposes

With the playground running I start tapping, beginning at the top of the white line and moving down. Here are the print statements produced by the tap gesture handling method:

Root view tapped @(188.5, 12.5)
Hit test returned "Root View -> Root Layer" with frame (0.0, 0.0, 375.0, 668.0)

Root view tapped @(188.5, 49.0)
Hit test returned "Root View -> Root Layer" with frame (0.0, 0.0, 375.0, 668.0)

Root view tapped @(188.5, 88.5)
Hit test returned "Root View -> Root Layer" with frame (0.0, 0.0, 375.0, 668.0)

Sub view tapped @(140.0, 11.0)

Sub view tapped @(138.5, 32.5)

Sub view tapped @(138.5, 55.5)

Sub view tapped @(138.0, 84.0)

Sub view tapped @(139.0, 113.0)
Hit test returned "Sub View -> Root Layer" with frame (50.0, 100.0, 275.0, 468.0)

Sub view tapped @(138.0, 138.5)
Hit test returned "Sub View -> Root Layer" with frame (50.0, 100.0, 275.0, 468.0)

Sub view tapped @(140.0, 171.5)
Hit test returned "Sub View -> Root Layer" with frame (50.0, 100.0, 275.0, 468.0)

Sub view tapped @(137.5, 206.5)
Hit test returned "Sub View -> Sub Layer" with frame (50.0, 100.0, 175.0, 268.0)

Sub view tapped @(135.5, 224.0)
Hit test returned "Sub View -> Sub Layer" with frame (50.0, 100.0, 175.0, 268.0)

You can see that as I tap in the root view things are "normal". As soon as I enter into the subview the hit test no longer finds a layer until I am more than 100 points below the top of the subview; at which point the subview's root layer is encountered. 100 points after that the sublayer of the subview's root layer is encountered.

What is going on? Well, apparently, when the subview was added to the root view, the subview's root layer became a sublayer of the root view's root layer and its frame was altered to reflect its placement in the root view's root layer instead of in the sub view.

So, may questions are:

  1. Am I correct regarding what has apparently happened?
  2. Is it normal and expected?
  3. How do I deal with it?

Thank you for taking the time to consider my question.

Here is the playground code:

//: [Previous](@previous)

import UIKit
import PlaygroundSupport

class ViewController: UIViewController {

    class View : UIView {
        override func draw(_ rect: CGRect) {
            let context = UIGraphicsGetCurrentContext()!
            context.setStrokeColor(UIColor.white.cgColor)
            context.setLineWidth(2.0)
            context.move(to: CGPoint(x: rect.midX, y: 0))
            context.addLine(to: CGPoint(x: rect.midX, y: rect.maxY))
            context.strokePath()
        }
    }

    private var subview: View!

    override func loadView() {
        let rootView = View(frame: CGRect.zero)
        rootView.backgroundColor = .black
        rootView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(tapGestureRecognized(_:))))
        rootView.layer.name = "Root View -> Root Layer"
        self.view = rootView
    }

    override func viewDidLayoutSubviews() {
        subview = View(frame: view.frame.insetBy(dx: 50.0, dy: 100.0))
        subview.backgroundColor = .red
        subview.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(tapGestureRecognized(_:))))
        subview.layer.name = "Sub View -> Root Layer"

        let sublayer = CALayer()
        sublayer.bounds = subview.layer.bounds.insetBy(dx: 50, dy: 100)
        sublayer.position = CGPoint(x: subview.layer.bounds.midX, y: subview.layer.bounds.midY)
        sublayer.backgroundColor = UIColor.gray.cgColor.copy(alpha: 0.5)
        sublayer.name = "Sub View -> Sub Layer"
        subview.layer.addSublayer(sublayer)

        view.addSubview(subview)
    }

    @objc func tapGestureRecognized(_ sender: UITapGestureRecognizer) {
        guard sender.state == .ended, let tappedView = sender.view else { return }

        var viewName: String
        switch tappedView {
        case view: viewName = "Root"
        case subview: viewName = "Sub"
        default: return
        }

        let location = sender.location(in: tappedView)

        print("\n\(viewName) view tapped @\(location)")

        if let layer = tappedView.layer.hitTest(location) {
            print("Hit test returned <\(layer.name ?? "unknown")> with frame \(layer.frame)")
        }
    }
}

PlaygroundPage.current.liveView = ViewController()
PlaygroundPage.current.needsIndefiniteExecution = true

//: [Next](@next)

Update: Problem Solved

With guidance from the always knowledgeable Matt Nueburg (and his most excellent book), I modified the tap gesture handler method to the following:

@objc func tapGestureRecognized(_ sender: UITapGestureRecognizer) {
    guard sender.state == .ended, let tappedView = sender.view else { return }

    var viewName: String
    let hitTestLocation: CGPoint

    switch tappedView {
    case view:
        viewName = "Root"
        hitTestLocation = sender.location(in: tappedView)
    case subview:
        viewName = "Sub"
        hitTestLocation = sender.location(in: tappedView.superview!)
   default: return
    }

    print("\n\(viewName) view tapped @\(sender.location(in: tappedView))")

    if let layer = tappedView.layer.hitTest(hitTestLocation) {
        print("Hit test returned <\(layer.name ?? "unknown")> with frame \(layer.frame)")
    }
}

Now the console output looks good:

Root view tapped @(186.0, 19.0)
Hit test returned "Root View -> Root Layer" with frame (0.0, 0.0, 375.0, 668.0)

Root view tapped @(187.0, 45.0)
Hit test returned "Root View -> Root Layer" with frame (0.0, 0.0, 375.0, 668.0)

Root view tapped @(187.0, 84.5)
Hit test returned "Root View -> Root Layer" with frame (0.0, 0.0, 375.0, 668.0)

Sub view tapped @(138.0, 9.0)
Hit test returned "Sub View -> Root Layer" with frame (50.0, 100.0, 275.0, 468.0)

Sub view tapped @(137.5, 43.5)
Hit test returned "Sub View -> Root Layer" with frame (50.0, 100.0, 275.0, 468.0)

Sub view tapped @(138.5, 77.5)
Hit test returned "Sub View -> Root Layer" with frame (50.0, 100.0, 275.0, 468.0)

Sub view tapped @(138.0, 111.0)
Hit test returned "Sub View -> Sub Layer 1" with frame (50.0, 100.0, 175.0, 268.0)

Sub view tapped @(138.5, 140.5)
Hit test returned "Sub View -> Sub Layer 1" with frame (50.0, 100.0, 175.0, 268.0)

Sub view tapped @(139.5, 174.5)
Hit test returned "Sub View -> Sub Layer 1" with frame (50.0, 100.0, 175.0, 268.0)

Second Update - Clearer Code

I am circling back with the code that I ended up using to detect touches on sub layers. It is, I hope, more clear than the playground code above.

private struct LayerTouched : CustomStringConvertible {

    let view: UIView // The touched view
    var layer: CALayer // The touched layer
    var location: CGPoint // The touch location expressed in the touched layer's coordinate system

    init(by recognizer: UIGestureRecognizer) {
        view = recognizer.view!
        let gestureLocation = recognizer.location(in: view)

        // AFAIK - Any touchable layer will have a super layer. Hence the forced unwrap is okay.
        let hitTestLocation = view.layer.superlayer!.convert(gestureLocation, from: view.layer)
        layer = view.layer.hitTest(hitTestLocation)!

        location = layer.convert(gestureLocation, from: view.layer)
    }

    var description: String {
        return """
        Touched \(layer)
        of \(view)
        at \(location).
        """
    }
}

Solution

  • the hitTest(:CGPoint) method of the subview's root layer

    But you have forgotten that layer hit testing works in a special way. The CGPoint you provide must be in the coordinate system of the superlayer of the layer you are hit testing.