32

I'm trying to build a titleView with constraints that looks like this:

titleView

I know how I would do this with frames. I would calculate the width of the text, the width of the image, create a view with that width/height to contain both, then add both as subviews at the proper locations with frames.

I'm trying to understand how one might do this with constraints. My thought was that intrinsic content size would help me out here, but I'm flailing around wildly trying to get this to work.

UILabel *categoryNameLabel = [[UILabel alloc] init];
categoryNameLabel.text = categoryName; // a variable from elsewhere that has a category like "Popular"
categoryNameLabel.translatesAutoresizingMaskIntoConstraints = NO;
[categoryNameLabel sizeToFit]; // hoping to set it to the instrinsic size of the text?

UIView *titleView = [[UIView alloc] init]; // no frame here right?
[titleView addSubview:categoryNameLabel];
NSArray *constraints;
if (categoryImage) {
    UIImageView *categoryImageView = [[UIImageView alloc] initWithImage:categoryImage];
    [titleView addSubview:categoryImageView];
    categoryImageView.translatesAutoresizingMaskIntoConstraints = NO;
    constraints = [NSLayoutConstraint constraintsWithVisualFormat:@"|[categoryImageView]-[categoryNameLabel]|" options:NSLayoutFormatAlignAllTop metrics:nil views:NSDictionaryOfVariableBindings(categoryImageView, categoryNameLabel)];
} else {
    constraints = [NSLayoutConstraint constraintsWithVisualFormat:@"|[categoryNameLabel]|" options:NSLayoutFormatAlignAllTop metrics:nil views:NSDictionaryOfVariableBindings(categoryNameLabel)];
}
[titleView addConstraints:constraints];


// here I set the titleView to the navigationItem.titleView

I shouldn't have to hardcode the size of the titleView. It should be able to be determined via the size of its contents, but...

  1. The titleView is determining it's size is 0 unless I hardcode a frame.
  2. If I set translatesAutoresizingMaskIntoConstraints = NO the app crashes with this error: 'Auto Layout still required after executing -layoutSubviews. UINavigationBar's implementation of -layoutSubviews needs to call super.'

Update

I got it to work with this code, but I'm still having to set the frame on the titleView:

UILabel *categoryNameLabel = [[UILabel alloc] init];
categoryNameLabel.translatesAutoresizingMaskIntoConstraints = NO;
categoryNameLabel.text = categoryName;
categoryNameLabel.opaque = NO;
categoryNameLabel.backgroundColor = [UIColor clearColor];

UIView *titleView = [[UIView alloc] init];
[titleView addSubview:categoryNameLabel];
NSArray *constraints;
if (categoryImage) {
    UIImageView *categoryImageView = [[UIImageView alloc] initWithImage:categoryImage];
    [titleView addSubview:categoryImageView];
    categoryImageView.translatesAutoresizingMaskIntoConstraints = NO;
    constraints = [NSLayoutConstraint constraintsWithVisualFormat:@"|[categoryImageView]-7-[categoryNameLabel]|" options:NSLayoutFormatAlignAllCenterY metrics:nil views:NSDictionaryOfVariableBindings(categoryImageView, categoryNameLabel)];
    [titleView addConstraints:constraints];
    constraints = [NSLayoutConstraint constraintsWithVisualFormat:@"V:|[categoryImageView]|" options:0 metrics:nil views:NSDictionaryOfVariableBindings(categoryImageView)];
    [titleView addConstraints:constraints];
    
    titleView.frame = CGRectMake(0, 0, categoryImageView.frame.size.width + 7 + categoryNameLabel.intrinsicContentSize.width, categoryImageView.frame.size.height);
} else {
    constraints = [NSLayoutConstraint constraintsWithVisualFormat:@"|[categoryNameLabel]|" options:NSLayoutFormatAlignAllTop metrics:nil views:NSDictionaryOfVariableBindings(categoryNameLabel)];
    [titleView addConstraints:constraints];
    constraints = [NSLayoutConstraint constraintsWithVisualFormat:@"V:|[categoryNameLabel]|" options:0 metrics:nil views:NSDictionaryOfVariableBindings(categoryNameLabel)];
    [titleView addConstraints:constraints];
    titleView.frame = CGRectMake(0, 0, categoryNameLabel.intrinsicContentSize.width, categoryNameLabel.intrinsicContentSize.height);
}
return titleView;
Community
  • 1
  • 1
Bob Spryn
  • 17,742
  • 12
  • 68
  • 91
  • Your original code probably didn't work because you didn't have any constraints in the vertical axis; your second should be fine without having to set any frames. What happens if you create that view and add it somewhere else (rather than to a navigation bar, which may be introducing additional complexity). – jrturton Apr 21 '13 at 15:43
  • 1
    I'm trying to achieve this without setting the frame but I couldn't find who is the title view's parent in which we are supposed to add the constraints... – Raphael Oliveira Jul 03 '13 at 18:44
  • 1
    The view's parent is the `UINavigationBar` itself - but you are not allowed to add constraints to it for some reason - failing with `Cannot modify constraints for UINavigationBar managed by a controller` . – Petar Nov 25 '14 at 12:30

6 Answers6

71

I really needed constraints, so played around with it today. What I found that works is this:

    let v  = UIView()
    v.translatesAutoresizingMaskIntoConstraints = false
    // add your views and set up all the constraints

    // This is the magic sauce!
    v.layoutIfNeeded()
    v.sizeToFit()

    // Now the frame is set (you can print it out)
    v.translatesAutoresizingMaskIntoConstraints = true // make nav bar happy
    navigationItem.titleView = v

Works like a charm!

David H
  • 40,852
  • 12
  • 92
  • 138
  • 2
    I wish I found this answer earlier because I just got this working, although by using `sizeThatFits` for all of the subviews and then calculating the frame of the view after that. This answer is a lot more concise and reliable. Thanks!! – Alexander Aug 15 '16 at 18:57
  • 1
    Thanks it saves my day, struggling with my stacklayout :p – Ayrton Werck Jan 26 '17 at 14:46
  • This gives me conflicting constraints for the subviews (size): the ones I set (non-zero) and the autoresizing mask-generated ones (zero). – Nicolas Miari Mar 30 '17 at 07:56
  • 1
    @NicolasMiari you should have no "autoresizing mask-generated ones" constraints - all constraints should be ones you added. Try starting with a simple view - maybe nothing in it except background color, then slowly build it up to what you want. – David H Mar 31 '17 at 00:53
  • Works like a charm until you configure label's fonts in `traitCollectionDidChange:` – Cy-4AH Dec 06 '19 at 11:41
  • @Cy-4AH did you try using the above code again? That is, retrieve the view, do the steps, then put it back? – David H Dec 06 '19 at 12:28
  • Label is jumping on navigation bar during appearance. – Cy-4AH Dec 09 '19 at 09:43
  • @Cy-4AH create skeleton project with just this, put on Dropbox or similar, will look at it and attempt to fix it. Perhaps best if you create new topic, add link, then lots of people may try too. – David H Dec 09 '19 at 12:32
  • this is not working for me, I'm trying to use custom UIView – Ben Shabat Jan 22 '20 at 10:16
19

an0's answer is correct. However, it doesn't help you getting the desired effect.

Here's my recipe for building title views that automatically have the right size:

  • Create a UIView subclass, for instance CustomTitleView that will be later used as the navigationItem's titleView.
  • Use auto layout inside CustomTitleView. If you want to have your CustomTitleView being always centered, you'll need to add an explicit CenterX constraint (see code and link below).
  • Call updateCustomTitleView (see below) every time your titleView content updates. We need to set the titleView to nil and set it afterwards to our view again to prevent the title view being offset centered. This would happen when the title view changes from wide to narrow.
  • DON'T disable translatesAutoresizingMaskIntoConstraints

Gist: https://gist.github.com/bhr/78758bd0bd4549f1cd1c

Updating CustomTitleView from your ViewController:

- (void)updateCustomTitleView
{
    //we need to set the title view to nil and get always the right frame
    self.navigationItem.titleView = nil;

    //update properties of your custom title view, e.g. titleLabel
    self.navTitleView.titleLabel.text = <#my_property#>;

    CGSize size = [self.navTitleView systemLayoutSizeFittingSize:UILayoutFittingCompressedSize];
    self.navTitleView.frame = CGRectMake(0.f, 0.f, size.width, size.height);

    self.navigationItem.titleView = self.customTitleView;
}

Sample CustomTitleView.h with one label and two buttons

#import <UIKit/UIKit.h>

@interface BHRCustomTitleView : UIView

@property (nonatomic, strong, readonly) UILabel *titleLabel;
@property (nonatomic, strong, readonly) UIButton *previousButton;
@property (nonatomic, strong, readonly) UIButton *nextButton;

@end

Sample CustomTitleView.m:

#import "BHRCustomTitleView.h"

@interface BHRCustomTitleView ()

@property (nonatomic, strong) UILabel *titleLabel;
@property (nonatomic, strong) UIButton *previousButton;
@property (nonatomic, strong) UIButton *nextButton;

@property (nonatomic, copy) NSArray *constraints;

@end

@implementation BHRCustomTitleView

- (void)updateConstraints
{
    if (self.constraints) {
        [self removeConstraints:self.constraints];
    }

    NSDictionary *viewsDict = @{ @"title": self.titleLabel,
                                 @"previous": self.previousButton,
                                 @"next": self.nextButton };
    NSMutableArray *constraints = [NSMutableArray array];

    [constraints addObjectsFromArray:[NSLayoutConstraint constraintsWithVisualFormat:@"H:|-(>=0)-[previous]-2-[title]-2-[next]-(>=0)-|"
                                                                             options:NSLayoutFormatAlignAllBaseline
                                                                             metrics:nil
                                                                               views:viewsDict]];

    [constraints addObjectsFromArray:[NSLayoutConstraint constraintsWithVisualFormat:@"V:|[previous]|"
                                                                             options:0
                                                                             metrics:nil
                                                                               views:viewsDict]];

    [constraints addObject:[NSLayoutConstraint constraintWithItem:self
                                                        attribute:NSLayoutAttributeCenterX
                                                        relatedBy:NSLayoutRelationEqual
                                                           toItem:self.titleLabel
                                                        attribute:NSLayoutAttributeCenterX
                                                       multiplier:1.f
                                                         constant:0.f]];
    self.constraints = constraints;
    [self addConstraints:self.constraints];

    [super updateConstraints];
}

- (UILabel *)titleLabel
{
    if (!_titleLabel)
    {
        _titleLabel = [[UILabel alloc] initWithFrame:CGRectZero];
        _titleLabel.translatesAutoresizingMaskIntoConstraints = NO;
        _titleLabel.font = [UIFont boldSystemFontOfSize:_titleLabel.font.pointSize];

        [self addSubview:_titleLabel];
    }

    return _titleLabel;
}


- (UIButton *)previousButton
{
    if (!_previousButton)
    {
        _previousButton = [UIButton buttonWithType:UIButtonTypeSystem];
        _previousButton.translatesAutoresizingMaskIntoConstraints = NO;
        [self addSubview:_previousButton];

        _previousButton.titleLabel.font = [UIFont systemFontOfSize:23.f];
        [_previousButton setTitle:@"❮"
                         forState:UIControlStateNormal];
    }

    return _previousButton;
}

- (UIButton *)nextButton
{
    if (!_nextButton)
    {
        _nextButton = [UIButton buttonWithType:UIButtonTypeSystem];
        _nextButton.translatesAutoresizingMaskIntoConstraints = NO;
        [self addSubview:_nextButton];
        _nextButton.titleLabel.font = [UIFont systemFontOfSize:23.f];
        [_nextButton setTitle:@"❯"
                     forState:UIControlStateNormal];
    }

    return _nextButton;
}

+ (BOOL)requiresConstraintBasedLayout
{
    return YES;
}

@end
bhr
  • 2,279
  • 1
  • 23
  • 31
  • Thanks for mentioning that you shouldn't disable `translatesAutoresizingMaskIntoConstraints`. I had set an element that was previously displayed elsewhere as the `titleView`, but it would always be positioned incorrectly after navigating back. Removing that line fixed it. – Livven Aug 11 '16 at 13:54
  • My title view is displayed at the center, bit its subviews appear at (0,0) in window coordinates (top-most, left-most). I'm doing basically the same as you, save for using the anchors API for constraints. – Nicolas Miari Mar 30 '17 at 08:38
10

Thanks @Valentin Shergin and @tubtub! According to their answers I made an implementation of navigation bar title with dropdown arrow image in Swift 1.2:

  1. Create a UIView subclass for custom titleView
  2. In your subclass: a) Use auto layout for subviews but not for itself. Set translatesAutoresizingMaskIntoConstraints to false for subviews and true for titleView itself. b) Implement sizeThatFits(size: CGSize)
  3. If your title can change call titleLabel.sizeToFit() and self.setNeedsUpdateConstraints() inside titleView's subclass after text changes
  4. In your ViewController call custom updateTitleView() and make sure to call titleView.sizeToFit() and navigationBar.setNeedsLayout() in there

Here's minimal implementation of DropdownTitleView:

import UIKit

class DropdownTitleView: UIView {

    private var titleLabel: UILabel
    private var arrowImageView: UIImageView

    // MARK: - Life cycle

    override init (frame: CGRect) {

        self.titleLabel = UILabel(frame: CGRectZero)
        self.titleLabel.setTranslatesAutoresizingMaskIntoConstraints(false)

        self.arrowImageView = UIImageView(image: UIImage(named: "dropdown-arrow")!)
        self.arrowImageView.setTranslatesAutoresizingMaskIntoConstraints(false)

        super.init(frame: frame)

        self.setTranslatesAutoresizingMaskIntoConstraints(true)
        self.addSubviews()
    }

    convenience init () {
        self.init(frame: CGRectZero)
    }

    required init(coder aDecoder: NSCoder) {
        fatalError("DropdownTitleView does not support NSCoding")
    }

    private func addSubviews() {
        addSubview(titleLabel)
        addSubview(arrowImageView)
    }

    // MARK: - Methods

    func setTitle(title: String) {
        titleLabel.text = title
        titleLabel.sizeToFit()
        setNeedsUpdateConstraints()
    }

    // MARK: - Layout

    override func updateConstraints() {
        removeConstraints(self.constraints())

        let viewsDictionary = ["titleLabel": titleLabel, "arrowImageView": arrowImageView]
        var constraints: [AnyObject] = []

        constraints.extend(NSLayoutConstraint.constraintsWithVisualFormat("H:|[titleLabel]-8-[arrowImageView]|", options: .AlignAllBaseline, metrics: nil, views: viewsDictionary))
        constraints.extend(NSLayoutConstraint.constraintsWithVisualFormat("V:|[titleLabel]|", options: NSLayoutFormatOptions(0), metrics: nil, views: viewsDictionary))

        self.addConstraints(constraints)

        super.updateConstraints()
    }

    override func sizeThatFits(size: CGSize) -> CGSize {
        // +8.0 - distance between image and text
        let width = CGRectGetWidth(arrowImageView.bounds) + CGRectGetWidth(titleLabel.bounds) + 8.0
        let height = max(CGRectGetHeight(arrowImageView.bounds), CGRectGetHeight(titleLabel.bounds))
        return CGSizeMake(width, height)
    }
}

and ViewController:

override func viewDidLoad() {
    super.viewDidLoad()

    // Set custom title view to show arrow image along with title
    self.navigationItem.titleView = dropdownTitleView

    // your code ...
}

private func updateTitleView(title: String) {
    // update text
    dropdownTitleView.setTitle(title)

    // layout title view
    dropdownTitleView.sizeToFit()
    self.navigationController?.navigationBar.setNeedsLayout()
}
Lion
  • 1,264
  • 1
  • 17
  • 23
8

You have to set the frame of titleView because you don't specify any constraints for its position in its superview. The Auto Layout system can only figure out the size of titleView for you from the constraints you specified and the intrinsic content size of its subviews.

an0
  • 17,191
  • 12
  • 86
  • 136
  • I don't think that's the case though, because I didn't change `translatesAutoresizingMaskIntoConstraints`, so it should have been using default autoresizing masks. If I did set that to `NO` then it should have been able to determine the size via the textlabel and image inside. – Bob Spryn Apr 20 '13 at 23:50
  • Auto Resizing needs you to define the initial size; Auto Layout needs you to specify the constraint. If you do neither, neither of them can help you. Think about it, or just try it — set the size or metric constraint for `titleView` and you'll see. – an0 Apr 21 '13 at 00:35
  • True on auto resizing, but even when I disabled `translatesAutoresizingMaskIntoConstraints`, my titleView should have been able to determine it's size from it's children (label or label and image) with the constraints I defined, and then should have had a size. I'm guessing it did, but then since there were no constraints positioning it in the parent, it would crash having no way to determine it's location. – Bob Spryn Apr 21 '13 at 01:35
  • Yes, as you noticed yourself, you didn't specify any constraints for its `position` in its superview. I updated my answer to be more specific. – an0 Apr 21 '13 at 15:25
  • 4
    So how to specify the custom `titleView`'s position using autolayout? – Petar Nov 25 '14 at 12:45
  • @Petar see my answer below for how you can use auto layout. – David H Aug 16 '16 at 11:04
6

For combining auto-layout constraints inside titleView and hardcoded layout logic inside UINavigationBar you have to implement method sizeThatFits: inside your own custom titleView's class (subclass of UIView) like this:

- (CGSize)sizeThatFits:(CGSize)size
{
    return CGSizeMake(
        CGRectGetWidth(self.imageView.bounds) + CGRectGetWidth(self.labelView.bounds) + 5.f /* space between icon and text */,
        MAX(CGRectGetHeight(self.imageView.bounds), CGRectGetHeight(self.labelView.bounds))
    );
}
Valentin Shergin
  • 7,166
  • 2
  • 50
  • 53
0

Here is my implementation of ImageAndTextView

@interface ImageAndTextView()
@property (nonatomic, strong) UIImageView *imageView;
@property (nonatomic, strong) UITextField *textField;
@end

@implementation ImageAndTextView

- (instancetype)init
{
    self = [super init];
    if (self)
    {
        [self initializeView];
    }

    return self;
}

- (void)initializeView
{
    self.translatesAutoresizingMaskIntoConstraints = YES;
    self.autoresizingMask = (UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight);

    self.imageView = [[UIImageView alloc] init];
    self.imageView.contentMode = UIViewContentModeScaleAspectFit;
    self.textField = [[UITextField alloc] init];
    [self addSubview:self.imageView];
    [self addSubview:self.textField];

    self.imageView.translatesAutoresizingMaskIntoConstraints = NO;
    self.textField.translatesAutoresizingMaskIntoConstraints = NO;
    //Center the text field
    [NSLayoutConstraint activateConstraints:@[
        [self.textField.centerXAnchor constraintEqualToAnchor:self.centerXAnchor],
        [self.textField.centerYAnchor constraintEqualToAnchor:self.centerYAnchor]
    ]];

    //Put image view on left of text field
    [NSLayoutConstraint activateConstraints:@[
        [self.imageView.rightAnchor constraintEqualToAnchor:self.textField.leftAnchor],
        [self.imageView.lastBaselineAnchor constraintEqualToAnchor:self.textField.lastBaselineAnchor],
        [self.imageView.heightAnchor constraintEqualToConstant:16]
    ]];
}

- (CGSize)intrinsicContentSize
{
    return CGSizeMake(CGFLOAT_MAX, CGFLOAT_MAX);
}
@end
Omkar
  • 1,108
  • 10
  • 19