Using Autolayout in a Tableheaderview

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

I asked and answered a similar question here. In summary, I add the header once and use it to find the required height. That height can then be applied to the header, and the header is set a second time to reflect 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;
}

If you have multi-line labels, this also relies on the custom view setting the preferredMaxLayoutWidth of each label:

- (void)layoutSubviews
{
[super layoutSubviews];

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

or perhaps more generally:

override func layoutSubviews() {
super.layoutSubviews()
for view in subviews {
guard let label = view as? UILabel where label.numberOfLines == 0 else { continue }
label.preferredMaxLayoutWidth = CGRectGetWidth(label.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()
header.frame.size = header.systemLayoutSizeFittingSize(UILayoutFittingCompressedSize)
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
self.tableHeaderView?.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
header.widthAnchor.constraint(equalTo: self.widthAnchor)
])
header.setNeedsLayout()
header.layoutIfNeeded()
header.frame.size = header.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
self.tableHeaderView = header
}
}

Usage:

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

Using autolayout in a tableHeaderView

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)

tableHeaderView with Autolayout not working correctly

In order to make it work you'll need some magic or migrate away from table header view. I've already answered on a similar question here. The trick is to magically reset tableHeaderView after evaluating it's height via autolayout. I've created sample project for that: TableHeaderView+Autolayout.

How do you install a tableHeaderView on a UITableView?

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:

Sample Image

Sample Image


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
}
}
}

UITableView tableHeaderView using autolayout height too small

Answering my own question here:

There are 2 steps that seem to get this to work. The first is in ViewDidLoad, and the second is in viewDidLayoutSubviews:

var headerView: UIView!

override func viewDidLoad() {
super.viewDidLoad()

if let currentTableHeaderView = self.tableView.tableHeaderView {
currentTableHeaderView.removeFromSuperview()
}
// Setting the table header view with a height of 0.01 fixes a bug that adds a gap between the
// tableHeaderView (once added) and the top row. See: http://stackoverflow.com/a/18938763/657676
self.tableView.tableHeaderView = UIView(frame: CGRectMake(0, 0, CGRectGetWidth(self.tableView.frame), 0.01))
self.headerView = AboutTableViewHeaderView(frame: CGRectZero)
}

override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
if let tableHeaderView = self.headerView {
var frame = CGRectZero
frame.size.width = self.tableView.bounds.size.width
frame.size.height = tableHeaderView.systemLayoutSizeFittingSize(UILayoutFittingCompressedSize).height
if self.tableView.tableHeaderView == nil || !CGRectEqualToRect(frame, tableHeaderView.frame) {
tableHeaderView.frame = frame
tableHeaderView.layoutIfNeeded()
self.tableView.tableHeaderView = tableHeaderView
}
}
}

Hopefully that all makes sense. The viewDidLayoutSubview code is via http://osdir.com/ml/general/2014-06/msg19399.html

Auto Layout for tableHeaderView

If you make a instance variable for the NSLayoutConstraint and set it like so:

headerViewHeightConstraint = [NSLayoutConstraint constraintWithItem:headerView
attribute:NSLayoutAttributeHeight
relatedBy:NSLayoutRelationEqual
toItem:nil
attribute:NSLayoutAttributeNotAnAttribute
multiplier:1.0f
constant:otherView.frame.size.height];

You can resize it by:

headerViewHeightConstraint.constant = 1000.0f;
[headerView updateConstraintsIfNeeded];

P.S: keep in mind that addConstraints: is not the same thing as addConstraint:.

addConstraints is used in conjunction with an array of constraints or [NSLayoutConstraints constraintsWithVisualFormat:...]

addConstraint: is used in conjunction with a single constraint which is the example above.

If you're overriding layoutSubviews: call [super layoutSubviews] in its very first line. That forces the view and its subviews to layout themselves so you can access their frame details etc.

How do I set the height of tableHeaderView (UITableView) with autolayout?

You need to use the UIView systemLayoutSizeFittingSize: method to obtain the minimum bounding size of your header view.

I provide further discussion on using this API in this Q/A:

How to resize superview to fit all subviews with autolayout?

Can't center views in tableHeaderView with auto layout and storyboards

Make sure that your UITableView has the leading, trailing, bottom, top constraints set up against its superview.



Related Topics



Leave a reply



Submit