“How do I pass touches through a view without removing the ability to interact with its subviews?”
Setting userInteractionEnabled to NO on a view passes through all touches, but we need our subviews to remain interactive. The code necessary to make this work is very simple, and this technique can also be used for solving another problem — accepting touches for subviews whose bounds extend beyond or exist entirely outside of the superview’s frame.
Understanding hitTest and pointInside
There are two methods in UIView for hit testing. These are pointInside:withEvent: and hitTest:withEvent:. When a view is touched, these two methods work together to detect which view or subview has actually been touched.
HitTest is designed to return the deepest descendant view that the touch was inside of. When hitTest:withEvent: is called for a particular view, it sends a message to pointInside:withEvent: (returns BOOL) on itself. If pointInside returns NO, then hitTest will return nil. Otherwise, hitTest will traverse subviews sending a message to hitTest:withEvent: for each one until one returns a view. If no view is ever returned, hitTest will return self.
We are going to change the behavior of pointInside:withEvent in a way that will not register a touch in an area of the view where a subview doesn’t already exist.
The Code
In our UIView subclass, we create this override:
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event
{
BOOL pointInside = NO;
// make sure the touch is inside the view
if(CGRectContainsPoint(self.bounds, point))
{
// step through our subviews' frames
for (UIView *subview in self.subviews)
{
// see if the point is inside their frame
if([subview pointInside:[self convertPoint:point toView:subview] withEvent:event])
{
pointInside = YES;
break;
}
}
}
return pointInside;
}
In general, if the touch is inside of the frame of any of the subviews (as well as the parent’s bounds), then we consider the point to be inside the view. Otherwise, NO is returned and the touch will “pass through.”
Solving Other Problems Easily
Another way to override pointInside is to allow subviews outside of their superview’s bounds to receive touches. In this example, our parent view will not pass touches through.
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event
{
BOOL pointInside = NO;
// step through our subviews' frames
for (UIView *subview in self.subviews)
{
if(!CGRectContainsRect(self.bounds, subview.frame) && [subview pointInside:[self convertPoint:point toView:subview] withEvent:event])
{
pointInside = YES;
break;
}
}
// now check inside the bounds
if(!pointInside)
{
pointInside = [super pointInside:point withEvent:event];
}
return pointInside;
}
Notice we have removed the bounds check restricting touches to the parent. Now pointInside steps through the subviews and checks any subviews that are not contained in the parent’s bounds to see if the point is in their frame. If none of the subviews outside the parent’s bounds were hit, then we do the regular old pointInside:withEvent: to see if we’re inside of the parent. Now your views can be interactive outside their superview’s bounds!
Conclusion
It’s not a complicated technique, but some of the help available out there is not complete enough. I saw one solution where the pointInside was implemented as a chain of CGRectContainsPoint() calls logically OR’d inside of an if() that returns YES. I suppose this works, but why risk forgetting about adding a call for your newest/latest/greatest subview? That’s why I’ve written this one up. Any improvements on my method? Let me know in the comments.