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
As @DonMag pointed out in his excellent answers to my previous question, 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
@DonMag suspected, that "I do not really understanding what intrinsicContentSize does and does not do". He is most likely correct :-) However, 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:
intrinsicSizeis a feature used inAutoLayout. 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
intrinsicSizeand theCompression Resistance+Content Huggingproperties. - The calculation of the
intrinsicSizeshould only depend on the content, not of the views frame.
What I do not understand:
- How can the calculation be independend from the views frame? Of course the
UIImageViewcan use the size of its image but the height of aUILabelcan obviously only be calculated depending on its content AND its width. So how could myRectsViewcalculate its height without considering the frames width? - When should the calculation of the
intrinsicSizehappen? In my example of theRectsViewthe size depends on rect size, spacing and number. In aUILabelthe 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?
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

Came across this question: UITableViewCell with intrinsic height based on width which seems to be a similar issue.
That solution was to override
systemLayoutSizeFitting(...)in the cell class. Seems to work for your case -- with a necessary change inRectsViewto re-calculate whenboundschanges.See how this behaves for you:
Edit
Yes, that would be a work-around... although, if it solves the issue, maybe not such a bad thing.
As you've noted,
UILabelcan re-calculate itsintrinsicContentSizecorrectly based on the final width -- which doesn't seem to be available in time for the way this code is written.Take a look at
UILabel.h(search for iOS runtime headers), and you'll see a lot of things "under-the-hood" that we're not privy to.