0

After a decade, I suspected no one has actually asked this question directly. There are many questions asking how to fix a tableHeaderView layout problem caused by rotation for example. But the real question is, how did Apple intend for this to work?

Auto-layout does not seem to play ball with tableHeaderView, as you can see in this almost 9 year old post

Is it possible to use AutoLayout with UITableView's tableHeaderView?

I have been doing iOS development daily, since 2011 and I have never come across API so poorly documented.

Given that auto-layout is such a pickle to work with when installing a tableHeaderView, I decided last week to use the old school method of autoresizing masks. It has been 4 full days and it still isn't working for me. This is quite humbling and I wanted to reach out to you guys, to ask this simple question.

How do you install a tableHeaderView, properly, using autoresizing masks (no auto-layout) ?

My failed attempt

enter image description here

final class EventDetailTableHeaderView: UIView {
    
    private let titleContainer: TitleContainerView
    private let subtitleContainer: SubtitleContainerView
    
    init(_ width: CGFloat, event: CloudEvent) {
        
        let size = CGSize(width: width, height: 0)
        let frame = CGRect(origin: .zero, size: size)
        
        titleContainer = TitleContainerView(frame: frame, text: event.title)
        subtitleContainer = SubtitleContainerView(frame: frame, text: event.displayString)
        
        super.init(frame: frame)
        
        backgroundColor = StyleKit.wDOWhite
        autoresizingMask = [.flexibleWidth]
        
        setupSubviews()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    private func setupSubviews() {
        setupTitleContiner()
        setupSubtitleContainer()
    }
    
    private func setupTitleContiner() {
        addSubview(titleContainer)
        titleContainer.autoresizingMask = [.flexibleWidth]
        titleContainer.backgroundColor = StyleKit.wDOWhite
    }
    
    private func setupSubtitleContainer() {
        addSubview(subtitleContainer)
        subtitleContainer.autoresizingMask = [.flexibleWidth]
        subtitleContainer.backgroundColor = StyleKit.wDOBlue
    }
        
    override func layoutSubviews() {
        super.layoutSubviews()
        positionSubtitleContainer()
        frame = CGRect(
            origin: .zero,
            size: calculateSize()
        )
    }
    
    
    private func positionSubtitleContainer() {
        subtitleContainer.frame.origin.y = titleContainer.frame.height
    }
        
    private func calculateSize() -> CGSize {
        CGSize(
            width: frame.width,
            height: calculateHeightOfSubviews()
        )
    }
    
    private func calculateHeightOfSubviews() -> CGFloat {
        let titleContainerHeight = titleContainer.frame.height
        let subtitleContainerHeight = subtitleContainer.frame.height
        return titleContainerHeight + subtitleContainerHeight
    }
}

final class TitleContainerView: UIView {
    
    private static let font = FontManagement.fontWithStyle(.heavy, withSize: 32.0)
    
    private let label: UILabel = {
        let label = UILabel()
        label.autoresizingMask = [.flexibleWidth]
        label.numberOfLines = 0
        label.backgroundColor = StyleKit.wDOWhite
        label.font = TitleContainerView.font
        label.textColor = StyleKit.wDOBlue
        return label
    }()
    
    convenience init(frame: CGRect, text: String) {
        let font = TitleContainerView.font
        let labelFrame = TitleContainerView.establishLabelFrame(frame, text, font)
        var frame = frame
        frame.size.height = TitleContainerView.establishHeight(labelFrame)
        self.init(frame: frame)
        label.text = text
        label.frame = labelFrame
    }
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        addSubview(label)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    private static let insets = UIEdgeInsets(top: 8, left: 8, bottom: 8, right: 8)
    
    override func layoutSubviews() {
        super.layoutSubviews()
        let font = label.font!
        let text = label.text ?? ""
        label.frame = Self.establishLabelFrame(frame, text, font)
        frame.size.height = Self.establishHeight(label.frame)
    }
        
    private static func establishLabelFrame(_ frame: CGRect, _ text: String, _ font: UIFont) -> CGRect {
        let size = establishLabelSize(frame, text, font)
        let origin = establishLabelOrigin(frame, size)
        return CGRect(origin: origin, size: size)
    }
    
    private static func establishLabelSize(_ frame: CGRect, _ text: String, _ font: UIFont) -> CGSize {
        let width = frame.width - TitleContainerView.insets.left - TitleContainerView.insets.right
        let height = text.height(withConstrainedWidth: width, font: font)
        return CGSize(
            width: width,
            height: height
        )
    }
    
    private static func establishLabelOrigin(_ frame: CGRect, _ size: CGSize) -> CGPoint {
        CGPoint(
            x: (frame.width - size.width) / 2.0,
            y: (frame.height - size.height) / 2.0
        )
    }
    
    private static func establishHeight(_ labelFrame: CGRect) -> CGFloat {
        labelFrame.size.height + TitleContainerView.insets.top + TitleContainerView.insets.bottom
    }
}

extension String {

    func height(withConstrainedWidth width: CGFloat, font: UIFont) -> CGFloat {
        let constraintRect = CGSize(width: width, height: .greatestFiniteMagnitude)
        let boundingBox = self.boundingRect(with: constraintRect, options: .usesLineFragmentOrigin, attributes: [NSAttributedString.Key.font: font], context: nil)
        return ceil(boundingBox.height)
    }
}

override func viewDidLoad() {
        super.viewDidLoad()
                        
        tableView = EventDetailTableView(frame: .zero, style: .plain)
        tableView?.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(tableView!) 
        
        let width = view.bounds.width
        let tableHeaderView = EventDetailTableHeaderView(width, event: event)
        tableHeaderView.layoutIfNeeded()
        tableView?.tableHeaderView = tableHeaderView
                
        NSLayoutConstraint.activate([
            view.safeAreaLayoutGuide.topAnchor.constraint(equalTo: tableView!.topAnchor),
            view.safeAreaLayoutGuide.trailingAnchor.constraint(equalTo: tableView!.trailingAnchor),
            view.safeAreaLayoutGuide.leadingAnchor.constraint(equalTo: tableView!.leadingAnchor),
            view.bottomAnchor.constraint(equalTo: tableView!.bottomAnchor)
        ])
}

enter image description here

bobby123uk
  • 892
  • 4
  • 17
  • It's been a LONG time since I worked with `.autoresizingMask` ... I imagine way-back-when, it was uncommon to have dynamic height table header views. With auto-layout, though, it's not difficult to do so. Are you asking simply to try and find an explanation? Or, is there a reason you don't want to use auto-layout? – DonMag Sep 02 '21 at 14:41
  • I don't want to use auto-layout because every implementation of it (for tableHeaderView) seems hacky - see the aforementioned link. – bobby123uk Sep 03 '21 at 14:10
  • I assume the "paper airplane" is a button... is that part of `SubtitleContainerView`? Or is your headerView `TitleContainerView` + `SubtitleContainerView` + `button`? Or is the button in the first row in the table? – DonMag Sep 03 '21 at 15:41
  • Button is in first row of table @DonMag . I look forward to any feedback you have, if you do decide to give this a go. – bobby123uk Sep 03 '21 at 17:45

1 Answers1

0

While I agree it seems like there would be a more straight-forward way of implementing an auto-height-sizing tableHeaderView, a common approach is to use auto-layout and an extension like this:

extension UITableView {
    func sizeHeaderToFit() {
        guard let headerView = tableHeaderView else { return }
        
        let newHeight = headerView.systemLayoutSizeFitting(CGSize(width: frame.width, height: .greatestFiniteMagnitude), withHorizontalFittingPriority: .required, verticalFittingPriority: .defaultLow)
        var frame = headerView.frame
        
        // avoids infinite loop!
        if newHeight.height != frame.height {
            frame.size.height = newHeight.height
            headerView.frame = frame
            tableHeaderView = headerView
        }
    }
}

We call that from within viewDidLayoutSubviews():

override func viewDidLayoutSubviews() {
    super.viewDidLayoutSubviews()
    tableView.sizeHeaderToFit()
}

Here is a complete example, which should come pretty close to your layout:

class TestViewController: UIViewController {
    
    let tableView = UITableView()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        tableView.translatesAutoresizingMaskIntoConstraints = false
        
        view.addSubview(tableView)
        
        let g = view.safeAreaLayoutGuide
        NSLayoutConstraint.activate([
            tableView.topAnchor.constraint(equalTo: g.topAnchor),
            tableView.leadingAnchor.constraint(equalTo: g.leadingAnchor),
            tableView.trailingAnchor.constraint(equalTo: g.trailingAnchor),
            tableView.bottomAnchor.constraint(equalTo: g.bottomAnchor),
        ])
        
        tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
        tableView.dataSource = self
        tableView.delegate = self
        
        let hView = EventDetailTableHeaderView(titleText: "Street Dance Championships", subTitleText: "4 June 2019  |  8:30 AM to 5:30 PM  |  Sports Wales National Centre  |  Cardiff")
        tableView.tableHeaderView = hView
    }
    
    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
        tableView.sizeHeaderToFit()
    }
}

extension TestViewController: UITableViewDelegate, UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return 30
    }
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let c = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
        c.textLabel?.text = "\(indexPath)"
        return c
    }
}

extension UITableView {
    func sizeHeaderToFit() {
        guard let headerView = tableHeaderView else { return }
        
        let newHeight = headerView.systemLayoutSizeFitting(CGSize(width: frame.width, height: .greatestFiniteMagnitude), withHorizontalFittingPriority: .required, verticalFittingPriority: .defaultLow)
        var frame = headerView.frame
        
        // avoids infinite loop!
        if newHeight.height != frame.height {
            frame.size.height = newHeight.height
            headerView.frame = frame
            tableHeaderView = headerView
        }
    }
}

class TitleContainerView: UIView {

    private static let font: UIFont = .systemFont(ofSize: 32, weight: .heavy)
    
    let label: UILabel = {
        let v = UILabel()
        v.numberOfLines = 0
        v.textColor = UIColor(red: 0.044, green: 0.371, blue: 0.655, alpha: 1.0)
        v.font = TitleContainerView.font
        return v
    }()

    convenience init(text: String) {
        self.init(frame: .zero)
        label.text = text
    }
    override init(frame: CGRect) {
        super.init(frame: frame)
        commonInit()
    }
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        commonInit()
    }
    func commonInit() -> Void {
        backgroundColor = UIColor(red: 0.93, green: 0.94, blue: 0.95, alpha: 1.0)
        label.translatesAutoresizingMaskIntoConstraints = false
        addSubview(label)
        NSLayoutConstraint.activate([
            label.topAnchor.constraint(equalTo: topAnchor, constant: 8.0),
            label.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8.0),
            label.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8.0),
            label.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -8.0),
        ])
    }

}
class SubtitleContainerView: UIView {
    
    private static let font: UIFont = .systemFont(ofSize: 20, weight: .bold)
    
    let label: UILabel = {
        let v = UILabel()
        v.numberOfLines = 0
        v.textColor = .white
        v.font = SubtitleContainerView.font
        return v
    }()
    
    convenience init(text: String) {
        self.init(frame: .zero)
        label.text = text
    }
    override init(frame: CGRect) {
        super.init(frame: frame)
        commonInit()
    }
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        commonInit()
    }
    func commonInit() -> Void {
        backgroundColor = UIColor(red: 0.044, green: 0.371, blue: 0.655, alpha: 1.0)
        label.translatesAutoresizingMaskIntoConstraints = false
        addSubview(label)
        NSLayoutConstraint.activate([
            label.topAnchor.constraint(equalTo: topAnchor, constant: 8.0),
            label.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8.0),
            label.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8.0),
            label.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -8.0),
        ])
    }
}

class EventDetailTableHeaderView: UIView {
    
    var titleView: TitleContainerView!
    var subTitleView: SubtitleContainerView!
    
    convenience init(titleText: String, subTitleText: String) {
        self.init(frame: .zero)
        titleView = TitleContainerView(text: titleText)
        subTitleView = SubtitleContainerView(text: subTitleText)
        commonInit()
    }
    
    func commonInit() -> Void {
        titleView.translatesAutoresizingMaskIntoConstraints = false
        subTitleView.translatesAutoresizingMaskIntoConstraints = false
        addSubview(titleView)
        addSubview(subTitleView)
        
        // this avoids auto-layout complaints
        let titleViewTrailingConstraint = titleView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: 0.0)
        titleViewTrailingConstraint.priority = UILayoutPriority(rawValue: 999)
        let subTitleViewBottomConstraint = subTitleView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: 0.0)
        subTitleViewBottomConstraint.priority = UILayoutPriority(rawValue: 999)
        
        NSLayoutConstraint.activate([
            titleView.topAnchor.constraint(equalTo: topAnchor, constant: 0.0),
            titleView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 0.0),
            
            titleViewTrailingConstraint,
            
            subTitleView.topAnchor.constraint(equalTo: titleView.bottomAnchor, constant: 0.0),
            subTitleView.leadingAnchor.constraint(equalTo: titleView.leadingAnchor, constant: 0.0),
            subTitleView.trailingAnchor.constraint(equalTo: titleView.trailingAnchor, constant: 0.0),
            
            subTitleViewBottomConstraint,
        ])
        
    }

}

and the output looks like this:

enter image description here

enter image description here


Edit -- same output, but using auto-layout only for adding the tableView to the main view.

Class names prefixed with RM_ (for Resizing Mask):

class RM_TestViewController: UIViewController {
    
    let tableView = UITableView()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        tableView.translatesAutoresizingMaskIntoConstraints = false
        
        view.addSubview(tableView)
        
        let g = view.safeAreaLayoutGuide
        NSLayoutConstraint.activate([
            tableView.topAnchor.constraint(equalTo: g.topAnchor),
            tableView.leadingAnchor.constraint(equalTo: g.leadingAnchor),
            tableView.trailingAnchor.constraint(equalTo: g.trailingAnchor),
            tableView.bottomAnchor.constraint(equalTo: g.bottomAnchor),
        ])
        
        tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
        tableView.dataSource = self
        tableView.delegate = self
        
        let hView = RM_EventDetailTableHeaderView(titleText: "Street Dance Championships", subTitleText: "4 June 2019  |  8:30 AM to 5:30 PM  |  Sports Wales National Centre  |  Cardiff")
        tableView.tableHeaderView = hView
    }
    
    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
        tableView.rm_sizeHeaderToFit()
    }
}

extension RM_TestViewController: UITableViewDelegate, UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return 30
    }
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let c = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
        c.textLabel?.text = "\(indexPath)"
        return c
    }
}

extension UITableView {
    func rm_sizeHeaderToFit() {
        guard let headerView = tableHeaderView as? RM_EventDetailTableHeaderView else { return }
        
        headerView.setNeedsLayout()
        headerView.layoutIfNeeded()
        
        // avoids infinite loop!
        if headerView.myHeight != headerView.frame.height {
            headerView.frame.size.height = headerView.myHeight
            tableHeaderView = headerView
        }
    }
}


class RM_TitleContainerView: UIView {
    
    private static let font: UIFont = .systemFont(ofSize: 32, weight: .heavy)
    
    let label: UILabel = {
        let v = UILabel()
        v.numberOfLines = 0
        v.textColor = UIColor(red: 0.044, green: 0.371, blue: 0.655, alpha: 1.0)
        v.font = RM_TitleContainerView.font
        // during dev, so we can see the label frame
        //v.backgroundColor = .green
        return v
    }()
    
    convenience init(text: String) {
        self.init(frame: .zero)
        label.text = text
    }
    override init(frame: CGRect) {
        super.init(frame: frame)
        commonInit()
    }
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        commonInit()
    }
    func commonInit() -> Void {
        backgroundColor = UIColor(red: 0.93, green: 0.94, blue: 0.95, alpha: 1.0)
        addSubview(label)
        label.frame.origin = CGPoint(x: 8, y: 8)
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()
        label.frame.size.width = bounds.width - 16
        let sz = label.systemLayoutSizeFitting(CGSize(width: label.frame.width, height: .greatestFiniteMagnitude), withHorizontalFittingPriority: .required, verticalFittingPriority: .defaultLow)
        label.frame.size.height = sz.height
    }

    var myHeight: CGFloat {
        get {
            return label.frame.height + 16.0
        }
    }
}
class RM_SubtitleContainerView: UIView {
    
    private static let font: UIFont = .systemFont(ofSize: 20, weight: .bold)
    
    let label: UILabel = {
        let v = UILabel()
        v.numberOfLines = 0
        v.textColor = .white
        v.font = RM_SubtitleContainerView.font
        // during dev, so we can see the label frame
        //v.backgroundColor = .systemYellow
        return v
    }()
    
    convenience init(text: String) {
        self.init(frame: .zero)
        label.text = text
    }
    override init(frame: CGRect) {
        super.init(frame: frame)
        commonInit()
    }
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        commonInit()
    }
    func commonInit() -> Void {
        backgroundColor = UIColor(red: 0.044, green: 0.371, blue: 0.655, alpha: 1.0)
        addSubview(label)
        label.frame.origin = CGPoint(x: 8, y: 8)
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()
        label.frame.size.width = bounds.width - 16
        let sz = label.systemLayoutSizeFitting(CGSize(width: label.frame.width, height: .greatestFiniteMagnitude), withHorizontalFittingPriority: .required, verticalFittingPriority: .defaultLow)
        label.frame.size.height = sz.height
    }
    
    var myHeight: CGFloat {
        get {
            return label.frame.height + 16.0
        }
    }
}

class RM_EventDetailTableHeaderView: UIView {
    
    var titleView: RM_TitleContainerView!
    var subTitleView: RM_SubtitleContainerView!
    
    convenience init(titleText: String, subTitleText: String) {
        self.init(frame: .zero)
        titleView = RM_TitleContainerView(text: titleText)
        subTitleView = RM_SubtitleContainerView(text: subTitleText)
        commonInit()
    }
    
    func commonInit() -> Void {
        addSubview(titleView)
        addSubview(subTitleView)
        
        // initial height doesn't matter
        titleView.frame = CGRect(x: 0, y: 0, width: self.frame.width, height: 8)
        subTitleView.frame = CGRect(x: 0, y: 0, width: self.frame.width, height: 8)
        
        titleView.autoresizingMask = [.flexibleWidth]
        subTitleView.autoresizingMask = [.flexibleWidth]
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()
        
        // force subviews to update
        titleView.setNeedsLayout()
        subTitleView.setNeedsLayout()
        titleView.layoutIfNeeded()
        subTitleView.layoutIfNeeded()
        
        // get subview heights
        titleView.frame.size.height = titleView.myHeight
        subTitleView.frame.origin.y = titleView.frame.maxY
        subTitleView.frame.size.height = subTitleView.myHeight
    }
    
    var myHeight: CGFloat {
        get {
            return subTitleView.frame.maxY
        }
    }
}
DonMag
  • 69,424
  • 5
  • 50
  • 86
  • Thanks for having a go at this Don. It looks great. It's a shame I can't accept the answer, given that the OP specifically states no auto-layout in the header view. – bobby123uk Sep 04 '21 at 03:53
  • @user9400730 - reading back through very old docs, blogs, articles, tutorials, old code, etc... dynamic layout was always much more difficult than it is now with auto-layout. However, if you really, really want an example of **not** using auto-layout, see the **Edit** to my answer. – DonMag Sep 04 '21 at 14:31
  • Did you have fun with that @DonMag ? :P Thank you. – bobby123uk Sep 09 '21 at 04:54
  • @user9400730 - wouldn't say I "had fun" with it... but it did serve to remind me just how much easier layout is these days. – DonMag Sep 09 '21 at 13:41