How to correctly use intrincSize in custom UIView. Where/when to calculate and to update the size?

I have been trying to understand and utilize intrinsicSize on a custom UIView for some days now. So far with little success.

The post is quite long, sorry for that :-) Problem is, that the topic is quite complex. While I know that there might be other solutions, I simply want to understand how intrinsicSize can be used correctly.

So when someone knows a good source for a in depth explanation on how to use / implement intrinsicSize you can skip all my questions and just leave me link.

My goal:

Create a custom UIView which uses intrinsicSize to let AutoLayout automatically adopt to different content. Just like a UILabel which automatically resizes depending on its text content, font, font size, etc.

As an example assume a simple view RectsView which does nothing but drawing a given number of rects of a given size with given spacing. If not all rects fit into a single row, the content is wrapped and drawing is continued in another row. Thus the height of the view depends on the different properties (number of rects, rects size, spacing, etc.)

This is very much like a UILabel but instead of words or letters simple rects are drawn. However, while UILabel works perfectly I was not able to achive the same for my RectsView.

Why intrinsicSize

I do not have to use intrinsicSize to achieve my goal. I could also use subviews and add constraints to create such a rect pattern. Or I could use a UICollectionView, etc.

While this might certainly work, I think it would add a lot of overhead. If the goal would be to recreate a UILabel class, one would not use AutoLayout or a CollectionView to arrange the letters to words, would one? Instead one would certainly try to draw the letters manually... Especially when using the RectsView in a TableView or a CollectionView a plain view with direct drawing is certainly better than a complex solution compiled of tons of subviews arranged using AutoLayout.

Of course this is an extreme example. However, at the bottom line there are cases where using intrinsicSize is certainly the better option. Since UILabel and other build in views uses intrinsicSize perfectly, there has to be a way to get this working and I just want to know how :-)

My understanding of intrinsic Size

The problem is that I found no source which really explains it... Thus I have spend several hours trying to understand how to correctly use intrinsicSize without little progress.

This is what I have learned [from the docs][1]:

  • intrinsicSize is a feature used in AutoLayout. Views which offer an intrinsic height and/or width do not need to specify constraints for these values.
  • There is no guarantee that the view will exactly get its intrinsicSize. It is more like a way to tell autoLayout which size would be best for the view while autoLayout will calculate the actual size.
  • The calculation is done using the intrinsicSize and the Compression Resistance + Content Hugging properties.
  • The calculation of the intrinsicSize should only depend on the content, not of the views frame.

What I do not understand:

  • How can the calculation be independent from the views frame? Of course the UIImageView can use the size of its image but the height of a UILabel can obviously only be calculated depending on its content AND its width. So how could my RectsView calculate its height without considering the frames width?
  • When should the calculation of the intrinsicSize happen? In my example of the RectsView the size depends on rect size, spacing and number. In a UILabel the size also depends on multiple properties like text, font, font size, etc. If the calculation is done when setting each property it will be performed multiple times which is quite inefficient. So what is the right place to do it?

I will continue the question a second post due to the character limit...

Post not yet marked as solved Up vote post of Agenor Down vote post of Agenor
4.9k views

Replies

Example implementation:

Here is a simple implementation of my RectsView:

@IBDesignable class RectsView: UIView {

    // Properties which determin the intrinsic height
    @IBInspectable public var rectSize: CGFloat = 20 {
        didSet {
            calcContent()
            setNeedsLayout()
        }
    }

    @IBInspectable public var rectSpacing: CGFloat = 10 {
        didSet {
            calcContent()
            setNeedsLayout()
        }
    }

    @IBInspectable public var rowSpacing: CGFloat = 5 {
        didSet {
            calcContent()
            setNeedsLayout()
        }
    }

    @IBInspectable public var rectsCount: Int = 20 {
        didSet {
            calcContent()
            setNeedsLayout()
        }
    }


    // Calculte the content and its height
    private var rects = [CGRect]()
    func calcContent() {
        var x: CGFloat = 0
        var y: CGFloat = 0

        rects = []
        if rectsCount > 0 {
            for _ in 0..<rectsCount {
                let rect = CGRect(x: x, y: y, width: rectSize, height: rectSize)
                rects.append(rect)

                x += rectSize + rectSpacing
                if x + rectSize > frame.width {
                    x = 0
                    y += rectSize + rowSpacing
                }
            }
        }

        height = y + rectSize
        invalidateIntrinsicContentSize()
    }


    // Intrinc height
    @IBInspectable var height: CGFloat = 50
    override var intrinsicContentSize: CGSize {
        return CGSize(width: UIView.noIntrinsicMetric, height: height)
    }

    override func prepareForInterfaceBuilder() {
        super.prepareForInterfaceBuilder()
        invalidateIntrinsicContentSize()
    }


    // Drawing
    override func draw(_ rect: CGRect) {
        super.draw(rect)

        let context = UIGraphicsGetCurrentContext()

        for rect in rects {
            context?.setFillColor(UIColor.red.cgColor)
            context?.fill(rect)
        }


        let attrs = [NSAttributedString.Key.font: UIFont.systemFont(ofSize: UIFont.systemFontSize)]
        let text = "\(height)"

        text.draw(at: CGPoint(x: 0, y: 0), withAttributes: attrs)
    }
}

class ViewController: UITableViewController {
    let CellId = "CellId"

    // Dummy content with different values per row
    var data: [(CGFloat, CGFloat, CGFloat, Int)] = [
        (10.0, 15.0, 13.0, 35),
        (20.0, 10.0, 16.0, 28),
        (30.0, 5.0, 19.0, 21)
    ]

    override func viewDidLoad() {
        super.viewDidLoad()
        tableView.register(UINib(nibName: "IntrinsicCell", bundle: nil), forCellReuseIdentifier: CellId)
    }

    override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return 3
    }

    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: CellId, for: indexPath) as? IntrinsicCell ?? IntrinsicCell()

        // Dummy content with different values per row
        cell.rectSize = data[indexPath.row].0     // CGFloat((indexPath.row+1) * 10)
        cell.rectSpacing = data[indexPath.row].1  // CGFloat(20 - (indexPath.row+1) * 5)
        cell.rowSpacing = data[indexPath.row].2   // CGFloat(10 + (indexPath.row+1) * 3)
        cell.rectsCount = data[indexPath.row].3   // (5 - indexPath.row) * 7

        return cell
    }

    // Add/remove content when tapping on a row
    override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        tableView.deselectRow(at: indexPath, animated: true)
        var rowData = data[indexPath.row]
        rowData.3 = (indexPath.row % 2 == 0 ? rowData.3 + 5 : rowData.3 - 5)
        data[indexPath.row] = rowData
        tableView.reloadRows(at: [indexPath], with: .automatic)
    }
}


// Simple cell holing an intrinsic rectsView
class IntrinsicCell: UITableViewCell {
    @IBOutlet private var rectsView: RectsView!

    var rectSize: CGFloat {
        get { return rectsView.rectSize }
        set { rectsView.rectSize = newValue }
    }

    var rectSpacing: CGFloat {
        get { return rectsView.rectSpacing }
        set { rectsView.rectSpacing = newValue }
    }

    var rowSpacing: CGFloat {
        get { return rectsView.rowSpacing }
        set { rectsView.rowSpacing = newValue }
    }

    var rectsCount: Int {
        get { return rectsView.rectsCount }
        set { rectsView.rectsCount = newValue }
    }
} 

Problems:

Basicly the intrinsicSize works fine: When the TableView is rendered for the first time each row has a different height depending on its intrinsic content. However, the size is not correctly, since the intrinsicSize is calculated before the TableView actually layouts its subviews. Thus the RectsViews calculate their content size using the default width instead of there acutal, final width.

So: When/Where to calculate the initial layout?

Additionally updates to the properties (= tapping on the cells) are not handled correctly

So: When/Where to calculate the updated layout?

Again: Sorry for the long post and thank you very much if you have managed to read until here. I really appreciate that!

I you know any good source / tutorial / howto which explains all this, I happy about any link you can provide!

UPDATE: Some more observations

I have added some debug output to my RectsView and to a UILabel subclass to see how intrinsicContentSize is used.

In RectsView intrinsicContentSize is called only once before the bounds are set to their final size. Since at this point I not not know the final size yet, I can only calculate the intrinsic size based on the old, outdated width which leads to a wrong result.

In UIView however, intrinsicContentSize is called multiple times (why?) and in the last call, the result seems to be fitting the upcoming, final size. How can this size be known at this point?

RectsView willSet frame: (-120.0, -11.5, 240.0, 23.0)
RectsView didSet frame: (40.0, 11.0, 240.0, 23.0)
RectsView didSet rectSize: 10.0
RectsView didSet rectSpacing: 15.0
RectsView didSet rowSpacing: 20
RectsView didSet rectsCount: 35
RectsView get intrinsicContentSize: 79.0
RectsView willSet bounds: (0.0, 0.0, 240.0, 23.0)
RectsView didSet bounds: (0.0, 0.0, 350.0, 79.33333333333333)
RectsView layoutSubviews
RectsView layoutSubviews

MyLabel willSet frame: (-116.5, -9.5, 233.0, 19.0)
MyLabel didSet frame: (53.0, 13.0, 233.0, 19.0)
MyLabel willSet text: (53.0, 13.0, 233.0, 19.0)
MyLabel didSet text: (53.0, 13.0, 233.0, 19.0)
MyLabel get intrinsicContentSize: (65536.0, 20.333333333333332)
MyLabel get intrinsicContentSize: (675.0, 20.333333333333332)
MyLabel get intrinsicContentSize: (65536.0, 20.333333333333332)
MyLabel get intrinsicContentSize: (338.0, 40.666666666666664)
MyLabel get intrinsicContentSize: (65536.0, 20.333333333333332)
MyLabel get intrinsicContentSize: (65536.0, 20.333333333333332)
MyLabel get intrinsicContentSize: (338.0, 40.666666666666664)
MyLabel get intrinsicContentSize: (65536.0, 20.333333333333332)
MyLabel get intrinsicContentSize: (338.0, 40.666666666666664)
MyLabel willSet bounds: (0.0, 0.0, 233.0, 19.0)
MyLabel didSet bounds: (0.0, 0.0, 350.0, 41.0)
MyLabel layoutSubviews
MyLabel layoutSubviews

When called for the first time UILabel returns a intrinsic width of 65536.0 is this some constant? I am only aware of UIView.noIntrinsicMetric = -1 which specifies, that the view does not have a instrinsic size in the given dimension.

Why, is intrinsicContentSize called multiple times on UILabel? I tried to return the same size (65536.0, 20.333333333333332) in RectsView but this does not make any difference, intrinsicContentSize is still only called once.

In the last call to intrinsicContentSize of UILabel the value (338.0, 40.666666666666664) is returend. It seems that UILabel know at this point, that it will be re-sized to a width of 350, but how?

Another oberservation is, that on both views intrinsicContentSize is NOT called after bounds.didSet. Thus there has to be a way to know the upcomming frame changes in intrinsicContentSize before bounds.didSet. How?

UPDATE 2:

I have added debug output to following, other UILabel methods as well, but they are not called (thus seem not to influence the problem):

sizeToFit()
sizeThatFits(_ :)
contentCompressionResistancePriority(for :)
contentHuggingPriority(for :)
systemLayoutSizeFitting(_ :)
systemLayoutSizeFitting(_ :, withHorizontalFittingPriority :, verticalFittingPriority:)
preferredMaxLayoutWidth  get + set

Let's begin

Full source code in GitHub

Step 1

In class SquareView : UIView the key is to define a "property called preferredMaxLayoutWidth, which specifies the maximum line width for calculating the intrinsic content size.

Since we usually don't know this value in advance, we need to take a two-step approach to get this right. First we let Auto Layout do its work, and then we use the resulting frame in the layout pass to update the preferred maximum width and trigger layout again."

override func layoutSubviews() {
    super.layoutSubviews()
    preferredMaxLayoutWidth = self.frame.size.width
    
    invalidateIntrinsicContentSize()
    setNeedsDisplay()
    super.layoutSubviews()
}

Step 2

Now that we have our preferredMaxLayoutWidth we can compute the intrinsicContentSize:

override var intrinsicContentSize: CGSize {
        
    let width = squareHeight * CGFloat(squares) + padding * CGFloat(squares) + padding
    
    if width < preferredMaxLayoutWidth {
        let height = squareHeight + padding + padding
        let size = CGSize(width: width, height: height)
        return size
    }
    else {
        let vertSquares = ceil((width / preferredMaxLayoutWidth))
        let height = squareHeight * vertSquares + padding * vertSquares + padding
        let size = CGSize(width: preferredMaxLayoutWidth, height: height)
        return size
    }
}

Step 3

In the class SquareCell: UITableViewCell we have to force the layout of all subviews, which updates self's intrinsic height, and thus height of a cell. The key thing is to call super at the end (NOT at the beginning)

override func systemLayoutSizeFitting(_ targetSize: CGSize, withHorizontalFittingPriority horizontalFittingPriority: UILayoutPriority, verticalFittingPriority: UILayoutPriority) -> CGSize {
    self.setNeedsLayout()
    self.layoutIfNeeded()

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

Learn more: UITableViewCell with intrinsic height based on width

Step 4

In the class SquareViewController: UIViewController make sure to reload the table when the orientation changes to landscape:

override func willTransition(to newCollection: UITraitCollection, with coordinator: UIViewControllerTransitionCoordinator) {
    coordinator.animate(alongsideTransition: { (context) in
        guard let windowInterfaceOrientation = self.windowInterfaceOrientation else { return }
        
        if windowInterfaceOrientation.isLandscape {
            self.myTableView.reloadData()
        } 
    })
}

This is it!

Now enjoy creating custom UIViews that auto adjust to your table view cells!