This article is also available in Chinese.
Back in the days before Crusty taught us “protocol-oriented programming”, sharing of implementations happened mostly via inheritance. In an average day of UIKit programming, you might subclass UIView
, add some child views, override -layoutSubviews
, and repeat. Maybe you’ll override -drawRect
. But on weird days, when you need to do weird things, you start to look at those other methods on UIView
that you can override.
One of the more eccentric corners of UIKit is the touch handling subsystem. This primarily includes the two methods -pointInside:withEvent:
and -hitTest:withEvent:
.
-pointInside:
tells the caller if a given point is inside a given view. -hitTest:
uses -pointInside:
to tell the caller which subview (if any) would be the receiver for a touch at a given point. It’s this latter method that I’m interested in today.
Apple gives you barely enough documentation to figure out how to reimplement this method. Until you learn to reimplement it, you can’t change how it works. Let’s check out the documentation and try to write the function.
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
// ...
}
First, let’s start with a bit from the second paragraph:
This method ignores view objects that are hidden, that have disabled user interactions, or have an alpha level less than 0.01.
Let’s put some quick guard
statements up front to handle these preconditions.
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
guard isUserInteractionEnabled else { return nil }
guard !isHidden else { return nil }
guard alpha >= 0.01 else { return nil }
// ...
Easy enough. What’s next?
This method traverses the view hierarchy by calling the
-pointInside:withEvent:
method of each subview to determine which subview should receive a touch event.
While a literal reading of the documentation makes it sound like -pointInside:
is called on each of the subviews inside a for loop, this isn’t quite correct.
Thanks to a reader dropping breakpoints in -hitTest:
and -pointInside:
, we know -pointInside:
is called on self
(with the other guards), rather than on each of the subviews. So we can add this line of code to the other guards:
guard self.point(inside: point, with: event) else { return nil }
-pointInside:
is another override point for UIView
. Its default implementation checks if the point that is passed in is contained within the bounds
of the view. If a view returns true for the -pointInside:
call, that means the touch event was contained within its bounds.
With that minor discrepency out of the way, we can continue with the documentation:
If
-pointInside:withEvent:
returns YES, then the subview’s hierarchy is similarly traversed until the frontmost view containing the specified point is found.
So, from this, we know we need to iterate over the view tree. This means looping over all the views, and calling -hitTest:
on each of those to find the proper child. In this way, the method is recursive.
To iterate the view hierarchy, we’re going to need a loop. However, one of the more counterintuitive things about this method is we need to iterate the views in reverse. Views that are toward the end of the subviews
array are higher in Z axis, and so they should be checked out first. (I wouldn’t quite have picked up on this point without this blog post.)
// ...
for subview in subviews.reversed() {
}
// ...
The point that we are passed is defined relative to the coordinate system of this view, not the subview that we’re interested in. Fortunately UIKit gives us a handy function to convert points from our reference to the reference frame of any other view.
// ...
for subview in subviews.reversed() {
let convertedPoint = subview.convert(point, from: self)
// ...
}
// ...
Once we have a converted point, we can simply ask each subview what view it thinks is at that point. Remember, if the point lies outside that view (i.e., -pointInside:
returns false), the -hitTest
will return nil, and we’ll check the next subview in the hierarchy.
// ...
let convertedPoint = subview.convert(point, from: self)
if let candidate = subview.hitTest(convertedPoint, with: event) {
return candidate
}
//...
Once we have our loop in place, the last thing we need to do is return self
. If the view is tappable (which all of our guard
statements assert), but none of our subviews want to take this touch, that means that the current view, self
, is the correct target for the touch.
Here’s the whole algorithm:
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
guard isUserInteractionEnabled else { return nil }
guard !isHidden else { return nil }
guard alpha >= 0.01 else { return nil }
guard self.point(inside: point, with: event) else { return nil }
for subview in subviews.reversed() {
let convertedPoint = subview.convert(point, from: self)
if let candidate = subview.hitTest(convertedPoint, with: event) {
return candidate
}
}
return self
}
Now that we have a reference implementation, we can begin to modify it to enable specific behaviors.
I’ve discussed one of those behaviors on this blog before, in Changing the size of a paging scroll view, I talked about the “old and busted” way to create this effect. Essentially, you’d
- turn off
clipsToBounds
. - put an invisible view over the scrollable area.
- override
-hitTest:
on the invisible view to pass all touches through to the scrollview.
The -hitTest:
method was the cornerstone of this technique. Because hit testing in UIKit is delegated to each view, each view gets to decide which view receives its touches. This enables you to override the default implementation (which does something expected and normal) and replace it with whatever you want, even returning a view that’s not a direct child of the original view. Pretty wild.
Let’s take a look at a different example. If you’ve played with this year’s version of Beacon, you might have noticed that the physics for the swipe-to-delete behavior on events feel a little different from the built-in stuff that the rest of the OS uses. This is because we couldn’t quite get the appearance we wanted with the system approach, and we had to reimplement the feature.
As you can imagine, rewriting the physics of swiping and bouncing is needlessly complicated, so we used a UIScrollView
with pagingEnabled
set to true to get as much of the mechanics of the bouncing for free. Using a technique similar to an older post on this blog, we set a custom page size by making our scroll view bounds
smaller and moving the panGestureRecognizer
to an overlay view on top of the event cell.
However, while the overlay correctly passes touch events through to the scroll view, there are other events that the overlay incorrectly intercepts. The cell contains buttons, like the “join event” button and the “delete event” button that need to be able to receive touches. There are a few custom implementations of the -hitTest:
method that would work for this situation; one such implementation is to explicitly check the two button subviews:
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
guard isUserInteractionEnabled else { return nil }
guard !isHidden else { return nil }
guard alpha >= 0.01 else { return nil }
guard self.point(inside: point, with: event) else { return nil }
if joinButton.point(inside: convert(point, to: joinButton), with: event) {
return joinButton
}
if isDeleteButtonOpen && deleteButton.point(inside: convert(point, to: deleteButton), with: event) {
return deleteButton
}
return super.hitTest(point, with: event)
}
This correctly forwards the right tap events to the right buttons without breaking the scrolling behavior that reveals the delete button. (You could try ignoring just the deletionOverlay
, but then it won’t correctly forward scroll events.)
-hitTest:
is an override point for views that is rarely used, but when needed, can provide behaviors that are hard to build using other tools. Knowing how to implement it yourself gives you the ability to replace it at will. You can use the technique to make tap targets bigger, remove certain subviews from touch handling without removing them from the visible hierarchy, or use one view as a sink for touches that will affect a different view. All things are possible!