15

While I was playing on my phone, I noticed that my UISegmentedControl was not very responsive. It would take 2 or more tries to make my taps register. So I decided to run my app in Simulator to more precisely probe what was wrong. By clicking dozens of times with my mouse, I determined that the top 25% of the UISegmentedControl does not respond (the portion is highlighted in red with Photoshop in the screenshot below). I am not aware of any invisible UIView that could be blocking it. Do you know how to make the entire control tappable?

uinavigationbar uisegmentedcontrol

self.segmentedControl = [[UISegmentedControl alloc] initWithItems:[NSArray arrayWithObjects:@"Uno", @"Dos", nil]];
self.segmentedControl.selectedSegmentIndex = 0;
[self.segmentedControl addTarget:self action:@selector(segmentedControlChanged:) forControlEvents:UIControlEventValueChanged];
self.segmentedControl.height = 32.0;
self.segmentedControl.width = 310.0;
self.segmentedControl.segmentedControlStyle = UISegmentedControlStyleBar;
self.segmentedControl.tintColor = [UIColor colorWithWhite:0.9 alpha:1.0];
self.segmentedControl.autoresizingMask = UIViewAutoresizingFlexibleLeftMargin | UIViewAutoresizingFlexibleRightMargin;

UIView* toolbar = [[UIView alloc] initWithFrame:CGRectMake(0, 0, self.view.width, HEADER_HEIGHT)];
toolbar.autoresizingMask = UIViewAutoresizingFlexibleWidth;
CAGradientLayer *gradient = [CAGradientLayer layer];
    gradient.frame = CGRectMake(
        toolbar.bounds.origin.x,
        toolbar.bounds.origin.y,
        // * 2 for enough slack when iPad rotates
        toolbar.bounds.size.width * 2,
        toolbar.bounds.size.height
    );
    gradient.colors = [NSArray arrayWithObjects:
        (id)[[UIColor whiteColor] CGColor],
        (id)[[UIColor 
            colorWithWhite:0.8
            alpha:1.0
            ] CGColor
        ],
        nil
];
[toolbar.layer insertSublayer:gradient atIndex:0];
toolbar.backgroundColor = [UIColor navigationBarShadowColor];
[toolbar addSubview:self.segmentedControl];

UIView* border = [[UIView alloc] initWithFrame:CGRectMake(0, HEADER_HEIGHT - 1, toolbar.width, 1)];
border.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleTopMargin;
border.backgroundColor = [UIColor colorWithWhite:0.7 alpha:1.0];
border.autoresizingMask = UIViewAutoresizingFlexibleWidth;
[toolbar addSubview:border];

[self.segmentedControl centerInParent];

self.tableView.tableHeaderView = toolbar;

http://scs.veetle.com/soget/session-thumbnails/5363e222d2e10/86a8dd984fcaddee339dd881544ecac7/5363e222d2e10_86a8dd984fcaddee339dd881544ecac7_20140509171623_536d6fd78f503_68_896x672.jpg

Pwner
  • 3,714
  • 6
  • 41
  • 67
  • What value is HEADER_HEIGHT (presume it is a macro or constant)? Where is this code invoked from - viewDidLoad? – TomSwift Jul 18 '13 at 20:02
  • `HEADER_HEIGHT = 42.0;` The code is invoked in `viewDidLoad` after `[super viewDidLoad]`. I use a `UIView+Position` category which has convenience properties such as x, y, width, height, and centerInParent. – Pwner Jul 18 '13 at 20:48
  • This might help http://stackoverflow.com/a/9719364/488611. The navigation bars touch area extends below to make it easier to tap. – James P Jul 18 '13 at 23:23
  • Have you tried switch on simulator option `Debug -> Color blended layers`? I maybe could show you some overlay you are not aware of and which could block you touches – Lukas Kukacka Jul 19 '13 at 09:50
  • If it's of any consolation: you experience the same behavior in Apple's own apps, when they use a segmented control right underneath the navigation bar. – Scott Berrevoets Jul 19 '13 at 19:04
  • I have a stable and legal solution (plus the explanation of why it happens ;-) look at my post – LombaX Jul 20 '13 at 17:34

9 Answers9

16

As already written in other answers, UINavigationBar grabs the touches made near the nav bar itself, but not because it has some subviews extended over the edges: this is not the reason.

If you log the whole view hierarchy, you will see that the UINavigationBar doesn't extends over the defined edges.

The reason why it receives the touches is another:

in UIKit, there are many "special cases", and this is one of them.

When you tap the screen, a process called "hit testing" starts. Starting from the first UIWindow, all views are asked to answer two "questions": is the point tapped inside your bounds? what is the subviews that must receive the touch event?

this questions are answered by these two methods:

- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event;
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event;

Ok, now we can continue.

After the tap, UIApplicationMain starts the hit testing process. The hit test starts from the main UIWindow (and is executed even on the status bar window and the alert view window, for example), and goes through all subviews.

This process is executed 3 times:

  • two times starting from UIWindow
  • one times starting from _UIApplicationHandleEvent

If you tap on the Navigation Bar, you will see that hitTest on UIWindow will return the UINavigationBar (all three times)

If you tap on the area below the Navigation Bar however, you will se something strange:

  • the first two hitTest will return your UISegmentedControl
  • the last hitTest will return UINavigationBar

why this? If you swizzle and subclass UIView, overriding hitTest, you will see that the first two times the tapped point is correct. The third time, something changes the point doing something like point - 15 (or a similar number)

After a lot of searching, I have found where this is happening:

UIWindow has a (private) method called

-(CGPoint)warpPoint:(CGPoint)point;

debugging it, I saw that this method changes the tapped point if it is immediately below the status bar. Debugging more, I saw that the stack calls that make this possible, are only 3:

[UINavigationBar, _isChargeEnabled]
[UINavigationBar, isEnabled]
[UINavigationBar, _isAlphaHittableAndHasAlphaHittableAncestors]

So, at the end, this warpPoint method checks if the UINavigationBar is enabled and hittable, if yes it "warps" the point. The point is warped of a number of pixel between 0 and 15, and this "warp" increases when you get closer to the Navigation Bar.

Now that you know what happens behind the scenes, you have to know how to avoid it (if you want).

You can't simply override warpPoint: if the application must go on the AppStore: it's a private method and your app will be rejected.

You have to find another system (like as suggested, overriding sendEvent, but I'm not sure if it will work)

Because this question is interesting, I will think about a legal solution tomorrow and update this answer (one good starting point can be subclassing UINavigationBar, overriding hitTest and pointInside, returning nil/false if, given the same event over multiple calls, the point changes. But I must test if it works tomorrow)

EDIT

Ok, I've tried many solutions but it's not simple to find a legal and stable one. I've described the actual behavior of the system, that could vary on different versions (hitTest called more or less than 3 times, the warpPoint warping the point of about 15px that can change ecc ecc).

The most stable is obviously the illegal override of warpPoint: in a UIWindow subclass:

-(CGPoint)warpPoint:(CGPoint)point;
{
    return point;
}

however, I've found that a method like this (in UIWindow subclass) it's stable enough and does the trick:

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
    // this method is not safe if you tap the screen two times at the same x position and y position different for 16px, because it moves the point
    if (self.lastPoint.x == point.x)
    {
        // the points are on the same vertical line
        if ((0 < (self.lastPoint.y - point.y)) && ((self.lastPoint.y - point.y) < 16) )
        {
            // there is a differenc of ~15px in the y position?
            // if so, the point has been changed
            point.y = self.lastPoint.y;
        }
    }

    self.lastPoint = point;

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

This method records the last point tapped, and if the subsequent tap is at the same x, and an y different for max 16px, then uses the previous point. I've tested a lot and it seems stable. If you want, you can add more controls to enable this behavior only in particular controllers, or only on a defined portion of the window, ecc ecc. If I find another solution, I'll update the post

LombaX
  • 17,265
  • 5
  • 52
  • 77
  • Would swizzling `warpPoint:` work? I'm not sure if that's illegal since you're not actually calling the method. – Scott Berrevoets Jul 19 '13 at 19:02
  • 2
    It's illegal, because you are swizzling a private method. It seems that Apple uses a static analyzer to find private symbols in the executable, so if you try to swizzle the method constructing the selector from a string at runtime (like: @"war" + @"P" + @"oint"), it could be difficult for Apple to find your use of a private method (but it's still illegal). It's not simple to find a legal solution, Now I post the simpler I've found – LombaX Jul 20 '13 at 17:16
1

I believe the problem is because the buttons in the UINavigationBar have a larger than normal touch area. See this SO post. You can also find plenty of discussion on this with a 'UINavigationBar touch area' Google search.

As a possible solution, you could put the segmented control IN the navigation bar, but you would know better than I if that fits your use cases or not.

Community
  • 1
  • 1
Mike
  • 2,399
  • 1
  • 20
  • 26
  • I need to put the segmented control under the navigation bar, because there is no space in the navigation bar. – Pwner Jul 18 '13 at 20:49
1

I've come up with an alternate solution that to me seems safer than LombaX's. It uses the fact that both events come in with the same timestamp to reject the subsequent event.

@interface RFNavigationBar ()

@property (nonatomic, assign) NSTimeInterval lastOutOfBoundsEventTimestamp;

@end

@implementation RFNavigationBar

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
    // [rfillion 2014-03-28]
    // UIApplication/UIWindow/UINavigationBar conspire against us. There's a band under the UINavigationBar for which the bar will return
    // subviews instead of nil (to make those tap targets larger, one would assume). We don't want that. To do this, it seems to end up
    // calling -hitTest twice. Once with a value out of bounds which is easy to check for. But then it calls it again with an altered point
    // value that is actually within bounds. The UIEvent it passes to both seem to be the same. However, we can't just compare UIEvent pointers
    // because it looks like these get reused and you end up rejecting valid touches if you just keep around the last bad touch UIEvent. So
    // instead we keep around the timestamp of the last bad event, and try to avoid processing any events whose timestamp isn't larger.
    if (point.y > self.bounds.size.height)
    {
        self.lastOutOfBoundsEventTimestamp = event.timestamp;
        return nil;
    }
    if (event.timestamp <= self.lastOutOfBoundsEventTimestamp + 0.001)
    {
        return nil;
    }
    return [super hitTest:point withEvent:event];
}

@end
0

You might want to check which view is recording the touches. Try this method-

-(void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
    UITouch *touch = [touches anyObject];
    [touch locationInView:self.view];
    if([touch.view isKindOfClass:[UISegmentedControl class]])
    {
      NSLog(@"This is UISegment");
    }
    else if([touch.view isKindOfClass:[UITabBar class]]) 
    {
      NSLog(@"This is UITabBar");
    } else if(...other views...) {
        ...
    }
 }

Once you figure that out you maybe able to narrow down your problem.

vforvendetta
  • 596
  • 7
  • 15
0

It looks as if you're using a category extension to set width/height on views, as well as center them in their parent. Perhaps there is a hidden issue here - can you refactor to do your layout w/out this category?

I copied your code into a clean project and ran it in a UITableViewController's viewDidLoad method - it works fine and I have no dead spots like you report. I had to change your code slightly since I don't have the same category extension that you're using.

Also, if you're running this code in viewDidLoad, you should verify that your view has a defined size (you access your view.width). If you're creating your UITableViewController programmatically (vs from a nib/storyboard) then the frame may be CGRectZero. Mine was loaded from a nib so the frame was preset.

I'd also try temporarily removing your border view to see if it's the culprit.

TomSwift
  • 39,369
  • 12
  • 121
  • 149
  • My UIView category is quite simple. `- (CGFloat) width { return self.frame.size.width; }` and `- (void) setWidth:(CGFloat)newWidth { self.frame = CGRectMake(self.x, self.y, newWidth, self.height); }`. The rest of the functions are quite similar. These all have been well unit tested and used for the past 2 years. I did put my code in `viewDidLoad`. There was no problem with `self.view`. It's frame is as follows: *x: 0.000000, y: 20.000000, w: 320.000000, h: 460.000000*. Removing the border did not solve the problem. – Pwner Jul 18 '13 at 20:55
  • can you drop your code in a clean UITableViewController and see if it replicates there? It did not in mine. – TomSwift Jul 18 '13 at 21:03
0

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;

Akki Zaveri
  • 1
  • 1
  • 3
0

An easy way to debug this is to try using DCIntrospect in your project. It's a very easy to use/implement library that makes finding out what views are where when in the simulator a breeze.

  1. Install the library and configure it
  2. Run the application in the simulator and navigate to the screen with the issue
  3. Press spacebar on the keyboard (the computer keyboard, not the simulator's keyboard)
  4. Click on the 25% area and see what gets highlighted.

If what's highlighted isn't the segmented view controller, that view could be what's covering up the touch event.

Cameron
  • 383
  • 3
  • 11
0

Create a protocol for UINavigationBar: (add new file and paste below code)

/******** file: UINavigationBar+BelowSpace.h*******/

"UINavigationBar+BelowSpace.h"

    #import <Foundation/Foundation.h>

@interface UINavigationBar (BelowSpace)

@end

/*******- file: UINavigationBar+BelowSpace.m*******/

#import "UINavigationBar+BelowSpace.h"

@implementation UINavigationBar (BelowSpace)


-(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];
}
@end

Hope this help ^ ^

tungnv
  • 29
  • 3
-1

Try this

self.navigationController!.navigationBar.userInteractionEnabled = false;
Tunaki
  • 132,869
  • 46
  • 340
  • 423
ashish0072
  • 11
  • 4