Using autolayout in a tableHeaderView

19,974

Solution 1

My own best answer so far involves setting the tableHeaderView once and forcing a layout pass. This allows a required size to be measured, which I then use to set the frame of the header. And, as is common with tableHeaderViews, I have to again set it a second time to apply the change.

- (void)viewDidLoad
{
    [super viewDidLoad];

    self.header = [[SCAMessageView alloc] init];
    self.header.titleLabel.text = @"Warning";
    self.header.subtitleLabel.text = @"This is a message with enough text to span multiple lines. This text is set at runtime and might be short or long.";

    //set the tableHeaderView so that the required height can be determined
    self.tableView.tableHeaderView = self.header;
    [self.header setNeedsLayout];
    [self.header layoutIfNeeded];
    CGFloat height = [self.header systemLayoutSizeFittingSize:UILayoutFittingCompressedSize].height;

    //update the header's frame and set it again
    CGRect headerFrame = self.header.frame;
    headerFrame.size.height = height;
    self.header.frame = headerFrame;
    self.tableView.tableHeaderView = self.header;
}

For multiline labels, this also relies on the custom view (the message view in this case) setting the preferredMaxLayoutWidth of each:

- (void)layoutSubviews
{
    [super layoutSubviews];

    self.titleLabel.preferredMaxLayoutWidth = CGRectGetWidth(self.titleLabel.frame);
    self.subtitleLabel.preferredMaxLayoutWidth = CGRectGetWidth(self.subtitleLabel.frame);
}

Update January 2015

Unfortunately this still seems necessary. Here is a swift version of the layout process:

tableView.tableHeaderView = header
header.setNeedsLayout()
header.layoutIfNeeded()
let height = header.systemLayoutSizeFittingSize(UILayoutFittingCompressedSize).height
var frame = header.frame
frame.size.height = height
header.frame = frame
tableView.tableHeaderView = header

I've found it useful to move this into an extension on UITableView:

extension UITableView {
    //set the tableHeaderView so that the required height can be determined, update the header's frame and set it again
    func setAndLayoutTableHeaderView(header: UIView) {
        self.tableHeaderView = header
        header.setNeedsLayout()
        header.layoutIfNeeded()
        let height = header.systemLayoutSizeFittingSize(UILayoutFittingCompressedSize).height
        var frame = header.frame
        frame.size.height = height
        header.frame = frame
        self.tableHeaderView = header
    }
}

Usage:

let header = SCAMessageView()
header.titleLabel.text = "Warning"
header.subtitleLabel.text = "Warning message here."
tableView.setAndLayoutTableHeaderView(header)

Solution 2

For anyone still looking for a solution, this is for Swift 3 & iOS 9+. Here is one using only AutoLayout. It also updates correctly on device rotation.

extension UITableView {
    // 1.
    func setTableHeaderView(headerView: UIView) {
        headerView.translatesAutoresizingMaskIntoConstraints = false

        self.tableHeaderView = headerView

        // ** Must setup AutoLayout after set tableHeaderView.
        headerView.widthAnchor.constraint(equalTo: self.widthAnchor).isActive = true
        headerView.centerXAnchor.constraint(equalTo: self.centerXAnchor).isActive = true
        headerView.topAnchor.constraint(equalTo: self.topAnchor).isActive = true
    }

    // 2.
    func shouldUpdateHeaderViewFrame() -> Bool {
        guard let headerView = self.tableHeaderView else { return false }
        let oldSize = headerView.bounds.size        
        // Update the size
        headerView.layoutIfNeeded()
        let newSize = headerView.bounds.size
        return oldSize != newSize
    }
}

To use:

override func viewDidLoad() {
    ...

    // 1.
    self.tableView.setTableHeaderView(headerView: customView)
}

override func viewDidLayoutSubviews() {
    super.viewDidLayoutSubviews()

    // 2. Reflect the latest size in tableHeaderView
    if self.tableView.shouldUpdateHeaderViewFrame() {

        // **This is where table view's content (tableHeaderView, section headers, cells) 
        // frames are updated to account for the new table header size.
        self.tableView.beginUpdates()
        self.tableView.endUpdates()
    }
}

The gist is that you should let tableView manage the frame of tableHeaderView the same way as table view cells. This is done through tableView's beginUpdates/endUpdates.

The thing is that tableView doesn't care about AutoLayout when it updates the children frames. It uses the current tableHeaderView's size to determine where the first cell/section header should be.

1) Add a width constraint so that the tableHeaderView uses this width whenever we call layoutIfNeeded(). Also add centerX and top constraints to position it correctly relative to the tableView.

2) To let the tableView knows about the latest size of tableHeaderView, e.g., when the device is rotated, in viewDidLayoutSubviews we can call layoutIfNeeded() on tableHeaderView. Then, if the size is changed, call beginUpdates/endUpdates.

Note that I don't include beginUpdates/endUpdates in one function, as we might want to defer the call to later.

Check out a sample project

Solution 3

The following UITableView extension solves all common problems of autolayouting and positioning of the tableHeaderView without frame-use legacy:

@implementation UITableView (AMHeaderView)

- (void)am_insertHeaderView:(UIView *)headerView
{
    self.tableHeaderView = headerView;

    NSLayoutConstraint *constraint = 
    [NSLayoutConstraint constraintWithItem: headerView
                                 attribute: NSLayoutAttributeWidth
                                 relatedBy: NSLayoutRelationEqual
                                    toItem: headerView.superview
                                 attribute: NSLayoutAttributeWidth
                                multiplier: 1.0
                                  constant: 0.0];
    [headerView.superview addConstraint:constraint];    
    [headerView layoutIfNeeded];

    NSArray *constraints = headerView.constraints;
    [headerView removeConstraints:constraints];

    UIView *layoutView = [UIView new];
    layoutView.translatesAutoresizingMaskIntoConstraints = NO;
    [headerView insertSubview:layoutView atIndex:0];

    [headerView addConstraints: [NSLayoutConstraint constraintsWithVisualFormat:@"|[view]|" options:0 metrics:nil views:@{@"view": layoutView}]];
    [headerView addConstraints: [NSLayoutConstraint constraintsWithVisualFormat:@"V:|[view]|" options:0 metrics:nil views:@{@"view": layoutView}]];

    [headerView addConstraints:constraints];

    self.tableHeaderView = headerView;
    [headerView layoutIfNeeded];
}

@end

Explanation of the "strange" steps:

  1. At first we tie the headerView width to the tableView width: it helps as under rotations and prevent from deep left shift of X-centered subviews of the headerView.

  2. (the Magic!) We insert fake layoutView in the headerView: At this moment we STRONGLY need to remove all headerView constraints, expand the layoutView to the headerView and then restore initial headerView constraints. It happens that order of constraints has some sense! In the way we get correct headerView height auto calculation and also correct
    X-centralization for all headerView subviews.

  3. Then we only need to re-layout headerView again to obtain correct tableView
    height calculation and headerView positioning above sections without intersecting.

P.S. It works for iOS8 also. It is impossible to comment out any code string here in common case.

Solution 4

Some of the answers here helped me get very close to what I needed. But I encountered conflicts with the constraint "UIView-Encapsulated-Layout-Width" which is set by the system, when rotating the device back-and-forth between portrait and landscape. My solution below is largely based on this gist by marcoarment (credit to him): https://gist.github.com/marcoarment/1105553afba6b4900c10. The solution does not rely on the header view containing a UILabel. There are 3 parts:

  1. A function defined in an extension to UITableView.
  2. Call the function from the view controller's viewWillAppear().
  3. Call the function from the view controller's viewWillTransition() in order to handle device rotation.

UITableView extension

func rr_layoutTableHeaderView(width:CGFloat) {
    // remove headerView from tableHeaderView:
    guard let headerView = self.tableHeaderView else { return }
    headerView.removeFromSuperview()
    self.tableHeaderView = nil

    // create new superview for headerView (so that autolayout can work):
    let temporaryContainer = UIView(frame: CGRect(x: 0, y: 0, width: width, height: CGFloat.greatestFiniteMagnitude))
    temporaryContainer.translatesAutoresizingMaskIntoConstraints = false
    self.addSubview(temporaryContainer)
    temporaryContainer.addSubview(headerView)

    // set width constraint on the headerView and calculate the right size (in particular the height):
    headerView.translatesAutoresizingMaskIntoConstraints = false
    let temporaryWidthConstraint = NSLayoutConstraint(item: headerView, attribute: .width, relatedBy: .equal, toItem: nil, attribute: .notAnAttribute, multiplier: 0, constant: width)
    temporaryWidthConstraint.priority = 999     // necessary to avoid conflict with "UIView-Encapsulated-Layout-Width"
    headerView.addConstraint(temporaryWidthConstraint)
    headerView.frame.size = headerView.systemLayoutSizeFitting(UILayoutFittingCompressedSize)

    // remove the temporary constraint:
    headerView.removeConstraint(temporaryWidthConstraint)
    headerView.translatesAutoresizingMaskIntoConstraints = true

    // put the headerView back into the tableHeaderView:
    headerView.removeFromSuperview()
    temporaryContainer.removeFromSuperview()
    self.tableHeaderView = headerView
}

Use in UITableViewController

override func viewDidLoad() {
    super.viewDidLoad()

    // build the header view using autolayout:
    let button = UIButton()
    let label = UILabel()
    button.setTitle("Tap here", for: .normal)
    label.text = "The text in this header will span multiple lines if necessary"
    label.numberOfLines = 0
    let headerView = UIStackView(arrangedSubviews: [button, label])
    headerView.axis = .horizontal
    // assign the header view:
    self.tableView.tableHeaderView = headerView

    // continue with other things...
}

override func viewWillAppear(_ animated: Bool) {
    super.viewWillAppear(animated)
    self.tableView.rr_layoutTableHeaderView(width: view.frame.width)
}

override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
    super.viewWillTransition(to: size, with: coordinator)
    self.tableView.rr_layoutTableHeaderView(width: size.width)
}

Solution 5

This should do the trick for a headerView or a footerView for the UITableView using AutoLayout.

extension UITableView {

  var tableHeaderViewWithAutolayout: UIView? {
    set (view) {
      tableHeaderView = view
      if let view = view {
        lowerPriorities(view)
        view.frameSize = view.systemLayoutSizeFitting(UILayoutFittingCompressedSize)
        tableHeaderView = view
      }
    }
    get {
      return tableHeaderView
    }
  }

  var tableFooterViewWithAutolayout: UIView? {
    set (view) {
      tableFooterView = view
      if let view = view {
        lowerPriorities(view)
        view.frameSize = view.systemLayoutSizeFitting(UILayoutFittingCompressedSize)
        tableFooterView = view
      }
    }
    get {
      return tableFooterView
    }
  }

  fileprivate func lowerPriorities(_ view: UIView) {
    for cons in view.constraints {
      if cons.priority.rawValue == 1000 {
        cons.priority = UILayoutPriority(rawValue: 999)
      }
      for v in view.subviews {
        lowerPriorities(v)
      }
    }
  }
}
Share:
19,974

Related videos on Youtube

Ben Packard
Author by

Ben Packard

iOS developer and program manager located in Washington, DC.

Updated on June 15, 2022

Comments

  • Ben Packard
    Ben Packard almost 2 years

    I have a UIView subclass that contains a multi-line UILabel. This view uses autolayout.

    enter image description here

    I would like to set this view as the tableHeaderView of a UITableView (not a section header). The height of this header will depend on the text of the label, which in turn depends on the width of the device. The sort of scenario autolayout should be great at.

    I have found and attempted many many solutions to get this working, but to no avail. Some of the things I've tried:

    • setting a preferredMaxLayoutWidth on each label during layoutSubviews
    • defining an intrinsicContentSize
    • attempting to figure out the required size for the view and setting the tableHeaderView's frame manually.
    • adding a width constraint to the view when the header is set
    • a bunch of other things

    Some of the various failures I've encountered:

    • label extends beyond the width of the view, doesn't wrap
    • frame's height is 0
    • app crashes with exception Auto Layout still required after executing -layoutSubviews

    The solution (or solutions, if necessary) should work for both iOS 7 and iOS 8. Note that all of this is being done programmatically. I've set up a small sample project in case you want to hack on it to see the issue. I've reset my efforts to the following start point:

    SCAMessageView *header = [[SCAMessageView alloc] init];
    header.titleLabel.text = @"Warning";
    header.subtitleLabel.text = @"This is a message with enough text to span multiple lines. This text is set at runtime and might be short or long.";
    self.tableView.tableHeaderView = header;
    

    What am I missing?

    • Peter Foti
      Peter Foti over 9 years
      Im confused as to what is wrong with what you have now? The screenshot looks good...
    • Ben Packard
      Ben Packard over 9 years
      For the screenshot I manually set a frame on the header.
    • Anthony Mattox
      Anthony Mattox over 9 years
      @BenPackard I struggled this for a while as well and as best I can tell, it's not possible to make the tableHeaderView respect auto layout. Ultimately, I added an extra view inside the header view wrapping all the content. In layoutSubviews of the table view's superview I grabbed the size of the wrapper and manually set the frame of the tableHeaderView.
    • Ben Packard
      Ben Packard over 9 years
      @AnthonyMattox This seems less worse than my own solutions. I can't get it to work though. Could you provide more info please? Specifically, what are you doing in layoutSubviews to calculate the necessary size?
    • Anthony Mattox
      Anthony Mattox over 9 years
      @BenPackard I might be a little off. In the situation I had faced there was more custom layout going on within the table header view so it might have worked incidentally. I struggled a little further with coming up with a clean working example, but the tableHeaderView is extremely finicky.
    • ItsASecret
      ItsASecret about 7 years
  • Ben Packard
    Ben Packard over 9 years
    Not sure if you misunderstood my question but I am not asking for help laying out the message view. Also, the reason you needed a filler view is because the message view itself has a height of 0. The labels are only visible because they are spilling outside the bounds. Set clipsToBounds = YES and you will see what I mean. If you were to install this view as a tableHeaderView, the table's cells would hide the labels since the header has a height of 0.
  • Fogmeister
    Fogmeister about 8 years
    This has got to be a bug in iOS?! Anyway, thanks for this, I'll have a go at implementing it. I've been tearing my hair out with many of the "solutions" you link to in your question.
  • race_carr
    race_carr about 8 years
    SO ANNOYING! I had to manually set the frame to twice the desired height to get a tableFooterView to not be zero height
  • Greg Ferreri
    Greg Ferreri about 8 years
    I can only get this to work if I add the code to viewDidAppear, but there is a quick flicker before it transitions to the new size. If I add the code to viewDidLoad it has no effect. Strangely, if I add it to viewWillLayoutSubviews it has no effect unless I rotate the display and then rotate it back.
  • malex
    malex almost 8 years
    Try to make the following headerView: x-centered square UIView at 10 pt from the top, x-centered UILabel under UIView separated by 10 pt at 10 pt from bottom. (for example, the header of FB Messenger settings screen). This frame solution will not work for the case described.
  • iwasrobbed
    iwasrobbed almost 8 years
    This only solution that actually worked..... that was maddening. Thanks!
  • Daumantas Versockas
    Daumantas Versockas over 7 years
    @Ben Packard, it seems, that this work around doesn't work on iOS 10 anymore. The final line ` self.tableHeaderView = header` sets the header with the correct frame. Despite this, inside viewDidLayoutSubviews frame is incorrect (frame = (0 0; 375 0);)
  • Jay Peyer
    Jay Peyer about 7 years
    I needed to remove the first line (setting the header before sizing it), otherwise it would use the incorrect size.
  • Sukhpreet
    Sukhpreet over 6 years
    It's working fine. Although I need to add height constraint headerView.heightAnchor.constraint(equalToConstant: 412).isActive = true
  • Dylan
    Dylan about 6 years
    This is a well written explanation of how to implement section headers but the question is about tableHeaderView.
  • Leon
    Leon about 4 years
    Your widthAnchor is probably redundant, from the docs: "The table view respects only the height of your view's frame rectangle; it adjusts the width of your footer view automatically to match the table view's width."