Proper Usage of Intrinsiccontentsize and Sizethatfits: on Uiview Subclass with Autolayout

Proper usage of intrinsicContentSize and sizeThatFits: on UIView Subclass with autolayout

I don't think you need to define an intrinsicContentSize.

Here's two reasons to think that:

  1. When the Auto Layout documentation discusses intrinsicContentSize, it refers to it as relevant to "leaf-views" like buttons or labels where a size can be computed purely based on their content. The idea is that they are the leafs in the view hierarchy tree, not branches, because they are not composed of other views.

  2. IntrinsicContentSize is not really a "fundamental" concept in Auto Layout. The fundamental concepts are just constraints and the attributes bound by constraints. The intrinsicContentSize, the content-hugging priorities, and the compression-resistance priorities are really just conveniences to be used to generate internal constraints concerning size. The final size is just the result of those constraints interacting with all other constraints in the usual way.

So what? So if your "custom view" is really just an assembly of a couple other views, then you don't need to define an intrinsicContentSize. You can just define the constraints that create the layout you want, and those constraints will also produce the size you want.

In the particular case that you describe, I'd set a >=0 bottom space constraint from the label to the superview, another one from the image to the superview, and then also a low priority constraint of height zero for the view as a whole. The low priority constraint will try to shrink the assembly, while the other constraints stop it from shrinking so far that it clips its subviews.

If you never define the intrinsicContentSize explicitly, how do you see the size resulting from these constraints? One way is to force layout and then observe the results.

Another way is to use systemLayoutSizeFittingSize: (and in iOS8, the little-heralded systemLayoutSizeFittingSize:withHorizontalFittingPriority:verticalFittingPriority:). This is a closer cousin to sizeThatFits: than is intrinsicContentSize. It's what the system will use to calculate your view's appropriate size, taking into account all constraints it contains, including intrinsic content size constraints as well as all the others.

Unfortunately, if you have a multi-line label, you'll likely also need to configure preferredMaxLayoutWidth to get a good result, but that's another story...

intrinsicContentSize vs. sizeThatFits. What's the difference? What are the use cases for each?

intrinsicContentSize was added in iOS 6 and as you mentioned is part of the AutoLayout API so anything which supports an earlier iOS won't have access to it. Also, if you turn off auto layout, it doesn't matter which you use and many people who are used to using sizeThatFits will still use it for a while... at least until auto layout gain greater adoption.

How to set a custom view's intrinsic content size in Swift?

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.

override var intrinsicContentSize: CGSize {
return CGSize(width: x, height: y)
}

Then call

invalidateIntrinsicContentSize()

Whenever your custom view's intrinsic content size changes and the frame should be updated.

Notes

  • Swift 3 update: Easier Auto Layout: Coding Constraints in iOS 9
  • Just because you have the intrinsic content size set up in your custom view doesn't mean it will work as you expect. Read the documentation for how to use it, paying special attention to Content-Hugging and Compression-Resistance.
  • Thanks also to this Q&A for putting me on the right track: How can I add padding to the intrinsic content size of UILabel?
  • Thanks also to this article and the documentation for help with invalidateIntrinsicContentSize().

Why does intrinsicContentSize for UIView always return (-1.0, -1.0)?

I find this extremely strange considering that the UIView does layout according to its intrinsic content size (i.e. the subviews and associated constraints).

This is just a misunderstanding of what the "intrinsic content size" is. An ordinary UIView has no (meaningful) instrinsic content size.

The intrinsic content size is a property that some interface objects implement (such as UILabel and UIButton) so that you do not have give them height and width constraints. These are special interface objects that contain text (and/or, in the case of a button, an image), and can size themselves to fit that.

Your UIView isn't like that, and basically doesn't implement the intrinsic content size at all. It is possible to write a UIView subclass that does implement the intrinsic content size, behaving like a UILabel or a UIButton; but you have not done so.


You say in a comment:

If I have a UIView with only a Center X and Center Y constraint and given it has some contents, I would like to know what its size is after autolayout operates

Fine, but that is not its intrinsic content size. It is its size. If you are in the view controller, implement didLayoutSubviews and look at this view's bounds.size. That is the size you are after.

I'm curious how I could calculate this

Ah, ok. But then you should have asked that. The answer is: call systemLayoutSizeFittingSize.

IntrinsicContentSize on a custom UIView streches the content

Trying to change the view's height from inside draw() is probably a really bad idea.

First, as you've seen, changing the intrinsic content size does not trigger a redraw. Second, if it did, your code would go into an infinite recursion loop.

Take a look at this edit to your class:

@IBDesignable class MyIntrinsicView: UIView {
override func draw(_ rect: CGRect) {
let context = UIGraphicsGetCurrentContext()
context?.setFillColor(UIColor.gray.cgColor)
context?.fill(CGRect(x: 0, y: 0, width: frame.width, height: 25))

// probably a really bad idea to do this inside draw()
//height = 300
//invalidateIntrinsicContentSize()
}


@IBInspectable var height: CGFloat = 50 {
didSet {
// call when height var is set
invalidateIntrinsicContentSize()
// we need to trigger draw()
setNeedsDisplay()
}
}

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

override func prepareForInterfaceBuilder() {
super.prepareForInterfaceBuilder()
// not needed
//invalidateIntrinsicContentSize()
}
}

Now, when you change the intrinsic height in IB via the IBDesignable property, it will update in your Storyboard properly.

Here's a quick look at using it at run-time. Each tap (anywhere) will increase the height property by 50 (until we get over 300, when it will be reset to 50), which then invalidates the intrinsic content size and forces a call to draw():

class QuickTestVC: UIViewController {

@IBOutlet var testView: MyIntrinsicView!

override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
var h: CGFloat = testView.intrinsicContentSize.height
h += 50
if h > 300 {
h = 50
}
testView.height = h
}
}

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

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.

UIView and intrinsicContentSize

No, a UIView does not have an intrinsicContentSize. Buttons and labels do, since it's easy for the system to calculate that size based on the string and or image in them. For a UIView, you generally need 4 constraints to fully describe its position and size.

UIView with dynamic height that uses intrinsicContentSize

To answer my own question, it appears that there is not an autolayout suitable solution to this situation. Looking to UILabel for inspiration, the problem here has been solved with the addition of a property preferredMaxLayoutWidth, which can then be used as a constraining width during the intrinsic content size calculation. Any custom view would need to use something similar.



Related Topics



Leave a reply



Submit