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):
Here is what it looks like:
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:
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).
"""
}
}
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.