Search code examples
iosswiftuitableviewautolayoutcontentsize

How to correctly get tableViewCell's contentView bound size?


Resources:

I've read multiple answers from Using Auto Layout in UITableView for dynamic cell layouts & variable row heights

And followed their suggestions but it's not working.

Setup to reproduce:

If you copy/paste the MyTableViewCell and ViewController snippets: then you can reproduce the issue.

I have subclassed MyTableViewCell and added my own label.

    import UIKit

    class MyTableViewCell: UITableViewCell {

        lazy var customLabel : UILabel = {
            let lbl = UILabel()
            lbl.translatesAutoresizingMaskIntoConstraints = false
            lbl.numberOfLines = 0
            return lbl
        }()

        override init(style: UITableViewCellStyle, reuseIdentifier: String?) {
            super.init(style: style, reuseIdentifier: reuseIdentifier)
            setupLayout()
        }
        private func setupLayout(){
            contentView.addSubview(customLabel)

            let top = customLabel.topAnchor.constraint(equalTo: contentView.topAnchor)
            let bottom = customLabel.bottomAnchor.constraint(equalTo: contentView.bottomAnchor)
            let leadingFromImage = customLabel.leadingAnchor.constraint(equalTo: imageView!.trailingAnchor, constant: 5)
            let trailing = customLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor)

            NSLayoutConstraint.activate([top, bottom, leadingFromImage, trailing])
        }

        required init?(coder aDecoder: NSCoder) {
            fatalError()
        }
    }

The following ViewController contains my tableview:

import UIKit

class ViewController: UIViewController {

    var datasource = ["It would have been a great day had Manchester United Lost its \n game. Anyhow I hope tomorrow Arsenal will win the game"]

    lazy var tableView : UITableView = {
        let table = UITableView()
        table.delegate = self
        table.dataSource = self
        table.translatesAutoresizingMaskIntoConstraints = false
        table.estimatedRowHeight = 100
        table.rowHeight = UITableViewAutomaticDimension
        return table
    }()
    override func viewDidLoad() {
        super.viewDidLoad()
        view.addSubview(tableView)
        tableView.pinToAllEdges(of: view)
        tableView.register(MyTableViewCell.self, forCellReuseIdentifier: "id")
    }
}

extension ViewController: UITableViewDelegate, UITableViewDataSource {

    func numberOfSections(in tableView: UITableView) -> Int {
        return 1
    }

    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return datasource.count
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "id", for: indexPath) as! MyTableViewCell

        cell.customLabel.text = datasource[indexPath.row]
        logInfo(of: cell)

        cell.accessoryType = .detailDisclosureButton
        cell.imageView?.image = UIImage(named: "honey")
        cell.layoutSubviews()
        cell.customLabel.preferredMaxLayoutWidth = tableView.bounds.width
        logInfo(of: cell)
        print("---------")

        return cell
    }

    private func logInfo(of cell: MyTableViewCell){
        print("boundsWidth: \(cell.contentView.bounds.width) | maxLayoutWidth: \(cell.contentView.bounds.width - 44 - 15 - 5) | systemLayoutSizeFitting : \(cell.customLabel.systemLayoutSizeFitting(UILayoutFittingCompressedSize))")
    }    
}

extension UIView{

    func pinToAllEdges(of view: UIView){
        let leading = leadingAnchor.constraint(equalTo: view.leadingAnchor)
        let top = topAnchor.constraint(equalTo: view.topAnchor)
        let trailing = trailingAnchor.constraint(equalTo: view.trailingAnchor)
        let bottom = bottomAnchor.constraint(equalTo: view.bottomAnchor)        

        NSLayoutConstraint.activate([leading, top, trailing, bottom])
    }
}

Link for honey image I used. I've set it's size to 44 * 44

Main issue

My major problem is inside cellForRowAtIndex:

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell = tableView.dequeueReusableCell(withIdentifier: "id", for: indexPath) as! MyTableViewCell

    cell.customLabel.text = datasource[indexPath.row]
    logInfo(of: cell)

    cell.accessoryType = .detailDisclosureButton
    cell.imageView?.image = UIImage(named: "honey")
    cell.layoutSubviews()
    cell.customLabel.preferredMaxLayoutWidth = cell.contentView.bounds.width
    logInfo(of: cell)
    print("---------")

    return cell
}

Questions:

For whatever reason the value assigned to:

cell.customLabel.preferredMaxLayoutWidth

doesn't seem to be right.

Q1: Why is that?

enter image description here

Q2: I'm logging the contentView's bound before and after I call cell.layoutSubviews and it switches from 320 to 260 but then eventually in the viewDebugger it shows up as 308!!!

Why is the contenView's bounds changing again?!

I've removed some other screenshots from the question. They were mostly clutter but maybe worth looking. You can take a look at the revision history.


Solution

  • I believe the issue is related to using the default cell's imageView.

    The image view itself doesn't exist until its .image property is set, so on your cell init you're constraining the custom label to an image view that is 0,0,0,0

    Then, in cellForRowAt, you set the .image property, and it appears that action also sets the contentView height. I can't find any docs on it, and digging through in debug I can't find any conflicting constraints, so I'm not entirely sure why that's happening.

    Two options:

    1 - Instead of creating and adding a custom label, set the .numberOfLines on the default .textLabel to 0. That should be enough.

    2 - If you need a customized label, also add a custom image view.

    Option 2 is here:

    class MyTableViewCell: UITableViewCell {
    
        lazy var customLabel : UILabel = {
            let lbl = UILabel()
            lbl.translatesAutoresizingMaskIntoConstraints = false
            lbl.numberOfLines = 0
            lbl.setContentCompressionResistancePriority(.required, for: .vertical)
            return lbl
        }()
    
        lazy var customImageView: UIImageView = {
            let v = UIImageView()
            v.translatesAutoresizingMaskIntoConstraints = false
            return v
        }()
    
        override init(style: UITableViewCellStyle, reuseIdentifier: String?) {
            super.init(style: style, reuseIdentifier: reuseIdentifier)
            setupLayout()
        }
    
        private func setupLayout(){
            contentView.addSubview(customLabel)
    
            contentView.addSubview(customImageView)
    
            // constrain leading of imageView to be 15-pts from the leading of the contentView
            let imgViewLeading = customImageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 15)
    
            // constrain width of imageView to 42-pts
            let imgViewWidth = customImageView.widthAnchor.constraint(equalToConstant: 42)
    
            // constrain height of imageView to be equal to width of imageView
            let imgViewHeight = customImageView.heightAnchor.constraint(equalTo: customImageView.widthAnchor, multiplier: 1.0)
    
            // center imageView vertically
            let imgViewCenterY = customImageView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor, constant: 0.0)
    
            // top and bottom constraints for the imageView also need to be set,
            // otherwise the image will exceed the height of the cell when there
            // is not enough text to wrap and expand the height of the label
    
            // constrain top of imageView to be *at least* 4-pts from the top of the cell
            let imgViewTop = customImageView.topAnchor.constraint(greaterThanOrEqualTo: contentView.topAnchor, constant: 4)
    
            // constrain bottom of imageView to be *at least* 4-pts from the bottom of the cell
            let imgViewBottom = customImageView.topAnchor.constraint(lessThanOrEqualTo: contentView.bottomAnchor, constant: -4)
    
            // constrain top of the label to be *at least* 4-pts from the top of the cell
            let top = customLabel.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 4)
    
            // if you want the text in the label vertically centered in the cell
            // constrain bottom of the label to be *exactly* 4-pts from the bottom of the cell
            let bottom = customLabel.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -4)
    
            // if you want the text in the label top-aligned in the cell
            // constrain bottom of the label to be *at least* 4-pts from the bottom of the cell
            // let bottom = customLabel.bottomAnchor.constraint(lessThanOrEqualTo: contentView.bottomAnchor, constant: -4)
    
            // constrain leading of the label to be 5-pts from the trailing of the image
            let leadingFromImage = customLabel.leadingAnchor.constraint(equalTo: customImageView.trailingAnchor, constant: 5)
    
            // constrain the trailing of the label to the trailing of the contentView
            let trailing = customLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor)
    
            NSLayoutConstraint.activate([
                top, bottom, leadingFromImage, trailing,
                imgViewLeading, imgViewCenterY, imgViewWidth, imgViewHeight,
                imgViewTop, imgViewBottom
                ])
    
        }
    
        required init?(coder aDecoder: NSCoder) {
            fatalError()
        }
    }
    
    class HoneyViewController: UIViewController {
    
        var datasource = [
            "It would have been a great day had Manchester United Lost its game. Anyhow I hope tomorrow Arsenal will win the game",
            "One line.",
            "Two\nLines.",
        ]
    
        lazy var tableView : UITableView = {
            let table = UITableView()
            table.delegate = self
            table.dataSource = self
            table.translatesAutoresizingMaskIntoConstraints = false
            table.estimatedRowHeight = 100
            table.rowHeight = UITableViewAutomaticDimension
            return table
        }()
    
        override func viewDidLoad() {
            super.viewDidLoad()
            view.addSubview(tableView)
            tableView.pinToAllEdges(of: view)
            tableView.register(MyTableViewCell.self, forCellReuseIdentifier: "id")
        }
    }
    
    extension HoneyViewController: UITableViewDelegate, UITableViewDataSource {
    
        func numberOfSections(in tableView: UITableView) -> Int {
            return 1
        }
    
        func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
            return datasource.count
        }
    
        func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
            let cell = tableView.dequeueReusableCell(withIdentifier: "id", for: indexPath) as! MyTableViewCell
    
            cell.customLabel.text = datasource[indexPath.row]
            logInfo(of: cell)
    
            cell.accessoryType = .detailDisclosureButton
            cell.customImageView.image = UIImage(named: "Honey")
            logInfo(of: cell)
            print("---------")
    
            return cell
        }
    
        private func logInfo(of cell: MyTableViewCell){
            print("boundsWidth: \(cell.contentView.bounds.width) | maxLayoutWidth: \(cell.contentView.bounds.width - 44 - 15 - 5) | systemLayoutSizeFitting : \(cell.customLabel.systemLayoutSizeFitting(UILayoutFittingCompressedSize))")
        }
    }
    
    extension UIView{
    
        func pinToAllEdges(of view: UIView){
            let leading = leadingAnchor.constraint(equalTo: view.leadingAnchor)
            let top = topAnchor.constraint(equalTo: view.topAnchor)
            let trailing = trailingAnchor.constraint(equalTo: view.trailingAnchor)
            let bottom = bottomAnchor.constraint(equalTo: view.bottomAnchor)
    
            NSLayoutConstraint.activate([leading, top, trailing, bottom])
        }
    }
    

    Edit:

    A couple more constraints are needed. If the cell has only enough text for one line (no wrapping), the imageView height will exceed the height of the cell:

    enter image description here

    So, we add top and bottom constraints to the imageView to fit at least the top and bottom of the cell:

    enter image description here

    and, it will probably look a little better with some padding, so we constrain the top and bottom of the imageView to be at least 4-pts from the top and bottom of the cell:

    enter image description here

    If desired, we can also "top-align" the text in the label by constraining its bottom to be at least 4-pts from the bottom, instead of exactly 4-pts from the bottom:

    enter image description here

    The comments in my edited code should explain each of those differences.