iOS 11 - UIBarButtonItem horizontal position

There used to be a way, on iOS 10 and below, to shift/position custom-view-UIBarButtonItems set on the left/rightBarButtonItems closer to the screen edges using a negative spacer outlined here:


This trick no longer works on iOS 11 with the new auto layout support in the navigation bar. Is there another means of shifting the left/right buttons closer to the screen edge?

Post not yet marked as solved Up vote post of master-nevi Down vote post of master-nevi
65k views

Answers

More info:

As of iOS 11 the left and right bar button items appear to be contained within subclasses of UIStackView (_UIButtonBarStackView) and those stack views are constrained to the navigation bar edges with constants of 16 pts. So now the question is, what's the UIKit friendly way of setting those constants. Is there a layoutMargin/safeAreaInset I can update?


Left side:

<NSLayoutConstraint:0x1c4486d10 UILayoutGuide:0x1c41bbf20'BackButtonGuide(0x159e05750)'.trailing >= _UINavigationBarContentView:0x159d3fe90.leading + 16   (active)>,
<NSLayoutConstraint:0x1c4486c20 H:[UILayoutGuide:0x1c41bbf20'BackButtonGuide(0x159e05750)']-(0)-[UILayoutGuide:0x1c41bc9a0'LeadingBarGuide(0x159e05750)']   (active)>,
<NSLayoutConstraint:0x1c4486220 _UIButtonBarStackView:0x159e099e0.leading == UILayoutGuide:0x1c41bc9a0'LeadingBarGuide(0x159e05750)'.leading   (active)>,


Right side:

<NSLayoutConstraint:0x1c4482120 UILayoutGuide:0x1c41b9d00'TrailingBarGuide(0x159e05750)'.trailing == _UINavigationBarContentView:0x159d3fe90.trailing - 16 priority:999   (active)>,
<NSLayoutConstraint:0x1c429dd80 _UIButtonBarStackView:0x15b958aa0.trailing == UILayoutGuide:0x1c41b9d00'TrailingBarGuide(0x159e05750)'.trailing   (active)>,

UPDATE: In beta 2, UIBarButtonItems without custom views appear to be offset from the navigation bar edge by 8pts instead of 16pts. However UIBarButtonItems with custom views are still offset by 16 pts 😟.


The code below demonstrates this behavior by implementing the left button with [UIBarButtonItem initWithCustomView:] using a UIButton as a custom view and the right button with [UIBarButtonItem initWithTitle:style:target:action:]. The custom view button is configured with the same tap target area via contentEdgeInsets that's imposed by on the non-custom right button:



self.navigationItem.leftBarButtonItem = [[UIBarButtonItem alloc] initWithCustomView:({
        UIButton *button = [UIButton buttonWithType:UIButtonTypeSystem];
        [button setTitle:@"Test" forState:UIControlStateNormal];
        button.contentEdgeInsets = UIEdgeInsetsMake(13, 8, 13, 8);
        button;
    })];

self.navigationItem.rightBarButtonItem = [[UIBarButtonItem alloc] initWithTitle:@"Test" style:UIBarButtonItemStylePlain target:nil action:nil];

I am using iOS 11 beta 3 and also experiencing this spacing issue. If I set the image insets I can get it to "look" aligned to the edge, but the touch events are still offset. If you've figured anything out I would greatly appreciate some help.

Here's my workaround, tested on iOS 11 beta 4.


BarButtonView.h

#import <UIKit/UIKit.h>

typedef NS_ENUM(NSInteger, BarButtonViewPosition) {
    BarButtonViewPositionLeft,
    BarButtonViewPositionRight
};

@interface BarButtonView : UIView

@property (nonatomic, assign) BarButtonViewPosition position;

@end


BarButtonView.m

#import "BarButtonView.h"

@interface BarButtonView ()
{
    BOOL applied;
}

@end

@implementation BarButtonView

- (void)layoutSubviews
{
    [super layoutSubviews];

    if (applied || [[[UIDevice currentDevice] systemVersion] doubleValue]  < 11)
    {
        return;
    }

    // Find the _UIButtonBarStackView containing this view, which is a UIStackView, up to the UINavigationBar
    UIView *view = self;
    while (![view isKindOfClass:[UINavigationBar class]] && [view superview] != nil)
    {
        view = [view superview];
        if ([view isKindOfClass:[UIStackView class]] && [view superview] != nil)
        {
            if (self.position == BarButtonViewPositionLeft)
            {
                [view.superview addConstraint:[NSLayoutConstraint constraintWithItem:view attribute:NSLayoutAttributeLeading relatedBy:NSLayoutRelationEqual toItem:view.superview attribute:NSLayoutAttributeLeading multiplier:1.0 constant:8.0]];
                applied = YES;
            }
            else if (self.position == BarButtonViewPositionRight)
            {
                [view.superview addConstraint:[NSLayoutConstraint constraintWithItem:view attribute:NSLayoutAttributeTrailing relatedBy:NSLayoutRelationEqual toItem:view.superview attribute:NSLayoutAttributeTrailing multiplier:1.0 constant:-8.0]];
                applied = YES;
            }
            break;
        }
    }
}

@end


Usage:

- (void)setLeftBarButtonView:(UIView *)view
{
    if ([[[UIDevice currentDevice] systemVersion] doubleValue] >= 11)
    {
        BarButtonView *barBtnView = [[BarButtonView alloc] initWithFrame:view.frame];
        [barBtnView setPosition:BarButtonViewPositionLeft];
        [barBtnView addSubview:view];
   
        [self.navigationItem setLeftBarButtonItem:[[UIBarButtonItem alloc] initWithCustomView:barBtnView]];
    }
    else
    {
        UIBarButtonItem *space = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemFixedSpace target:nil action:NULL];
        [space setWidth:-8];
    
        [self.navigationItem setLeftBarButtonItems:@[space,[[UIBarButtonItem alloc] initWithCustomView:view]]];
    }
}

- (void)setRightBarButtonView:(UIView *)view
{
    if ([[[UIDevice currentDevice] systemVersion] doubleValue] >= 11)
    {
        BarButtonView *barBtnView = [[BarButtonView alloc] initWithFrame:view.frame];
        [barBtnView setPosition:BarButtonViewPositionRight];
        [barBtnView addSubview:view];
   
        [self.navigationItem setRightBarButtonItem:[[UIBarButtonItem alloc] initWithCustomView:barBtnView]];
    }
    else
    {
        UIBarButtonItem *space = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemFixedSpace target:nil action:NULL];
        [space setWidth:-8];
    
        [self.navigationItem setRightBarButtonItems:@[space,[[UIBarButtonItem alloc] initWithCustomView:view]]];
    }
}


This is definitely not the perfect solution, and this trigger the "Unable to simultaneously satisfy constraints" error in the debug console. Use with caution.

IOS 11 navigation bar use auto layout, so you should change the frame or position using NSLayoutConstraint

How are you using autolayouts for the navigation bar? Adding your own constraints causes conflicts the same as the solution above.

Anyone found a solution for this issue? It makes sense they moved to UIStackView but I can't imagine why anyone would want a different offset for UIBarButtonItems that use custom views.😕


While browsing through the UIStackView docs I've found multiple properties/methods that could help with aligning the buttonitems:

If only we could access the UIStackView that holds the left- and rightBarButtonItems...😟

_button.translatesAutoresizingMaskIntoConstraints = NO;

iOS 11 set this to YES by default, try to set this property, and adjust button:

[_button setContentEdgeInsets:UIEdgeInsetsMake(10, 10, 10, 20)];


This may help you.😝

It works for me, thank you so much

Alright people, I think I got something.


After two days of constant struggling with all kinds of workarounds and after diving into private view hierarchies, this is what I concluded to:


Here's the view hierarchy of a UINavigationBar to its UIBarButtonItems:

UINavigationBar > _UINavigationBarContentView > _UIButtonBarStackView(s) > UIButton(s)


By independently looking at each one of the views and their auto layout constraints (not by the way visible in the Debug View Hierarchy!), I could see that there are constraints to the layout guide, and that was the cause of the weird horizontal positioning of the UIBarButtonItems.


In particular the content view's layout margins were the following (output from the console):


(lldb) po self.navigationController?.navigationBar.subviews[2].layoutMargins
▿ Optional<UIEdgeInsets>
  ▿ some : UIEdgeInsets
    - top : 0.0
    - left : 16.0
    - bottom : 0.0
    - right : 16.0


What I found is that simply setting the layout margins of the content view would fix the issue. You can do this easily in the layoutSubviews() method of your custom UINavigationBar. Since accessing the private view directly wouldn't be safe, iterating through all its subviews does the trick.


override func layoutSubviews() {
  super.layoutSubviews()

  for view in subviews {
    view.layoutMargins = .zero
  }
}


Not the cleanest solution but I guess will do until Apple addresses the issue.

Although this visually does the trick, the UIButton's tappable area becomes smaller that the actual element. :/

that is pretty useful. i was looking at the same thing today and it makes me wonder, why is this not possible by setting the layoutMargins of the navigationBar? since the layoutmargins of view of uiviewcontrollers is now customizable using

viewRespectsSystemMinimumLayoutMargins = false

doesn't it makes sense to allow this for uinavigationcontrollers? however, this approach has no effect. UIButtonBarStackView continue to use the system layout margins of the nav bar.

I totally agree.


I thought I could achieve that by setting the layoutMargins on the nav bar's View Controller, or Navigation Controller, but that didn't do the trick.

override func layoutSubviews() {

super.layoutSubviews();

if #available(iOS 11, *){

loop:

for view in subviews {

for stack in view.subviews {

if stack is UIStackView {

stack.superview?.layoutMargins = .zero;

break loop;

}

}

}

}

}

Just a workaround for my case: I would like an imageView stick to the left edge of navigationBar.


let logoImage = UIImage(named: "your_image")

let logoImageView = UIImageView(image: logoImage)

logoImageView.frame = CGRect(x: -16, y: 0, width: 150, height: 44)

logoImageView.contentMode = .scaleAspectFit

let logoView = UIView(frame: CGRect(x: 0, y: 0, width: 10, height: 44))

logoView.clipsToBounds = false

logoView.addSubview(logoImageView)

let logoItem = UIBarButtonItem(customView: logoView)

navigationItem.leftBarButtonItem = logoItem