Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to override intrinsictContentSize for a view with flexible height and fixed width?

I am trying to display custom views in a tableView and letting iOS compute the height of each row using UITableViewAutomaticDimension. Unfortunatelly my cell are not properly sized (bad height).

My custom view is defined as followed (I am not using AutoLayout for this part, for many reasons, and I do not want to use it):

class MyView : UIView {
    var label: UILabel!

    override init(frame: CGRect) {
        super.init(frame: frame)
        setupView()
    }

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        setupView()
    }

    func setupView() {
        label = UILabel()
        label.numberOfLines = 0
        addSubview(label)
    }

    func setText(text: String) {
        label.text = text
        setNeedsLayout()
    }

    override func layoutSubviews() {
        super.layoutSubviews()
        label.frame = bounds
        invalidateIntrinsicContentSize()
    }

    override var intrinsicContentSize: CGSize {
        guard let text = label.text else { return .zero }
        let width = bounds.width
        let height = text.heightWithConstrainedWidth(width: width, font: label.font)
        return CGSize(width: width, height: height)
    }
}

Now, I wrapped this custom view in a UITableViewCell to use it in a UITableView. Here I am using AutoLayout:

class MyCell : UITableViewCell {
    var myView: MyView!

    override init(style: UITableViewCellStyle, reuseIdentifier: String?) {
        super.init(style: style, reuseIdentifier: reuseIdentifier)
        setupCell()
    }

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        setupCell()
    }

    func setupCell() {
        myView = MyView()
        contentView.addSubview(myView)
        myView.snp.makeConstraints { make in
            make.edges.equalTo(contentView)
        }
    }
}

Now, using the following viewController, I display a cell with a big text to see if its height gets automatically computed:

class ViewController: UIViewController {
    var tableView: UITableView!

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        tableView = UITableView()
        view.addSubview(tableView)
        tableView.snp.makeConstraints { make in
            make.edges.equalTo(view)
        }
        tableView.register(MyCell.self, forCellReuseIdentifier: "MyCell")
        tableView.rowHeight = UITableViewAutomaticDimension
        tableView.estimatedRowHeight = 30
        tableView.dataSource = self
    }
}

extension ViewController: UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return 1
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "MyCell") as! MyCell
        cell.myView.setText(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur")
        return cell
    }
}

Unfortunatelly, the cell's height is equal to 21. And this, even though the height returned by the last intrinsicContentSize call of MyView is 143. Here is a screenshot of the result: enter image description here

The cell is sized correctly if it is recycled (I scroll the cell outside the screen): enter image description here

I know that the intrinsictContentSize of MyView uses its frame and it should not, but I have no idea on how to design a UIView subclass that has a flexible height given a fixed width. I thought it would work given the fact I call invalidateIntrinsicContentSize() in MyView's layoutSubviews().

How can I change this code to have the good cell's height from start?

like image 451
MartinMoizard Avatar asked Dec 11 '16 18:12

MartinMoizard


People also ask

How do I set intrinsic content size?

Setting the intrinsic content size of a custom view lets auto layout know how big that view would like to be. In order to set it, you need to override intrinsicContentSize . Whenever your custom view's intrinsic content size changes and the frame should be updated.

How does a view's intrinsic content size aid in auto layout?

In general, the intrinsic content size simplifies the layout, reducing the number of constraints you need. However, using the intrinsic content size often requires setting the view's content-hugging and compression-resistance (CHCR) priorities, which can add additional complications.

What is intrinsic content size?

Intrinsic content size is information that a view has about how big it should be based on what it displays. For example, a label's intrinsic content size is based on how much text it is displaying. In your case, the image view's intrinsic content size is the size of the image that you selected.


1 Answers

The documentation suggests that overriding intrinsicContentSize may not be the best way to achieve what you want to achieve. As you've observed, from the UIView class reference:

Setting this property allows a custom view to communicate to the layout system what size it would like to be based on its content. This intrinsic size must be independent of the content frame, because there’s no way to dynamically communicate a changed width to the layout system based on a changed height, for example.

MyView's bounds are not set until after its intrinsicContentSize has been requested, and in an auto layout context are not guaranteed to have been set at any time before layoutSubviews() is called. There is a way to work around that and pass a width to your custom view for use in intrinsicContentSize, however, which I've included as the second option in this answer.

Option 1: Manual layout by overriding sizeThatFits(_:)

First, in the absence of auto layout, configure MyView and its label subview to size and resize themselves appropriately:

//  MyView.swift

func setupView() {
    label = UILabel()
    label.numberOfLines = 0
    label.autoresizingMask = [.flexibleWidth] // New
    addSubview(label)
}

override func layoutSubviews() {
    super.layoutSubviews()
    label.sizeToFit() // New
}

override func sizeThatFits(_ size: CGSize) -> CGSize {
    return label.sizeThatFits(size) // New
}

MyView will now size itself according to the size of its label subview, and the label will be sized according to the width of its superview (because of its .flexibleWidth autoresizing mask) and its text content (because numberOfLines is set to 0). If MyView had other subviews, you would need to compute and return the total size in sizeThatFits(_:).

Secondly, we want UITableView to be able to compute the height of its cells and your custom subviews according to the manual layout above. When self-sizing cells, UITableView calls systemLayoutSizeFitting(_:withHorizontalFittingPriority:verticalFittingPriority) on each cell, which in turn calls sizeThatFits(_:) on the cell. (See WWDC 2014 Session 226.)

The target size or fitting size passed to those methods is the width of the table view with a zero height. The horizontal fitting priority is UILayoutPriorityRequired. You take control of the self-sizing cell process by overriding one of those methods in your custom cell subclass (the former for auto layout, the latter for manual layout) and using the width of the size passed in to compute and return the height of the cell.

In this case, the size of the cell is the size of myView:

//  MyCell.swift

override func systemLayoutSizeFitting(_ targetSize: CGSize, withHorizontalFittingPriority horizontalFittingPriority: UILayoutPriority, verticalFittingPriority: UILayoutPriority) -> CGSize {
    return myView.sizeThatFits(targetSize)
}

And you're done.

Option 2: Auto layout by overriding intrinsicContentSize

As the size of MyView depends on the size of its label, the first step is to ensure that the label is positioned and sized correctly. You've already done this:

//  MyView.swift

override func layoutSubviews() {
    super.layoutSubviews()
    label.frame = bounds
}

The second step is to define MyView's intrinsic content size. In this case, there is no intrinsic width (because that dimension will be determined entirely by the cell or other superview), and the intrinsic height is the intrinsic height of the label:

//  MyView.swift

override var intrinsicContentSize: CGSize {
    return CGSize(width: UIViewNoIntrinsicMetric, height: label.intrinsicContentSize.height)
}

Because your label is a multiline label, its intrinsic height cannot be determined in the absence of a maximum width. By default, in the absence of a maximum width, UILabel will return an intrinsic content size appropriate for a single line label, which is not what we want.

The only documented way to provide a maximum width to a multiline label for the purpose of computing its intrinsic content size is the preferredMaxLayoutWidth property. The header file for UILabel provides the following comment for that property:

// If nonzero, this is used when determining -intrinsicContentSize for multiline labels

So the third step is to ensure preferredMaxLayoutWidth is set to an appropriate width. As the intrinsicContentSize property forms part of, and is relevant only to, the auto layout process, and your custom cell subclass is the only part of your code performing auto layout, the appropriate place to set layout preferences is in that class:

//  MyCell.swift

override func systemLayoutSizeFitting(_ targetSize: CGSize, withHorizontalFittingPriority horizontalFittingPriority: UILayoutPriority, verticalFittingPriority: UILayoutPriority) -> CGSize {
    myView.label.preferredMaxLayoutWidth = targetSize.width
    myView.invalidateIntrinsicContentSize()

    return super.systemLayoutSizeFitting(targetSize, withHorizontalFittingPriority: horizontalFittingPriority, verticalFittingPriority: verticalFittingPriority)
}

If MyView had more than one multiline label, or if you preferred to keep the subview hierarchy of MyView hidden from its superview, you could equally create your own preferredMaxLayoutWidth property on MyView and follow through.

The above code will ensure MyView computes and return an appropriate intrinsicContentSize based on the content of its multiline label, and that the size is invalidated on rotation.

like image 53
jamesk Avatar answered Sep 20 '22 16:09

jamesk