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:
intrinsicSize
is 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
intrinsicSize
and theCompression 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 independend from the views frame? Of course the
UIImageView
can use the size of its image but the height of aUILabel
can obviously only be calculated depending on its content AND its width. So how could myRectsView
calculate its height without considering the frames width? - When should the calculation of the
intrinsicSize
happen? In my example of theRectsView
the size depends on rect size, spacing and number. In aUILabel
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?
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 RectsView
s 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 inRectsView
to re-calculate whenbounds
changes.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,
UILabel
can re-calculate itsintrinsicContentSize
correctly 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.