Hacking UINavigationBar

N.B. There are 2 things that I won't be addressing in this post:

  • The fact that messing around with the subviews of any system view is not really a good idea, and is practically guaranteed to break in a future release of iOS.
  • Sometimes people turn their phone to landscape and your app will want to rotate.

Maybe I'll come back to landscape support in a future post.

As I will often do in posts about code, I'm going to explain the process I went through to solve a certain problem, rather than just give the answers. So forgive me if you feel cheated when I change my mind near the end.

UINavigationBar has never been the easiest class to customise. I'm working on a project at the moment in which the design calls for a UINavigationBar that is taller than normal to contain a UISegmentedControl for filtering the content.

I'm only going to cover the increased navigation bar size in this post. As well as adding the segmented control, the navigation bar also hides on scroll (à la Safari, Instagram) but I'll cover those features in another post.

A quick search easily solves the problem of make the navigation bar taller. Simply create a custom UINavigationBar subclass, which overrides sizeThatFits: and returns a larger size.

const CGFloat VFSNavigationBarHeightIncrease = 38.f;

@implementation VFSNavigationBar

- (CGSize)sizeThatFits:(CGSize)size {

    CGSize amendedSize = [super sizeThatFits:size];
    amendedSize.height += VFSNavigationBarHeightIncrease;

    return amendedSize;
}

@end

The resulting navigation bar is the correct height, but note that the content is still aligned to the bottom.

This seems like it would be easy to fix, just override layoutSubviews in the navigation bar subclass and move the buttons and title up by the increase in height. First up, let's examine the view hierarchy for the navigation bar (using po [[UIWindow keyWindow] recursiveDescription] in the debugger).

<VFSNavigationBar: 0x10974be90; baseClass = UINavigationBar; frame = (0 20; 320 82); opaque = NO; autoresize = W; gestureRecognizers = <NSArray: 0x10974c770>; layer = <CALayer: 0x1097419e0>>
| <_UINavigationBarBackground: 0x109758820; frame = (0 -20; 320 102); opaque = NO; autoresize = W; userInteractionEnabled = NO; layer = <CALayer: 0x1097589c0>> - (null)
|    | <_UIBackdropView: 0x109069c30; frame = (0 0; 320 102); opaque = NO; autoresize = W+H; userInteractionEnabled = NO; layer = <_UIBackdropViewLayer: 0x1090622f0>>
|    |    | <_UIBackdropEffectView: 0x109040fa0; frame = (0 0; 320 102); clipsToBounds = YES; opaque = NO; autoresize = W+H; userInteractionEnabled = NO; layer = <CABackdropLayer: 0x109068590>>
|    |    | <UIView: 0x10903fd20; frame = (0 0; 320 102); hidden = YES; opaque = NO; autoresize = W+H; userInteractionEnabled = NO; layer = <CALayer: 0x10905fed0>>
|    | <UIImageView: 0x109757f80; frame = (0 102; 320 0.5); userInteractionEnabled = NO; layer = <CALayer: 0x109741a20>> - (null)
| <UINavigationItemView: 0x10974c670; frame = (136.5 46; 47 27); opaque = NO; userInteractionEnabled = NO; layer = <CALayer: 0x1097416f0>>
|    | <UILabel: 0x10974cb70; frame = (0 3; 47 22); text = 'Home'; clipsToBounds = YES; opaque = NO; userInteractionEnabled = NO; layer = <CALayer: 0x109741c60>>
| <UINavigationButton: 0x109731a80; frame = (5 44; 47 30); opaque = NO; layer = <CALayer: 0x10974d050>>
|    | <UIImageView: 0x109754c50; frame = (11 2.5; 25 25); clipsToBounds = YES; opaque = NO; userInteractionEnabled = NO; layer = <CALayer: 0x109754e70>> - (null)
| <UINavigationButton: 0x1097531d0; frame = (275 44; 40 30); opaque = NO; layer = <CALayer: 0x109753120>>
|    | <UIImageView: 0x1097569e0; frame = (11 6; 18 18); clipsToBounds = YES; opaque = NO; userInteractionEnabled = NO; layer = <CALayer: 0x109756930>> - (null)
| <_UINavigationBarBackIndicatorView: 0x10906fcd0; frame = (8 50; 12.5 20.5); alpha = 0; opaque = NO; userInteractionEnabled = NO; layer = <CALayer: 0x109020880>> - Back

It's relatively easy to work out which subview corresponds to which part of the navigation bar.

  • The _UINavigationBarBackground contains the blur effect view and hairline separator.
  • The UINavigationItemView contains a UILabel, which is the title.
  • A UINavigationButton view contains a UIBarButtonItem, one for each side.
  • The _UINavigationBarBackIndicatorView is the back button, which isn't currently visible.

Ignoring the back button for now, we can assume that the subviews we want to move are either of the class UINavigationItemView or UINavigationButton. This leads to a relatively easy layoutSubviews hack.

const CGFloat VFSNavigationBarHeightIncrease = 38.f;

@implementation VFSNavigationBar

- (void)layoutSubviews {
    [super layoutSubviews];     

    NSArray *classNamesToReposition = @[@"UINavigationItemView", @"UINavigationButton"];

    for (UIView *view in [self subviews]) {

        if ([classNamesToReposition containsObject:NSStringFromClass([view class])]) {

            CGRect frame = [view frame];
            frame.origin.y -= VFSNavigationBarHeightIncrease;

            [view setFrame:frame];
        }
    }
}

@end

Resulting in.

Huh. The buttons have relocated just fine but the title is still aligned to the bottom. Taking another look at the view hierarchy you can see that the UILabel inside the UINavigationItemView has now been repositioned to be outside its superview.

<UINavigationItemView: 0x1090a4f40; frame = (136.5 8; 47 27); opaque = NO; userInteractionEnabled = NO; layer = <CALayer: 0x10909a0f0>>
| <UILabel: 0x1090a5440; frame = (0 41; 47 22); text = 'Home'; clipsToBounds = YES; opaque = NO; userInteractionEnabled = NO; layer = <CALayer: 0x10909a660>>

This is even more obvious with a simple background colour change on the UINavigationItemView.

Now to try something else. UINavigationBar has setTitleVerticalPositionOffset:forBarMetrics: method, which looks promising (and I also suspect is the reason for this title label repositioning problem). So instead of moving the UINavigationItemView let's just use this method to move the title.

const CGFloat VFSNavigationBarHeightIncrease = 38.f;

@implementation VFSNavigationBar

- (id)initWithCoder:(NSCoder *)aDecoder {

    self = [super initWithCoder:aDecoder];

    if (self) {
        [self initialize];
    }

    return self;
}

- (id)initWithFrame:(CGRect)frame {

    self = [super initWithFrame:frame];

    if (self) {
        [self initialize];
    }

    return self;
}

- (void)initialize {

    [self setTitleVerticalPositionAdjustment:-(VFSNavigationBarHeightIncrease) forBarMetrics:UIBarMetricsDefault];
}

- (void)layoutSubviews {
    [super layoutSubviews];     

    NSArray *classNamesToReposition = @[@"UINavigationButton"];

    for (UIView *view in [self subviews]) {

        if ([classNamesToReposition containsObject:NSStringFromClass([view class])]) {

            CGRect frame = [view frame];
            frame.origin.y -= VFSNavigationBarHeightIncrease;

            [view setFrame:frame];
        }
    }
}

@end

Obviously if this was going into production we'd need to consider the fact that setTitleVerticalPositionOffset:forBarMetrics: might be called by another class and account for that. Perhaps by overriding the method and adjusting the value. But I digress... it works.

It looks great until we push another view controller onto the navigation stack. During the push there are 2 issues. First of all, the original bar button items jump upwards seemingly randomly. And because the back button isn't aligned with the title in the detail view controller, the title cross-fade effect is broken.

Try adding the _UINavigationBarBackIndicatorView class to those that are repositioned in layoutSubviews and things get even worse.

At this point it becomes obvious that moving the buttons and title is not going to work - far too much is going wrong. Another approach is needed.

So, let's delete all of the layoutSubviews code we've written and just apply a simple transform to the navigation bar to push it upwards by our height increase.

const CGFloat VFSNavigationBarHeightIncrease = 38.f;

@implementation VFSNavigationBar

- (id)initWithCoder:(NSCoder *)aDecoder {

    self = [super initWithCoder:aDecoder];

    if (self) {
        [self initialize];
    }

    return self;
}

- (id)initWithFrame:(CGRect)frame {

    self = [super initWithFrame:frame];

    if (self) {
        [self initialize];
    }

    return self;
}

- (void)initialize {

    [self setTransform:CGAffineTransformMakeTranslation(0, -(VFSNavigationBarHeightIncrease))];
}

@end

This will return our navigation bar to its original appearance (even though we know it's actual 38 points taller), but it fixes the animations.

An important thing to note here is that even though we've translated the navigation bar upwards, the top layout guide remains at the increased offset. You can see that in this table view (note the position of the scroll bar).

So now we can resize and reposition the whole background of the navigation bar to get the appearance we want.

- (void)layoutSubviews {
    [super layoutSubviews];

    NSArray *classNamesToReposition = @[@"_UINavigationBarBackground"];

    for (UIView *view in [self subviews]) {

        if ([classNamesToReposition containsObject:NSStringFromClass([view class])]) {

            CGRect bounds = [self bounds];
            CGRect frame = [view frame];
            frame.origin.y = bounds.origin.y + VFSNavigationBarHeightIncrease - 20.f;
            frame.size.height = bounds.size.height + 20.f;

            [view setFrame:frame];
        }
    }
}

The maths is a bit more complicated in this one. The [super layoutSubviews] call doesn't position the background view on every pass, so we need to calculate the frame origin and size from the bounds. The subtraction and addition of 20 points are to account for the status bar - this should probably be improved to allow for a situation where the status bar is hidden, but I'm lazy.

Success.

In the next post on this subject I'll cover placing the UISegmentedControl and hiding the navigation bar as the user scrolls.