46

Hacking Hit Tests

 5 years ago
source link: https://www.tuicool.com/articles/hit/ziem6fe
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

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 self.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.

So, from this, we know we need to traverse 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 self.subviews.reversed() {

}
// ...

Next up in 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.

For each subview, we need to do two things: first, convert the touch’s point to the reference frame of the subview (rather than the superview), and second, check if the point is inside the subview:

for subview in self.subviews.reversed() {
	let convertedPoint = subview.convert(point, from: self)
	let pointInside = subview.point(inside: convertedPoint, with: event)
	// ...
}

-pointInside: is also an override point for UIView . Its default implementation checks if the point that is passed in is contained within the bounds of the view. If the subview returns true for the -pointInside: call, that means itself or one of its children was was under the touch event, which is when we call the subview’s -hitTest: method:

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

The reason we call -pointInside: first, and then call -hitTest: after, is because -pointInside: is super fast — it’s just little bit of geometry checking — “is this point in this rectangle?” -hitTest: , on the other hand, has to traverse the whole view hierarchy underneath it, making it a little more expensive.

Once we have our for 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 self.alpha > 0.01 else { return nil }
	
	for subview in self.subviews.reversed() {
		let convertedPoint = subview.convert(point, from: self)
		if subview.point(inside: convertedPoint, with: event) {
			return subview.hitTest(convertedPoint, with: event)
		}
	}
	return self
}

Now that we have an 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

clipsToBounds
-hitTest:

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 self.alpha > 0.01 else { return nil }

	if joinButton.point(inside: self.convert(point, to: joinButton), with: event) {
		return joinButton
	}
	
	if isDeleteButtonOpen && deleteButton.point(inside: self.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!


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK