Capturing touches on a subview outside the frame of its superview using hitTest:withEvent:
Asked Answered
K

8

102

My problem: I have a superview EditView that takes up basically the entire application frame, and a subview MenuView which takes up only the bottom ~20%, and then MenuView contains its own subview ButtonView which actually resides outside of MenuView's bounds (something like this: ButtonView.frame.origin.y = -100).

(note: EditView has other subviews that are not part of MenuView's view hierarchy, but may affect the answer.)

You probably already know the issue: when ButtonView is within the bounds of MenuView (or, more specifically, when my touches are within MenuView's bounds), ButtonView responds to touch events. When my touches are outside of MenuView's bounds (but still within ButtonView's bounds), no touch event is received by ButtonView.

Example:

  • (E) is EditView, the parent of all views
  • (M) is MenuView, a subview of EditView
  • (B) is ButtonView, a subview of MenuView

Diagram:

+------------------------------+
|E                             |
|                              |
|                              |
|                              |
|                              |
|+-----+                       |
||B    |                       |
|+-----+                       |
|+----------------------------+|
||M                           ||
||                            ||
|+----------------------------+|
+------------------------------+

Because (B) is outside (M)'s frame, a tap in the (B) region will never be sent to (M) - in fact, (M) never analyzes the touch in this case, and the touch is sent to the next object in the hierarchy.

Goal: I gather that overriding hitTest:withEvent: can solve this problem, but I don't understand exactly how. In my case, should hitTest:withEvent: be overridden in EditView (my 'master' superview)? Or should it be overridden in MenuView, the direct superview of the button that is not receiving touches? Or am I thinking about this incorrectly?

If this requires a lengthy explanation, a good online resource would be helpful - except Apple's UIView docs, which have not made it clear to me.

Thanks!

Kinder answered 2/8, 2012 at 3:41 Comment(0)
S
152

I have modified the accepted answer's code to be more generic - it handles the cases where the view does clip subviews to its bounds, may be hidden, and more importantly : if the subviews are complex view hierarchies, the correct subview will be returned.

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {

    if (self.clipsToBounds) {
        return nil;
    }

    if (self.hidden) {
        return nil;
    }

    if (self.alpha == 0) {
        return nil;
    }

    for (UIView *subview in self.subviews.reverseObjectEnumerator) {
        CGPoint subPoint = [subview convertPoint:point fromView:self];
        UIView *result = [subview hitTest:subPoint withEvent:event];

        if (result) {
            return result;
        }
    }

    return nil;
}

SWIFT 3

override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {

    if clipsToBounds || isHidden || alpha == 0 {
        return nil
    }

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

    return nil
}

I hope this helps anyone trying to use this solution for more complex use cases.

Slinky answered 14/2, 2013 at 13:13 Comment(10)
This looks good and thanks for doing it. However, had one question: why are you doing return [super hitTest:point withEvent:event]; ? Wouldn't you just return nil if there is no subview that triggers the touch? Apple says hitTest returns nil if no subview contains the touch.Flowery
Hm... Yea, that sounds just about right. Also, for correct behavior, the objects need to be iterated in reverse order (because the last one is the top-most one visually). Edited code to match.Slinky
Perfect, this helped me make a custom side menu for my project where I bring in custom UIButtons as the menuWade
I've just used your solution to make a UIButton capture the touch, and it is inside of a UIView that is inside of a UICollectionViewCell inside of (obviously) a UICollectionView. I had to subclass UICollectionView and UICollectionViewCell to override hitTest:withEvent: on this three classes. And it works like charm !! Thanks !!Fannie
The break statement after the return statement is not needed because it will never be executed. Otherwise great answer.Finsen
The last return should be self, shouldn't it? Otherwise, the touches on view itself will be ignored. Correct me, if I'm wrong.Lamentation
Depending on the desired use, it should either return [super hitTest:point withEvent:event] or nil. Returning self would cause it to receive everything.Slinky
Yep - I have UIView *me=[super hitTest...]; if (!me) {your code} else {return me} and that works for my use case.Readership
Here's a Q&A technical doc from Apple on this same technique: developer.apple.com/library/ios/qa/qa2013/qa1812.htmlNitrite
I have a similar situation. Where I have a drop down control that when tapped displays a table view below the control. Oftentimes this runs out of real estate for its parent view. The very simple solution is to assign the main view of the application as the parent of the drop down table view. Then everything works without having to loop through subviews and check hits. Of course you do have to adjust the coordinates in relation of the main view to the subview which contains the control. If you are using constraints, which I am not, this may not be possible.Egan
K
33

Ok, I did some digging and testing, here's how hitTest:withEvent works - at least at a high level. Image this scenario:

  • (E) is EditView, the parent of all views
  • (M) is MenuView, a subview of EditView
  • (B) is ButtonView, a subview of MenuView

Diagram:

+------------------------------+
|E                             |
|                              |
|                              |
|                              |
|                              |
|+-----+                       |
||B    |                       |
|+-----+                       |
|+----------------------------+|
||M                           ||
||                            ||
|+----------------------------+|
+------------------------------+

Because (B) is outside (M)'s frame, a tap in the (B) region will never be sent to (M) - in fact, (M) never analyzes the touch in this case, and the touch is sent to the next object in the hierarchy.

However, if you implement hitTest:withEvent: in (M), taps anywhere in in the application will be sent to (M) (or it least it knows about them). You can write code to handle the touch in that case and return the object that should receive the touch.

More specifically: the goal of hitTest:withEvent: is to return the object that should receive the hit. So, in (M) you might write code like this:

// need this to capture button taps since they are outside of self.frame
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{   
    for (UIView *subview in self.subviews) {
        if (CGRectContainsPoint(subview.frame, point)) {
            return subview;
        }
    }

    // use this to pass the 'touch' onward in case no subviews trigger the touch
    return [super hitTest:point withEvent:event];
}

I am still very new to this method and this problem, so if there are more efficient or correct ways to write the code, please comment.

I hope that helps anyone else who hits this question later. :)

Kinder answered 3/8, 2012 at 17:39 Comment(3)
Thanks, this worked for me, although I had to do some more logic to determine which of the subviews that should actually receive hits. I removed an unneeded break from your example btw.Im
When subview frame was beyound frame of parent view, click event was not responding! Your solution fixed this issue. Thanks a lot :-)Ecdysiast
@Kinder (funny nickname :) ) you should edit this excellent older answer to make it very clear (USE LARGE BOLD CAPS) in which class you have to add this. Cheers!Melodist
H
33

In Swift 5

override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
    guard !clipsToBounds && !isHidden && alpha > 0 else { return nil }
    for member in subviews.reversed() {
        let subPoint = member.convert(point, from: self)
        guard let result = member.hitTest(subPoint, with: event) else { continue }
        return result
    }
    return nil
}
Harpoon answered 28/3, 2015 at 14:24 Comment(2)
This will only work if you apply the override on the direct superview of the "misbehaving" viewKeniakenilworth
I think that this should also include a test for isUserInteractionEnabled.Mullen
B
2

What I would do is have both the ButtonView and MenuView exist at the same level in the view hierarchy by placing them both in a container whose frame completely fits both of them. This way the interactive region of the clipped item will not be ignored because of it's superview's boundaries.

Bolivar answered 2/8, 2012 at 4:7 Comment(1)
i thought about this workaround as well - it means i will have to duplicate some placement logic (or refactor some serious code!), but it may indeed be my best choice in the end..Kinder
A
1

If you have many other subviews inside your parent view then probably most of other interactive views would not work if you use above solutions, in that case you can use something like this(In Swift 3.2):

class BoundingSubviewsViewExtension: UIView {

    @IBOutlet var targetView: UIView!

    override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
        // Convert the point to the target view's coordinate system.
        // The target view isn't necessarily the immediate subview
        let pointForTargetView: CGPoint? = targetView?.convert(point, from: self)
        if (targetView?.bounds.contains(pointForTargetView!))! {
            // The target view may have its view hierarchy,
            // so call its hitTest method to return the right hit-test view
            return targetView?.hitTest(pointForTargetView ?? CGPoint.zero, with: event)
        }
        return super.hitTest(point, with: event)
    }
}
Afflictive answered 3/11, 2017 at 6:51 Comment(0)
E
0

Place below lines of code into your view hierarchy:

- (UIView*)hitTest:(CGPoint)point withEvent:(UIEvent*)event
{
    UIView* hitView = [super hitTest:point withEvent:event];
    if (hitView != nil)
    {
        [self.superview bringSubviewToFront:self];
    }
    return hitView;
}

- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent*)event
{
    CGRect rect = self.bounds;
    BOOL isInside = CGRectContainsPoint(rect, point);
    if(!isInside)
    {
        for (UIView *view in self.subviews)
        {
            isInside = CGRectContainsPoint(view.frame, point);
            if(isInside)
                break;
        }
    }
    return isInside;
}

For the more clarification, it was explained in my blog: "goaheadwithiphonetech" regarding "Custom callout : Button is not clickable issue".

I hope that helps you...!!!

Envious answered 19/5, 2015 at 10:50 Comment(1)
Your blog has been removed, so where can we find the explanation please?Alienage
E
0

If anyone needs it, here is the swift alternative

override func hitTest(point: CGPoint, withEvent event: UIEvent?) -> UIView? {
    if !self.clipsToBounds && !self.hidden && self.alpha > 0 {
        for subview in self.subviews.reverse() {
            let subPoint = subview.convertPoint(point, fromView:self);

            if let result = subview.hitTest(subPoint, withEvent:event) {
                return result;
            }
        }
    }

    return nil
}
Eft answered 26/8, 2016 at 17:16 Comment(0)
F
0

I took time to understand how this works exactly because the above solutions didn't work for me as I have many nested subviews. I'll try to explain simply hitTest so everyone can adapt his code depending on the situation.

Imagine this situation : A view called GrandParent has a subview entierly in its bounds called Parent. This Parent has a subview called Child that has bounds outside Parent's bounds :

-------------------------------------- -> Grand parent 
|              ------- -> Child      |
|              |     |               |
|          ----|-----|--- -> Parent  |
|          |   |   ^ |   |           |
|          |   |     |   |           |
|          ----|-----|---            |
|              | +   |               |
|     *        |-----|               |
|                                    |
--------------------------------------

* + and ^ are 3 different user touches. + touch is not recognized on Child Whenever you touch grand parent anywhere in its bound, grand parent will call all its direct subviews .hitTest, no matter where you touched in grand parent. So here, for the 3 touches, grand parent will call parent.hitTest().

hitTest is supposed to return the farthest descendant that contain the given point (the point is given relative to self bounds, in our exemple, Grand Parent call Parent.hitTest() with a point relative to Parent bounds) .

For * touch, parent.hitTest returns nil, and it's perfect. For ^ touch, parent.hitTest returns the Child, because it's in its bounds (default implementation of hitTest).

But for + touch, parent.hitTest returns nil by default, as the touch is not whithin parent's bound. So we need to have our custom implementation of hitTest, in order to basically convert the point relative to Parent's bound to a point relative to Child bounds. Then call hitTest on the child to see if the touch is whithin child bounds (child is supposed to have the default implementation of hitTest, returning the farthest descendant from it whithin its bounds, that is : itself).

If you have complex nested subviews and if the above solution doesn't work very well for you, this explanation may be usefull to make your custom implementation, fitting your view hierarchy.

Fergus answered 30/1, 2021 at 15:22 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.