45

My application creates a UITableViewController that contains a custom tableHeaderView which may have an arbitrary height. I've been struggling with a way to set this header dynamically, as it seems the suggested ways have been cutting this header short. My UITableViewController's relevant code:

import UIKit
import SafariServices

class RedditPostViewController: UITableViewController, NetworkCommunication, SubViewLaunchLinkManager {

    //MARK: UITableViewDataSource
    var post: PostData?
    var tree: CommentTree?
    weak var session: Session! = Session.sharedInstance

    override func viewDidLoad() {
        super.viewDidLoad()

        // Get post info from api
        guard let postData = post else { return }

        //Configure comment table
        self.tableView.registerClass(RedditPostCommentTableViewCell.self, forCellReuseIdentifier: "CommentCell")

       let tableHeader = PostView(withPost: postData, inViewController: self)
       let size = tableHeader.systemLayoutSizeFittingSize(UILayoutFittingExpandedSize)
       let height = size.height
       let width = size.width
       tableHeader.frame = CGRectMake(0, 0, width, height)
       self.tableView.tableHeaderView = tableHeader


       session.getRedditPost(postData) { (post) in
           self.post = post?.post
           self.tree = post?.comments
           self.tableView.reloadData()
       }
    }
}

This results in the following incorrect layout:

If I change the line: tableHeader.frame = CGRectMake(0, 0, width, height) to tableHeader.frame = CGRectMake(0, 0, width, 1000) the tableHeaderView will lay itself out correctly:

I'm not sure what I'm doing incorrectly here. Also, custom UIView class, if this helps:

import UIKit
import Foundation

protocol SubViewLaunchLinkManager: class {
    func launchLink(sender: UIButton)
}

class PostView: UIView {

    var body: UILabel?
    var post: PostData?
    var domain: UILabel?
    var author: UILabel?
    var selfText: UILabel?
    var numComments: UILabel?

    required init?(coder aDecoder: NSCoder) {
        fatalError("Not implemented yet")
    }

    init(withPost post: PostData, inViewController viewController: SubViewLaunchLinkManager) {
        super.init(frame: CGRectZero)

        self.post = post
        self.backgroundColor = UIColor.lightGrayColor()

        let launchLink = UIButton()
        launchLink.setImage(UIImage(named: "circle-user-7"), forState: .Normal)
        launchLink.addTarget(viewController, action: "launchLink:", forControlEvents: .TouchUpInside)
        self.addSubview(launchLink)

        selfText = UILabel()
        selfText?.backgroundColor = UIColor.whiteColor()
        selfText?.numberOfLines = 0
        selfText?.lineBreakMode = .ByWordWrapping
        selfText!.text = post.selfText
        self.addSubview(selfText!)
        selfText?.sizeToFit()

        //let attributedString = NSAttributedString(string: "Test"/*post.selfTextHtml*/, attributes: [NSDocumentTypeDocumentAttribute: NSHTMLTextDocumentType])
        //selfText.attributedText = attributedString

        body = UILabel()
        body!.text = post.title
        body!.numberOfLines = 0
        body!.lineBreakMode = .ByWordWrapping
        body!.textAlignment = .Justified
        self.addSubview(body!)

        domain = UILabel()
        domain!.text = post.domain
        self.addSubview(domain!)

        author = UILabel()
        author!.text = post.author
        self.addSubview(author!)

        numComments = UILabel()
        numComments!.text = "\(post.numComments)"
        self.addSubview(numComments!)

        body!.translatesAutoresizingMaskIntoConstraints = false
        domain!.translatesAutoresizingMaskIntoConstraints = false
        author!.translatesAutoresizingMaskIntoConstraints = false
        selfText!.translatesAutoresizingMaskIntoConstraints = false
        launchLink.translatesAutoresizingMaskIntoConstraints = false
        numComments!.translatesAutoresizingMaskIntoConstraints = false

        let views: [String: UIView] = ["body": body!, "domain": domain!, "author": author!, "numComments": numComments!, "launchLink": launchLink, "selfText": selfText!]
        //let selfTextSize = selfText?.sizeThatFits((selfText?.frame.size)!)
        //print(selfTextSize)
        //let metrics = ["selfTextHeight": selfTextSize!.height]

                   self.addConstraints(NSLayoutConstraint.constraintsWithVisualFormat("V:|-[body]-[selfText]-[domain]-|", options: [], metrics: nil, views: views))
       self.addConstraints(NSLayoutConstraint.constraintsWithVisualFormat("V:|-[body]-[selfText]-[author]-|", options: [], metrics: nil, views: views))
    self.addConstraints(NSLayoutConstraint.constraintsWithVisualFormat("V:|-[body]-[selfText]-[numComments]-|", options: [], metrics: nil, views: views))
    self.addConstraints(NSLayoutConstraint.constraintsWithVisualFormat("V:|-[launchLink]-[numComments]-|", options: [], metrics: nil, views: views))
    self.addConstraints(NSLayoutConstraint.constraintsWithVisualFormat("H:|[body][launchLink]|", options: [], metrics: nil, views: views))
    self.addConstraints(NSLayoutConstraint.constraintsWithVisualFormat("H:|[selfText][launchLink]|", options: [], metrics: nil, views: views))
    self.addConstraints(NSLayoutConstraint.constraintsWithVisualFormat("H:|[domain][author][numComments][launchLink]|", options: [], metrics: nil, views: views))
}

override func layoutSubviews() {
    super.layoutSubviews()
    body?.preferredMaxLayoutWidth = body!.bounds.width
}
}
EsatGozcu
  • 185
  • 8
TravMatth
  • 1,908
  • 2
  • 13
  • 17

12 Answers12

125

Copied from this post. (Make sure you see it if you're looking for more details)

override func viewDidLayoutSubviews() {
    super.viewDidLayoutSubviews()

    if let headerView = tableView.tableHeaderView {

        let height = headerView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize).height
        var headerFrame = headerView.frame

        //Comparison necessary to avoid infinite loop
        if height != headerFrame.size.height {
            headerFrame.size.height = height
            headerView.frame = headerFrame
            tableView.tableHeaderView = headerView
        }
    }
}
Sunil Targe
  • 7,251
  • 5
  • 49
  • 80
TravMatth
  • 1,908
  • 2
  • 13
  • 17
  • Without the last line tableView.tableHeaderView = headerView doesn't work. @TravMatth , could you explain me why do we have to reassign tableView.tableHeaderView? – Giorgio Sep 18 '17 at 11:58
  • If you've tried all the code samples in the many posts on this subject and none seem to work, check your constraints!! In my case the trailing space to the UIView which was my header was missing. Ensuring the controls contained in your header view have a continuous chain of constraints from the top to the bottom of the superview is essential no matter which code sample you choose. – Robert Apr 28 '19 at 20:13
  • 2
    `let height` is always `0` for me! – IvanPavliuk Sep 11 '19 at 07:50
  • I tried to use your solution, it create a blank area with multiple lines label. https://stackoverflow.com/questions/63174149/add-stackview-with-multiple-lines-label-in-tableview-header – frank61003 Aug 04 '20 at 07:12
  • If anyone run into the same issue @frank61003 had, can try my workaround, I left an answer under this post: https://stackoverflow.com/a/69591753/9182804 – Daniel Hu Oct 16 '21 at 00:18
  • If you use UILabel in headerView, sometimes the adaptive height may not work if you just set the Label constraint top, bottom, left, and right. The solution is to set the width constraint of the label so that the height is calculated automatically – wlixcc Apr 06 '22 at 02:24
39

Determining the header's frame size using

header.systemLayoutSizeFitting(UILayoutFittingCompressedSize)

as suggested in the answers above didn't work for me when my header view consisted of a single multiline label. With the label's line break mode set to wrap, the text just gets cut off:

enter image description here

Instead, what did work for me was using the width of the table view and a height of 0 as the target size:

header.systemLayoutSizeFitting(CGSize(width: tableView.bounds.width, height: 0))

enter image description here

Putting it all together (I prefer to use an extension):

extension UITableView {
    func updateHeaderViewHeight() {
        if let header = self.tableHeaderView {
            let newSize = header.systemLayoutSizeFitting(CGSize(width: self.bounds.width, height: 0))
            header.frame.size.height = newSize.height
        }
    }
}

And call it like so:

override func viewWillLayoutSubviews() {
    super.viewWillLayoutSubviews()
    tableView.updateHeaderViewHeight()
}
NSExceptional
  • 1,368
  • 15
  • 12
  • 1
    I tried to use your solution, it create a blank area with multiple lines label. https://stackoverflow.com/questions/63174149/add-stackview-with-multiple-lines-label-in-tableview-header – frank61003 Aug 04 '20 at 07:14
23

More condensed version of OP's answer, with the benefit of allowing layout to happen naturally (note this solution uses viewWillLayoutSubviews):

override func viewWillLayoutSubviews() {
    super.viewWillLayoutSubviews()

    if let header = tableView.tableHeaderView {
        let newSize = header.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
        header.frame.size.height = newSize.height
    }
}

Thanks to TravMatth for the original answer.

Nathan Hosselton
  • 1,089
  • 1
  • 12
  • 16
  • Its because `tableView.tableHeaderView.frame.size.height` is read-only. You have to set the frame `tableView.tableHeaderView.frame = ...`. – Marcel Dec 15 '17 at 14:34
  • I am also unsure why this did not work for you @Harris. CGSize height/width properties are no longer read-only, so that should not be the reason. If anyone offers insight into their issue with this solution I'd be happy to help diagnose as I still stand by it. – Nathan Hosselton Feb 07 '18 at 20:32
  • 3
    Works great and much cleaner than the accepted answer – NSExceptional Jun 17 '18 at 22:47
  • I tried to use your solution, it create a blank area with multiple lines label. https://stackoverflow.com/questions/63174149/add-stackview-with-multiple-lines-label-in-tableview-header – frank61003 Aug 04 '20 at 07:14
  • @frank61003 your layout appears to be exceptionally complex for a table header view, especially if you are using any constraints within your stack views. i would recommend first pulling your root-most stack view (the table header view) out of the table view and seeing if autolayout can resolve the constraints how you expect. if it doesn't, your issue is your constraints. if it does, then my solution is too general for your layout and you'll likely need to do some programmatic layout manipulation. – Nathan Hosselton Aug 06 '20 at 22:06
  • @NathanHosselton My stackView works fine when I put outside the tableView Header. It's auto layout also can work well when I put in TableViewCell ,I think this is IDE's problem(when tableView's header is too complex). I decide to let my stackView second and third part become table view's first and second section for a workaround. Thanks for your comment. – frank61003 Aug 07 '20 at 08:10
5

If you're still having problems with layout with the above code sample, there's a slight chance you disabled translatesAutoresizingMaskIntoConstraints on the custom header view. In that case, you need to set translatesAutoresizingMaskIntoConstraints back to true after you set the header's frame.

Here's the code sample I'm using, and working correctly on iOS 11.

public override func viewDidLayoutSubviews() {
    super.viewDidLayoutSubviews()

    guard let headerView = tableView.tableHeaderView else { return }

    let height = headerView.systemLayoutSizeFitting(UILayoutFittingCompressedSize).height
    var headerFrame = headerView.frame

    if height != headerFrame.size.height {
        headerFrame.size.height = height
        headerView.frame = headerFrame
        tableView.tableHeaderView = headerView

        if #available(iOS 9.0, *) {
            tableView.layoutIfNeeded()
        }
    }

    headerView.translatesAutoresizingMaskIntoConstraints = true
}
emrekyv
  • 1,256
  • 1
  • 18
  • 21
  • This is a really valuable note, especially if you build ui in code and reset that flag almost all the time. The answer would be truly invaluable if it explained WHY a UITableView is unable to build the needed constraints for its header/footer views even if `translatesAutoresizingMaskIntoConstraints=false` – Paul E. Nov 18 '20 at 21:14
4

Based on @TravMatth and @NSExceptional's answer:

For Dynamic TableView Header, with multiple line of text(No matter have or not)

My solution is:

override func viewDidLayoutSubviews() {
    super.viewDidLayoutSubviews()

    if let footView = tableView.tableFooterView {
        let newSize = footView.systemLayoutSizeFitting(CGSize(width: self.view.bounds.width, height: 0))
        if newSize.height != footView.frame.size.height {
            footView.frame.size.height = newSize.height
            tableView.tableFooterView = footView
        }
    }
}

tableView.tableFooterView = footView to make sure that your tableview Header or Footer updated. And if newSize.height != footView.frame.size.height helps you not to be called this method many times

HamasN
  • 524
  • 1
  • 5
  • 20
  • 1
    Don't know why this works for me but `UIView.layoutFittingCompressedSize` doesn't. Thanks. – Rishab Mar 30 '21 at 15:01
3

I use the accepted answer for a long time and it always worked for me, until today, when I used a multiple lines label in a complex table header view, I ran into the same issue @frank61003 had:

it create a blank area with multiple lines label.

So in my case, there were big vertical margins around my label. If label text is just 1 line, then everything is fine. This issue only happens when the label has multiple lines of text.

I don't know the exact reason causing this, but I dug for a while and found a workaround to solve the issue, so I want to leave a reference here in case anyone runs into the same problem.

Optional first step, make sure your multiple lines label has the lowest Content Hugging Priority in your table header view, so it can auto increase to fit its text.

Then, add this calculate label height method to your view controller

private func calculateHeightForString(_ string: String) -> CGFloat {
        let yourLabelWidth = UIScreen.main.bounds.width - 20
        let constraintRect = CGSize(width: yourLabelWidth, height: CGFloat.greatestFiniteMagnitude)
        let rect = string.boundingRect(with: constraintRect,
                                       options: .usesLineFragmentOrigin,
                                       // use your label's font
                                       attributes: [.font: descriptionLabel.font!],
                                       context: nil)
        
        return rect.height + 6  // give a little extra arbitrary space (6), remove it if you don't need
    }

And use the method above to configure your multiple lines label in viewDidLoad

let description = "Long long long ... text"
descriptionLabel.text = description
// manually calculate multiple lines label height and add a constraint to avoid extra space bug
descriptionLabel.heightAnchor.constraint(equalToConstant: calculateHeightForString(description)).isActive = true

This solved my issue, hope it can work for you too.

Daniel Hu
  • 423
  • 4
  • 11
1

I did a subclass to keep the layout stuff inside of the view itself.

/// The view gets automatically resized based on it's constraints to fit into parent `UITableView`. Assign to `tableHeaderView`, do not set constraints between `DynamicHeaderView` and the `UITableView`.
class DynamicTableHeaderView: UIView {
    override func layoutSubviews() {
        if let tableView = superview as? UITableView {
            let targetSize = CGSize(width: tableView.contentSize.width, height: UIView.layoutFittingCompressedSize.height)
            let fittingSize = self.systemLayoutSizeFitting(targetSize, withHorizontalFittingPriority: .required, verticalFittingPriority: .fittingSizeLevel)
            if frame.size != fittingSize {
                self.frame.size = fittingSize
                tableView.tableHeaderView = self // Forces the tableView to redraw
            }
        }
        super.layoutSubviews()
    }
}
MrKew
  • 333
  • 1
  • 14
  • **The incredible and key tip is that you have to use `verticalFittingPriority: .fittingSizeLevel`** – Fattie Aug 30 '23 at 01:30
  • 1
    @Fattie Yes, the `systemLayoutSizeFitting` returns size of the view closest to the `targetSize` while respecting the fitting priorities. The `UIView.layoutFittingCompressedSize` is just a different name for `CGSize(height: 0, width: 0)`, so the `systemLayoutSizeFitting` will try to fit the view into rect of width `contentSize.width` and height `0`. That is not possible and the `.required` means the width cannot be expanded, while the `.fittingSizeLevel` means the height should expand as needed. So the height will expand to fit the view into the given width. – MrKew Aug 30 '23 at 07:07
  • I'm thinking it through !! – Fattie Aug 30 '23 at 10:48
  • 1
    I did some experiments and you are totally correct!!!! I don't yet *understand* why but you're right! Phew! thank you! – Fattie Aug 30 '23 at 11:49
0
override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
        if let headerView = self.tableView.tableHeaderView {
            let headerViewFrame = headerView.frame
            let height = headerView.systemLayoutSizeFitting(headerViewFrame.size, withHorizontalFittingPriority: UILayoutPriority.defaultHigh, verticalFittingPriority: UILayoutPriority.defaultLow).height
            var headerFrame = headerView.frame
            if height != headerFrame.size.height {
                headerFrame.size.height = height
                headerView.frame = headerFrame
                self.tableView.tableHeaderView = headerView
            }
        }
    }

Problem in calculating label size when using horizontal or vertical fitting

0

If all constraint is added, this will work:

headerView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
hstdt
  • 5,652
  • 2
  • 34
  • 34
0

It's this "simple" in 2023.

Note the critical tip from @MrKew.

Drag a plain UIView in to the table header position of the table. Don't subclass or reference it.

Inside that view, add whatever you want.

Obviously, chain the vertical constraints top to bottom. (If you are not completely familiar with that process, seek basic tutorials.)

Note that importantly you do not have to and should not reduce/increase any of the three top/tail priorities. Note that a vert stack view is common here, and works perfectly.

The magic code from @MrKew is:

override func viewDidLayoutSubviews() {
    
    super.viewDidLayoutSubviews()
    
    if let thv = table.tableHeaderView {
        
        var f = thv.frame
        
        let targetSize = CGSize(
            width: table.contentSize.width,
            height: UIView.layoutFittingCompressedSize.height)
        let fittingSize = thv.systemLayoutSizeFitting(
            targetSize,
            withHorizontalFittingPriority: .required,
            verticalFittingPriority: .fittingSizeLevel)
        f.size = fittingSize
        
        thv.frame = f
        table.tableHeaderView = thv
    }
}

That's the end of it.

There's no other way to do it.

Note that the @MrKew answer gives the key element needed in the latest versions of UIKit.

(Please note that you do indeed have to specify the width (ie, simply your table width), not one of the .layoutFitting values, as @MrKew explains in the comments.)

Fattie
  • 27,874
  • 70
  • 431
  • 719
  • 1
    I think you should specify fixed target width instead of just using the `UIView.layoutFittingCompressedSize`. The way you are doing it, you are calculating the height based on 0 width, which can't be right. Even if it somehow works, I think specifying the target width states the intentions of this code much more cleanly. – MrKew Aug 30 '23 at 10:20
  • @MrKew please help me understand what you mean .. in a table view (not collection view) all cells are as wide as the table and the content view has a width constraint that does that (I'd never thought about it but I guess the plain UIView you drop in as a table header view, also gets the same constraint, or nothing would work - I guess). Then, on the vertical, say you have eg. a stack view. as always you want that to be compressed, not expanded. So that does seem to be the correct logic. I am also reading your other comment ... – Fattie Aug 30 '23 at 10:48
  • 1
    Ok, the `.systemLayoutSizeFitting` just calculates the size that is needed to fit the view and the `UIView.layoutFittingCompressedSize` is different name for `CGSize(height: 0, width: 0)`. So the line`thv.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize, withHorizontalFittingPriority: .required, verticalFittingPriority: .fittingSizeLevel)` means: "Give me actual size that is needed to fit the `thv` inside a `CGSize(height: 0, width: 0)` where the width is `.required` (that means it has to be exactly target width, threfore 0) and height should be as close to 0 as possible. [Continues] – MrKew Aug 30 '23 at 12:08
  • 1
    [Continues] This will return: `CGSize(height: [the height of the view if width was 0], width: 0)`. You then set the `thv`'s height to this height, which is surely bigger than needed height if `thv` had width of the `contentSize`. After that you add the `thv` as header view, which sets its width to `contentSize` width, but the height stays untouched and therefore bigger than needed. https://developer.apple.com/documentation/uikit/uiview/1622623-systemlayoutsizefitting Thats how I understand it at least, I might be wrong. – MrKew Aug 30 '23 at 12:08
-1
**My Working Solution is:
Add this function in viewcontroller**
public override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
        guard let headerView = myTableView.tableHeaderView else { return }
        let height = headerView.systemLayoutSizeFitting(UILayoutFittingCompressedSize).height
        var headerFrame = headerView.frame

        if height != headerFrame.size.height {
            headerFrame.size.height = height
            headerView.frame = headerFrame
            myTableView.tableHeaderView = headerView

            if #available(iOS 9.0, *) {
                myTableView.layoutIfNeeded()
            }
        }

        headerView.translatesAutoresizingMaskIntoConstraints = true
} 


 **Add one line in header's view class.**
override func layoutSubviews() {
        super.layoutSubviews()
        bookingLabel.preferredMaxLayoutWidth = bookingLabel.bounds.width

    }
Gurpreet Singh
  • 803
  • 9
  • 16
-7

Just implementing these two UITableView delegate methods worked for me:

-(CGFloat)tableView:(UITableView *)tableView estimatedHeightForHeaderInSection:(NSInteger)section
{
    return 100;
}

-(CGFloat)tableView:(UITableView *)tableView heightForHeaderInSection:(NSInteger)section
{
    return UITableViewAutomaticDimension;
}
kachulach
  • 45
  • 1
  • 7