Event handling for iOS - how hitTest:withEvent: and pointInside:withEvent: are related?
Asked Answered
Y

7

156

While most apple documents are very well written, I think 'Event Handling Guide for iOS' is an exception. It's hard for me to clearly understand what's been described there.

The document says,

In hit-testing, a window calls hitTest:withEvent: on the top-most view of the view hierarchy; this method proceeds by recursively calling pointInside:withEvent: on each view in the view hierarchy that returns YES, proceeding down the hierarchy until it finds the subview within whose bounds the touch took place. That view becomes the hit-test view.

So is it like that only hitTest:withEvent: of the top-most view is called by the system, which calls pointInside:withEvent: of all of subviews, and if the return from a specific subview is YES, then calls pointInside:withEvent: of that subview's subclasses?

Yearround answered 10/2, 2011 at 18:57 Comment(2)
A very good tutorial that helped me out linkKeening
The equivalent newer document for this might now be developer.apple.com/documentation/uikit/uiview/1622469-hittestBye
K
179

It seems quite a basic question. But I agree with you the document is not as clear as other documents, so here is my answer.

The implementation of hitTest:withEvent: in UIResponder does the following:

  • It calls pointInside:withEvent: of self
  • If the return is NO, hitTest:withEvent: returns nil. the end of the story.
  • If the return is YES, it sends hitTest:withEvent: messages to its subviews. it starts from the top-level subview, and continues to other views until a subview returns a non-nil object, or all subviews receive the message.
  • If a subview returns a non-nil object in the first time, the first hitTest:withEvent: returns that object. the end of the story.
  • If no subview returns a non-nil object, the first hitTest:withEvent: returns self

This process repeats recursively, so normally the leaf view of the view hierarchy is returned eventually.

However, you might override hitTest:withEvent to do something differently. In many cases, overriding pointInside:withEvent: is simpler and still provides enough options to tweak event handling in your application.

Kaiserslautern answered 10/2, 2011 at 19:7 Comment(9)
Do you mean hitTest:withEvent: of all subviews are executed eventually?Yearround
Yes. Just override hitTest:withEvent: in your views (and pointInside if you want), print a log and call [super hitTest... to find out whose hitTest:withEvent: is called in which order.Kaiserslautern
shouldn't step 3 where you mention "If the return is YES, it sends hitTest:withEvent: ...shouldn't it be pointInside:withEvent? I thought it sends pointInside to all subviews?Lavina
Back in February it first sent hitTest:withEvent:, in which a pointInside:withEvent: was sent to itself. I haven't re-checked this behavior with following SDK versions, but I think sending hitTest:withEvent: makes more sense because it provides a higher-level control of whether an event belongs to a view or not; pointInside:withEvent: tells whether the event location is on the view or not, not whether the event belongs to the view. For example, a subview may not want to handle an event even if its location is on the subview.Kaiserslautern
I think, it also be consider the zOrder of subviews. the enumeration subviews might be from top zOrder to lower. If two view belongs to the some parent view. The two views intersect each other. The one at to top will be first called.Halfwitted
WWDC2014 Session 235 - Advanced Scrollviews and Touch Handling Techniques gives great explanation and example for this problem.Pact
What does "top level" subview mean in this case? Does it go through the subviews array from index 0, or from the other end?Stpierre
It reverses the subview array and then sends in incrementing index order. i.e. logically equivalent to sending hittest: last index to fist index.Gingili
Ridiculously useful answer. I never would've guessed that pointInside:withEvent is called by hitTest on the same object. And this actually helped me resolve a massive issue today, thank you!Neveda
I
305

I think you are confusing subclassing with the view hierarchy. What the doc says is as follows. Say you have this view hierarchy. By hierarchy I'm not talking about class hierarchy, but views within views hierarchy, as follows:

+----------------------------+
|A                           |
|+--------+   +------------+ |
||B       |   |C           | |
||        |   |+----------+| |
|+--------+   ||D         || |
|             |+----------+| |
|             +------------+ |
+----------------------------+

Say you put your finger inside D. Here's what will happen:

  1. hitTest:withEvent: is called on A, the top-most view of the view hierarchy.
  2. pointInside:withEvent: is called recursively on each view.
    1. pointInside:withEvent: is called on A, and returns YES
    2. pointInside:withEvent: is called on B, and returns NO
    3. pointInside:withEvent: is called on C, and returns YES
    4. pointInside:withEvent: is called on D, and returns YES
  3. On the views that returned YES, it will look down on the hierarchy to see the subview where the touch took place. In this case, from A, C and D, it will be D.
  4. D will be the hit-test view
Ivoryivorywhite answered 10/2, 2011 at 19:8 Comment(6)
Thank you for the answer. What you described is also what was in my mind, but @MHC says hitTest:withEvent: of B, C and D are also invoked. What happens if D is a subview of C, not A? I think I got confused...Yearround
In my drawing, D is a subview of C.Ivoryivorywhite
Wouldn't A return YES as well, just as C and D does?Tireless
Don't forget that views that are invisible (either by .hidden or opacity below 0.1), or have user interaction turned off will never respond to hitTest. I don't think hitTest is being called on these objects in the first place.Facility
Just wanted to add that hitTest:withEvent: may be called on all the views depending on their hierarchy.Dahle
I want to say that, you are wrong. If you define a custom subclass of uiview, you'll notice that no one calls your pointInside. the hitTest is the only method that is called by your parent view. and in your implementation of hitTest, you don't call subview's pointInside, you just call hitTest.Bum
K
179

It seems quite a basic question. But I agree with you the document is not as clear as other documents, so here is my answer.

The implementation of hitTest:withEvent: in UIResponder does the following:

  • It calls pointInside:withEvent: of self
  • If the return is NO, hitTest:withEvent: returns nil. the end of the story.
  • If the return is YES, it sends hitTest:withEvent: messages to its subviews. it starts from the top-level subview, and continues to other views until a subview returns a non-nil object, or all subviews receive the message.
  • If a subview returns a non-nil object in the first time, the first hitTest:withEvent: returns that object. the end of the story.
  • If no subview returns a non-nil object, the first hitTest:withEvent: returns self

This process repeats recursively, so normally the leaf view of the view hierarchy is returned eventually.

However, you might override hitTest:withEvent to do something differently. In many cases, overriding pointInside:withEvent: is simpler and still provides enough options to tweak event handling in your application.

Kaiserslautern answered 10/2, 2011 at 19:7 Comment(9)
Do you mean hitTest:withEvent: of all subviews are executed eventually?Yearround
Yes. Just override hitTest:withEvent: in your views (and pointInside if you want), print a log and call [super hitTest... to find out whose hitTest:withEvent: is called in which order.Kaiserslautern
shouldn't step 3 where you mention "If the return is YES, it sends hitTest:withEvent: ...shouldn't it be pointInside:withEvent? I thought it sends pointInside to all subviews?Lavina
Back in February it first sent hitTest:withEvent:, in which a pointInside:withEvent: was sent to itself. I haven't re-checked this behavior with following SDK versions, but I think sending hitTest:withEvent: makes more sense because it provides a higher-level control of whether an event belongs to a view or not; pointInside:withEvent: tells whether the event location is on the view or not, not whether the event belongs to the view. For example, a subview may not want to handle an event even if its location is on the subview.Kaiserslautern
I think, it also be consider the zOrder of subviews. the enumeration subviews might be from top zOrder to lower. If two view belongs to the some parent view. The two views intersect each other. The one at to top will be first called.Halfwitted
WWDC2014 Session 235 - Advanced Scrollviews and Touch Handling Techniques gives great explanation and example for this problem.Pact
What does "top level" subview mean in this case? Does it go through the subviews array from index 0, or from the other end?Stpierre
It reverses the subview array and then sends in incrementing index order. i.e. logically equivalent to sending hittest: last index to fist index.Gingili
Ridiculously useful answer. I never would've guessed that pointInside:withEvent is called by hitTest on the same object. And this actually helped me resolve a massive issue today, thank you!Neveda
W
52

I find this Hit-Testing in iOS to be very helpful

enter image description here

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
    if (!self.isUserInteractionEnabled || self.isHidden || self.alpha <= 0.01) {
        return nil;
    }
    if ([self pointInside:point withEvent:event]) {
        for (UIView *subview in [self.subviews reverseObjectEnumerator]) {
            CGPoint convertedPoint = [subview convertPoint:point fromView:self];
            UIView *hitTestView = [subview hitTest:convertedPoint withEvent:event];
            if (hitTestView) {
                return hitTestView;
            }
        }
        return self;
    }
    return nil;
}

Edit Swift 4:

override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
    if self.point(inside: point, with: event) {
        return super.hitTest(point, with: event)
    }
    guard isUserInteractionEnabled, !isHidden, alpha > 0 else {
        return nil
    }

    for subview in subviews.reversed() {
        let convertedPoint = subview.convert(point, from: self)
        if let hitView = subview.hitTest(convertedPoint, with: event) {
            return hitView
        }
    }
    return nil
}
Wholesome answered 15/7, 2015 at 3:8 Comment(1)
So you need to add this to a subclass of UIView and have all the views in your hierarchy inherit from it?Legator
G
21

Thanks for answers, they helped me to solve situation with "overlay" views.

+----------------------------+
|A +--------+                |
|  |B  +------------------+  |
|  |   |C            X    |  |
|  |   +------------------+  |
|  |        |                |
|  +--------+                | 
|                            |
+----------------------------+

Assume X - user's touch. pointInside:withEvent: on B returns NO, so hitTest:withEvent: returns A. I wrote category on UIView to handle issue when you need to receive touch on top most visible view.

- (UIView *)overlapHitTest:(CGPoint)point withEvent:(UIEvent *)event {
    // 1
    if (!self.userInteractionEnabled || [self isHidden] || self.alpha == 0)
        return nil;

    // 2
    UIView *hitView = self;
    if (![self pointInside:point withEvent:event]) {
        if (self.clipsToBounds) return nil;
        else hitView = nil;
    }

    // 3
    for (UIView *subview in [self.subviewsreverseObjectEnumerator]) {
        CGPoint insideSubview = [self convertPoint:point toView:subview];
        UIView *sview = [subview overlapHitTest:insideSubview withEvent:event];
        if (sview) return sview;
    }

    // 4
    return hitView;
}
  1. We should not send touch events for hidden or transparent views, or views with userInteractionEnabled set to NO;
  2. If touch is inside self, self will be considered as potential result.
  3. Check recursively all subviews for hit. If any, return it.
  4. Else return self or nil depending on result from step 2.

Note, [self.subviewsreverseObjectEnumerator] needed to follow view hierarchy from top most to bottom. And check for clipsToBounds to ensure not to test masked subviews.

Usage:

  1. Import category in your subclassed view.
  2. Replace hitTest:withEvent: with this
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
    return [self overlapHitTest:point withEvent:event];
}

Official Apple's Guide provides some good illustrations too.

Hope this helps somebody.

Gratification answered 26/6, 2013 at 20:19 Comment(2)
Amazing! Thanks for the clear logic and GREAT code snippet, solved my head-scratcher!Beller
@Lion, Nice answer. Also you can check equality to clear color in first step.Slighting
A
5

iOS Touch Event

1. user made a touch
2. system creates an event object with global coordinate of touch
3. hit testing by coordinate - find First Responder

4.1 send Touch Event to `UIGestureRecognizer`. After handling the touch can or can not(depends on setup) be forward to the First Responder

4.2 send Touch Event to the First Responder
4.2.1 handle Touch Event 

Class diagram

3 Hit Testing

Hit Testing to find a First Responder - checking hierarchy of UIView and successors of it (e.g. UIWindow). Recursively find a front view starting from the biggests(back) UIView(UIWindow is a start/root point) to the smallest(front)) UIView. As a result First Responder in this case is a top (the smallest) UIView, point() method(hitTest() uses point() internally) of which returned true.

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

Internally hitTest() takes into account

  • point() == true
  • point() takes into account rectangle which is inside superview
  • isUserInteractionEnabled == true
  • isHidden == false
func hitTest() -> View? {
    if (isUserInteractionEnabled == false || isHidden == true || alpha == 0 || point() == false) { return nil }

    for subview in subviews {
        if subview.hitTest() != nil {
            return subview
        }
    }
    return nil
}

Notes:

  • by default when you set view.isUserInteractionEnabled = false this view and all it's subview will not handle the touch event
  • by default point() takes into account rectangle which lay into superview. It means that if a touch is occured on a view part which is drawn out of superview, this view and all it's subview will not handle the touch event

[UIView.clipsToBounds]

4 Send UIEvent

UIKit creates UIEvent which is sent by UIApplication.shared.sendEvent() to main event loop[About]. UIEvent contains one or more UITouchwhich contains -UIView`, location...

It always go through UIApplication.sendEvent() -> UIWindow.sendEvent() -> <First_Responder>

UIApplication.sendEvent() -> UIWindow.sendEvent() -> <First_Responder>.touchesBegan()
UIApplication.sendEvent() -> UIWindow.sendEvent() -> <First_Responder>.touchesEnded()
//UIApplication.shared.sendEvent()

//UIApplication, UIWindow
func sendEvent(_ event: UIEvent)

4.1 send Touch Event to UIGestureRecognizer

It is a simple and handy way to work with gestures. There are some out of box UIGestureRecognizer like UITapGestureRecognizer, UISwipeGestureRecognizer ... and you are able to create your own

let tap_v_0_2 = UITapGestureRecognizer(target: self, action: #selector(self.onView_0_2))
view_0_2.addGestureRecognizer(tap_v_0_2)

@objc func onView_0_2() {
    print("---> onView V_0_2")
}

System tries to find a UIGestureRecognizer in a view hierarchy. Starts from first responder (using UIView.superview) up toUIWindow(subclass of UIView). It means if you setup UIGestureRecognizer on UIWindow and first responder is UIView_0 without UIGestureRecognizer - UIWindow.UIGestureRecognizer will be called

There are some functions to hadle work between UIGestureRecognizer in UIGestureRecognizerDelegate

And some functions to handle forwarding event to Responder Chain like: cancelsTouchesInView, delaysTouchesBegan, delaysTouchesEnded

4.2 Send Touch Event to the First Responder

It is more low level and more advanced approach where you can customize your logic. To use it you should inherit from UIView and override some methods, like:

//UIResponder
func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?)
func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?)
func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?)
func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?)

When UIView_0_2 is a first responder:

4.2.1 Handle Touch Event

When FirstRessponder is found it's a time to handle Touch Event using Responder Chain

Responder Chain

It a kind of chain of responsibility pattern who extends UIResponder. Every UIResponder has next property which points on next responder in the chain

By default:

  • UIView.next -> superview ?? UIViewController
  • UIViewController.next -> UINavigationController ?? UIWindow
  • UIWindow.next -> UIApplication

Abstract diagram:

Print responder chain starting from UIView_0_2:

UIView_0_2 -> UIView_0 -> ViewController -> UIDropShadowView -> UITransitionView -> CustomWindow -> UIWindowScene -> CustomApplication -> AppDelegate

In case with Touch Event:

  • it starts from FirstResponder and try to find overrode appropriate method (e.g. touchesBegan()...) If this method was not overrode - UIResponder.next is used to find next responder and try to call this method there
  • if you call super.touchesBegan() inside override func touchesBegan() - UIResponder.next is used to find next responder and try to call this method there

For example if first responder is UIView_0_2 but touchesBegan() is override in UIWindow(not in UIView_0_2) - UIWindow will hadle this touch event

UIWindow hitTest BEGIN
UIWindow point result:true
    UIView_0 hitTest BEGIN
    UIView_0 point result:true
      UIView_0_1 hitTest BEGIN
      UIView_0_1 point result:false
      UIView_0_1 hitTest END result: nil

      UIView_0_2 hitTest BEGIN
      UIView_0_2 point result:true
        UIView_0_2_1 hitTest BEGIN
        UIView_0_2_1 hitTest END result: nil
      UIView_0_2 hitTest END result: UIView_0_2
    UIView_0 hitTest END result: UIView_0_2
UIWindow hitTest END result: UIView_0_2

Let's take a look at example:

Additional usage of Responder Chain

Responder chain is also used by UIControl.addTarget() or UIApplication.sendAction() approaches like event bus.

customButton.addTarget(nil, action: #selector(CustomApplication.onButton), for: .touchUpInside)
//target is nil. In other cases Responder Chain is not ussed

UIApplication.shared.sendAction(#selector(CustomApplication.foo), to: nil, from: self, for: nil)

Internally it uses UIResponder.target() and UIResponder.canPerformAction(). When first UIRessponder starts a flow - target() is called, if super.target() is called inside override func target() then canPerformAction() is called, if canPerformAction() returns false then next is used to find a next UIResponder and recursively repeated these steps, if canPerformAction() returns true(by default the selector was found in this object) then this target will be recursively returned back and will be used to call the selector

Notes:

  • When target doesn't contain a selector but canPerformAction() returns true next error is thrown:

unrecognized selector sent to instance

For example - you call UIApplication.shared.sendAction(#selector(CustomApplication.foo), to: nil, from: self, for: nil) from UIViewController and(as you can see) foo selector is located in CustomApplication(UIApplication)

    UIViewController target Begin
    UIViewController canPerformAction result:false
    UIViewController next is: UIWindow
  UIWindow target Begin
  UIWindow canPerformAction result:false
  UIWindow next is UIApplication
UIApplication target begin
UIApplication canPerformAction result:true
UIApplication target result: UIApplication
  UIWindow target result: UIApplication
    UIViewController target result: UIApplication
//foo method body logic

[Android onTouch]

Adam answered 18/5, 2020 at 16:14 Comment(0)
A
4

It shows like this snippet!

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
    if (self.hidden || !self.userInteractionEnabled || self.alpha < 0.01)
    {
        return nil;
    }

    if (![self pointInside:point withEvent:event])
    {
        return nil;
    }

    __block UIView *hitView = self;

    [self.subViews enumerateObjectsWithOptions:NSEnumerationReverse usingBlock:^(id obj, NSUInteger idx, BOOL *stop) {   

        CGPoint thePoint = [self convertPoint:point toView:obj];

        UIView *theSubHitView = [obj hitTest:thePoint withEvent:event];

        if (theSubHitView != nil)
        {
            hitView = theSubHitView;

            *stop = YES;
        }

    }];

    return hitView;
}
Alejandro answered 4/9, 2013 at 4:22 Comment(2)
I find this the easiest to understand answer, and it matches my observations of the actual behaviour very closely. The only difference is that the subviews are enumerated in reverse order, so subviews closer to the front receive touches in preference to siblings behind them.Placket
@DouglasHill thanks to your correction. Best regardsAlejandro
W
1

The snippet of @lion works like a charm. I ported it to swift 2.1 and used it as an extension to UIView. I'm posting it here in case somebody needs it.

extension UIView {
    func overlapHitTest(point: CGPoint, withEvent event: UIEvent?) -> UIView? {
        // 1
        if !self.userInteractionEnabled || self.hidden || self.alpha == 0 {
            return nil
        }
        //2
        var hitView: UIView? = self
        if !self.pointInside(point, withEvent: event) {
            if self.clipsToBounds {
                return nil
            } else {
                hitView = nil
            }
        }
        //3
        for subview in self.subviews.reverse() {
            let insideSubview = self.convertPoint(point, toView: subview)
            if let sview = subview.overlapHitTest(insideSubview, withEvent: event) {
                return sview
            }
        }
        return hitView
    }
}

To use it, just override hitTest:point:withEvent in your uiview as follows:

override func hitTest(point: CGPoint, withEvent event: UIEvent?) -> UIView? {
    let uiview = super.hitTest(point, withEvent: event)
    print("hittest",uiview)
    return overlapHitTest(point, withEvent: event)
}
Wiedmann answered 13/1, 2016 at 18:19 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.