Why does UINavigationBar steal touch events?
Asked Answered
B

12

19

I have a custom UIButton with UILabel added as subview. Button perform given selector only when I touch it about 15points lower of top bound. And when I tap above that area nothing happens.

I found out that it hasn't caused by wrong creation of button and label, because after I shift the button lower at about 15 px it works correctly.

UPDATE I forgot to say that button located under the UINavigationBar and 1/3 of upper part of the button don't get touch events.

Image was here

View with 4 buttons is located under the NavigationBar. And when touch the "Basketball" in top, BackButton get touch event, and when touch "Piano" in top, then rightBarButton (if exists) get touch. If not exists, nothing happened.

I didn't find this documented feature in App docs.

Also I found this topic related to my problem, but there is no answer too.

Beagle answered 31/1, 2012 at 12:58 Comment(1)
A reply by @nonamelive in this link solves the problem on all OSs (including iOS 7.0/7.1): #7807057Rainey
B
16

I found out the answer here(Apple Developer Forum).

Keith at Apple Developer Technical Support, on 18th May 2010 (iPhone OS 3):

I recommend that you avoid having touch-sensitive UI in such close proximity to the nav bar or toolbar. These areas are typically known as "slop factors" making it easier for users to perform touch events on buttons without the difficulty of performing precision touches. This is also the case for UIButtons for example.

But if you want to capture the touch event before the navigation bar or toolbar receives it, you can subclass UIWindow and override: -(void)sendEvent:(UIEvent *)event;

Also I found out,that when I touch the area under the UINavigationBar, the location.y defined as 64,though it was not. So I made this:

CustomWindow.h

@interface CustomWindow: UIWindow 
@end

CustomWindow.m

@implementation CustomWindow
- (void) sendEvent:(UIEvent *)event
{       
  BOOL flag = YES;
  switch ([event type])
  {
   case UIEventTypeTouches:
        //[self catchUIEventTypeTouches: event]; perform if you need to do something with event         
        for (UITouch *touch in [event allTouches]) {
          if ([touch phase] == UITouchPhaseBegan) {
            for (int i=0; i<[self.subviews count]; i++) {
                //GET THE FINGER LOCATION ON THE SCREEN
                CGPoint location = [touch locationInView:[self.subviews objectAtIndex:i]];

                //REPORT THE TOUCH
                NSLog(@"[%@] touchesBegan (%i,%i)",  [[self.subviews objectAtIndex:i] class],(NSInteger) location.x, (NSInteger) location.y);
                if (((NSInteger)location.y) == 64) {
                    flag = NO;
                }
             }
           }  
        }

        break;      

   default:
        break;
  }
  if(!flag) return; //to do nothing

    /*IMPORTANT*/[super sendEvent:(UIEvent *)event];/*IMPORTANT*/
}

@end

In AppDelegate class I use CustomWindow instead of UIWindow.

Now when I touch area under navigation bar, nothing happens.

My buttons still don't get touch events,because I don't know how to send this event (and change coordinates) to my view with buttons.

Beagle answered 15/3, 2012 at 12:8 Comment(6)
Do you know what's most strange here? That in the iPad, instead of 64, the origin that should be flagged to NO is 44...Keijo
Perhaps this is so. I have not checked on the iPad. And yes, this is queerly.Beagle
This even happens outside the containing view controller's bounds (my question). I've filed this as radar 19504573.Ravin
The Apple link doesn't work. Is there another link to active Apple documentation which gives this advice? I haven't found it in the HIG.Halliburton
@Halliburton it works for people with a developer account. Credit to the quote is given to Keith from Apple Developer Technical Support.Schweinfurt
I have a developer account and the link does not work. Takes you to login screen and then, after login, to the top developer account page.Halliburton
S
28

I noticed that if you set userInteractionEnabled to OFF, the NavigationBar doesn't "steal" the touches anymore.

So you have to subclass your UINavigationBar and in your CustomNavigationBar do this:

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

    if ([self pointInside:point withEvent:event]) {
        self.userInteractionEnabled = YES;
    } else {
        self.userInteractionEnabled = NO;
    }

    return [super hitTest:point withEvent:event];
}

Info about how to subclass UINavigationBar you can find here.

Saiva answered 15/5, 2012 at 9:19 Comment(4)
Disabling user interaction seems to work on the device for me. I'm testing with an iPhone 5 and iOS 6.1.2. Subclassing isn't necessary though; you can just disable user interaction externally.Victor
very nice solution, since I cannot disable user interaction (there was a button), thanks.Pip
This solution caused a bug for me where sometimes when navigating back only the navigation bar animated back, but the view wasn't removed or became black.Retrochoir
Works perfectly with iOS 9.3 on simulator as well as on device. I've created an UINavigationBar subclass and replaced the default class directly in IB.Grasp
B
16

I found out the answer here(Apple Developer Forum).

Keith at Apple Developer Technical Support, on 18th May 2010 (iPhone OS 3):

I recommend that you avoid having touch-sensitive UI in such close proximity to the nav bar or toolbar. These areas are typically known as "slop factors" making it easier for users to perform touch events on buttons without the difficulty of performing precision touches. This is also the case for UIButtons for example.

But if you want to capture the touch event before the navigation bar or toolbar receives it, you can subclass UIWindow and override: -(void)sendEvent:(UIEvent *)event;

Also I found out,that when I touch the area under the UINavigationBar, the location.y defined as 64,though it was not. So I made this:

CustomWindow.h

@interface CustomWindow: UIWindow 
@end

CustomWindow.m

@implementation CustomWindow
- (void) sendEvent:(UIEvent *)event
{       
  BOOL flag = YES;
  switch ([event type])
  {
   case UIEventTypeTouches:
        //[self catchUIEventTypeTouches: event]; perform if you need to do something with event         
        for (UITouch *touch in [event allTouches]) {
          if ([touch phase] == UITouchPhaseBegan) {
            for (int i=0; i<[self.subviews count]; i++) {
                //GET THE FINGER LOCATION ON THE SCREEN
                CGPoint location = [touch locationInView:[self.subviews objectAtIndex:i]];

                //REPORT THE TOUCH
                NSLog(@"[%@] touchesBegan (%i,%i)",  [[self.subviews objectAtIndex:i] class],(NSInteger) location.x, (NSInteger) location.y);
                if (((NSInteger)location.y) == 64) {
                    flag = NO;
                }
             }
           }  
        }

        break;      

   default:
        break;
  }
  if(!flag) return; //to do nothing

    /*IMPORTANT*/[super sendEvent:(UIEvent *)event];/*IMPORTANT*/
}

@end

In AppDelegate class I use CustomWindow instead of UIWindow.

Now when I touch area under navigation bar, nothing happens.

My buttons still don't get touch events,because I don't know how to send this event (and change coordinates) to my view with buttons.

Beagle answered 15/3, 2012 at 12:8 Comment(6)
Do you know what's most strange here? That in the iPad, instead of 64, the origin that should be flagged to NO is 44...Keijo
Perhaps this is so. I have not checked on the iPad. And yes, this is queerly.Beagle
This even happens outside the containing view controller's bounds (my question). I've filed this as radar 19504573.Ravin
The Apple link doesn't work. Is there another link to active Apple documentation which gives this advice? I haven't found it in the HIG.Halliburton
@Halliburton it works for people with a developer account. Credit to the quote is given to Keith from Apple Developer Technical Support.Schweinfurt
I have a developer account and the link does not work. Takes you to login screen and then, after login, to the top developer account page.Halliburton
M
3

Subclass UINavigationBar and add this method. It will cause taps to be passed through unless they are tapping a subview (such as a button).

 -(UIView*) hitTest:(CGPoint)point withEvent:(UIEvent *)event
 {
    UIView *v = [super hitTest:point withEvent:event];
    return v == self? nil: v;
 }
Moye answered 9/10, 2014 at 20:28 Comment(2)
Notably this will break any non-customized UIBarButtonitems in your navbar, eg. the back button.Schlemiel
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event { UIView *hitView = [super hitTest:point withEvent:event]; if ([hitView isKindOfClass:[UIControl class]]) { return hitView; } else { return nil; } } @SchlemielDespicable
H
2

The solution for me was the following one:

First: Add in your application (It doesn't matter where you enter this code) an extension for UINavigationBar like so: The following code just dispatch a notification with the point and event when the navigationBar is being tapped.

extension UINavigationBar {
open override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
    NotificationCenter.default.post(name: NSNotification.Name(rawValue: "tapNavigationBar"), object: nil, userInfo: ["point": point, "event": event as Any])
    return super.hitTest(point, with: event)
    }
}

Then in your specific view controller you must listen to this notification by adding this line in your viewDidLoad:

NotificationCenter.default.addObserver(self, selector: #selector(tapNavigationBar), name: NSNotification.Name(rawValue: "tapNavigationBar"), object: nil)

Then you need to create the method tapNavigationBar in your view controller as so:

func tapNavigationBar(notification: Notification) {
    let pointOpt = notification.userInfo?["point"] as? CGPoint
    let eventOpt = notification.userInfo?["event"] as? UIEvent?
    guard let point = pointOpt, let event = eventOpt else { return }

    let convertedPoint = YOUR_VIEW_BEHIND_THE_NAVBAR.convert(point, from: self.navigationController?.navigationBar)
    if YOUR_VIEW_BEHIND_THE_NAVBAR.point(inside: convertedPoint, with: event) {
        //Dispatch whatever you wanted at the first place.
    }
}

PD: Don't forget to remove the observation in the deinit like so:

deinit {
    NotificationCenter.default.removeObserver(self)
}

That's it... That's a little bit 'tricky', but it's a good workaround for not subclassing and getting a notification anytime the navigationBar is being tapped.

Hermon answered 27/4, 2017 at 19:7 Comment(0)
C
1

I just wanted to share another prospective to solving this problem. This is not a problem by design, but it was meant to help user get back or navigate. But we need to put things tightly in or below nav bar and things look sad.

First lets look at the code.

class MyNavigationBar: UINavigationBar {
private var secondTap = false
private var firstTapPoint = CGPointZero

override func pointInside(point: CGPoint, withEvent event: UIEvent?) -> Bool {
    if !self.secondTap{
        self.firstTapPoint = point
    }

    defer{
        self.secondTap = !self.secondTap
    }

    return  super.pointInside(firstTapPoint, withEvent: event)
}

}

You might be see why am i doing second touch handling. There is the recipe to the solution.

Hit test is called twice for a call. The first time the actual point on the window is reported. Everything goes well. On the second pass, this happens.

If system sees a nav bar and the hit point is around 9 pixels more on Y side, it tries to decrease that gradually to below 44 points which is where the nav bar is.

Take a look at the screen to be clear.

enter image description here

So theres a mechanism that will use nearby logic to the second pass of hittest. If we can know its second pass and then call the super with first hit test point. Job done.

The above code does that exactly.

Crupper answered 25/9, 2015 at 8:55 Comment(1)
if hit test is called an odd number of times for some reason, then it would break, isn't it?Schweinfurt
R
0

There are 2 things that might be causing problems.

  1. Did you try setUserInteractionEnabled:NO for the label.

  2. Second thing i think might work is apart from that after adding label on top of button you can send the label to back (it might work, not sure although)

    [button sendSubviewToBack:label];

Please let me know if the code works :)

Ratliff answered 31/1, 2012 at 13:23 Comment(1)
Thanks, but code works same way, area above label text don't get touches... but under label text it works normal.Beagle
A
0

Your labels are huge. They start at {0,0} (the left top corner of the button), extend over the entire width of the button and have a height of the entire view. Check your frame data and try again.

Also, you have the option of using the UIButton property titleLabel. Maybe you are setting the title later and it goes into this label rather than your own UILabel. That would explain why the text (belonging to the button) would work, while the label would be covering the rest of the button (not letting the taps go through).

titleLabel is a read-only property, but you can customize it just as your own label (except perhaps the frame) including text color, font, shadow, etc.

Auriculate answered 31/1, 2012 at 14:3 Comment(1)
Thanks, but I found out that wasn't the problem of creation button and label. And now I use titleLabel instead of my label.Beagle
B
0

Extending Alexander's solution:

Step 1. Subclass UIWindow

@interface ChunyuWindow : UIWindow {
    NSMutableArray * _views;

@private
    UIView *_touchView;
}

- (void)addViewForTouchPriority:(UIView*)view;
- (void)removeViewForTouchPriority:(UIView*)view;

@end
// .m File
// #import "ChunyuWindow.h"

@implementation ChunyuWindow
- (void) dealloc {
    TT_RELEASE_SAFELY(_views);
    [super dealloc];
}


- (void)motionBegan:(UIEventSubtype)motion withEvent:(UIEvent *)event {
    if (UIEventSubtypeMotionShake == motion
        && [TTNavigator navigator].supportsShakeToReload) {
        // If you're going to use a custom navigator implementation, you need to ensure that you
        // implement the reload method. If you're inheriting from TTNavigator, then you're fine.
        TTDASSERT([[TTNavigator navigator] respondsToSelector:@selector(reload)]);
        [(TTNavigator*)[TTNavigator navigator] reload];
    }
}

- (void)addViewForTouchPriority:(UIView*)view {
    if ( !_views ) {
        _views = [[NSMutableArray alloc] init];
    }
    if (![_views containsObject: view]) {
        [_views addObject:view];
    }
}

- (void)removeViewForTouchPriority:(UIView*)view {
    if ( !_views ) {
        return;
    }

    if ([_views containsObject: view]) {
        [_views removeObject:view];
    }
}

- (void)sendEvent:(UIEvent *)event {
    if ( !_views || _views.count == 0 ) {
        [super sendEvent:event];
        return;
    }

    UITouch *touch = [[event allTouches] anyObject];
    switch (touch.phase) {
        case UITouchPhaseBegan: {
            for ( UIView *view in _views ) {
                if ( CGRectContainsPoint(view.frame, [touch locationInView:[view superview]]) ) {
                    _touchView = view;
                    [_touchView touchesBegan:[event allTouches] withEvent:event];
                    return;
                }
            }
            break;
        }
        case UITouchPhaseMoved: {
            if ( _touchView ) {
                [_touchView touchesMoved:[event allTouches] withEvent:event];
                return;
            }
            break;
        }
        case UITouchPhaseCancelled: {
            if ( _touchView ) {
                [_touchView touchesCancelled:[event allTouches] withEvent:event];
                _touchView = nil;
                return;
            }
            break;
        }
        case UITouchPhaseEnded: {
            if ( _touchView ) {
                [_touchView touchesEnded:[event allTouches] withEvent:event];
                _touchView = nil;
                return;
            }
            break;
        }

        default: {
            break;
        }
    }

    [super sendEvent:event];
}

@end

Step 2: Assign ChunyuWindow instance to AppDelegate Instance

Step 3: Implement touchesEnded:widthEvent: for view with buttons, for example:

- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event {
    [super touchesEnded: touches withEvent: event];

    UITouch *touch = [touches anyObject];
    CGPoint point = [touch locationInView: _buttonsView]; // a subview contains buttons
    for (UIButton* button in _buttons) {
        if (CGRectContainsPoint(button.frame, point)) {
            [self onTabButtonClicked: button];
            break;
        }
    }    
}

Step 4: call ChunyuWindow's addViewForTouchPriority when the view we care about appears, and call removeViewForTouchPriority when the view disappears or dealloc, in viewDidAppear/viewDidDisappear/dealloc of ViewControllers, so _touchView in ChunyuWindow is NULL, and it is the same as UIWindow, having no side effects.

Butterfingers answered 14/5, 2013 at 16:42 Comment(0)
A
0

This solved my problem..

I added hitTest:withEvent: code to my navbar subclass..

 -(UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
        int errorMargin = 5;// space left to decrease the click event area
        CGRect smallerFrame = CGRectMake(0 , 0 - errorMargin, self.frame.size.width, self.frame.size.height);
        BOOL isTouchAllowed =  (CGRectContainsPoint(smallerFrame, point) == 1);

        if (isTouchAllowed) {
            self.userInteractionEnabled = YES;
        } else {
            self.userInteractionEnabled = NO;
        }
        return [super hitTest:point withEvent:event];
    }
Antimissile answered 24/1, 2014 at 9:53 Comment(0)
L
0

An alternative solution that worked for me, based on the answer provided by Alexandar:

self.navigationController?.barHideOnTapGestureRecognizer.enabled = false

Instead of overriding the UIWindow, you can just disable the gesture recogniser responsible for the "slop area" on the UINavigationBar.

Lonely answered 2/6, 2016 at 15:4 Comment(3)
looked promising but not working (ios10, tested on a device)Aires
Saviour, simplest solution, struggled entire day for this, Thanks Chris.Dibbell
Yep, unfortunately not working on iOS 14.3Mcatee
C
0

Give a extension version according to Bart Whiteley. No need to subclass.

@implementation UINavigationBar(Xxxxxx)

- (UIView*)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
    UIView *v = [super hitTest:point withEvent:event];
    return v == self ? nil: v;
}

@end
Calista answered 31/8, 2016 at 12:15 Comment(1)
It is not safe to add category methods with the same name as existing methods on an object. (see https://mcmap.net/q/55602/-overriding-methods-using-categories-in-objective-c)Postrider
L
0

The following worked for me:

self.navigationController?.isNavigationBarHidden = true
Link answered 24/5, 2019 at 14:49 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.