How to Set a Custom View's Intrinsic Content Size in Swift

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().

iOS - Custom view: Intrinsic content size ignored after updated in layoutSubviews()

After a lot of trying and research I finally solved the bug.

As @DonMag mentioned in the comments the new size of the cell wasn't recognized until a new layout pass. This could be verified by scrolling the cell off-screen and back in which showed the correct layout. Unfortunately it is harder than expected to trigger new pass as .beginUpdates() + .endUpdates()didn't
do the job.

Anyway I didn't find a way to trigger it but I followed the instructions described in this answer. Especially the part with the prototype cell for the height calculation provided a value which can be returned in tableview(heightForRowAt:).

Swift 5:

This is the code used for calculation:

let fitSize = CGSize(width: view.frame.size.width, height: .zero)
/* At this point populate the cell with the exact same data as the actual cell in the tableview */
cell.setNeedsUpdateConstraints()
cell.updateConstraintsIfNeeded()
cell.bounds = CGRect(x: .zero, y: .zero, width: view.frame.size.width, height: cell.bounds.height)
cell.setNeedsLayout()
cell.layoutIfNeeded()
height = headerCell.contentView.systemLayoutSizeFitting(fitSize).height + 1

The value is only calculated once and the cached as the size doesn't change anymore in my case.

Then the value can be returned in the delegate:

func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
indexPath.row == 0 ? height : UITableView.automaticDimension
}

I only used for the first cell as it is my header cell and there is only one section.

How do you make Interface Builder respect a custom view's intrinsic content size in constraint based layout?

Set a placeholder intrinsic content size — a "guess," if you will — in Interface Builder.

  1. Select your custom view.
  2. Show the size inspector (Shift5).
  3. Change the "Intrinsic Size" drop-down from "Default (System Defined)" to "Placeholder."
  4. Enter reasonable guesses at your view's runtime width and height.

These constraints are removed at compile-time, meaning they will have no effect on your running app, and the layout engine will add constraints as appropriate at runtime to respect your view's intrinsicContentSize.

How to make a custom view's frame match its instrinsic content size

I was missing a single line of code:

invalidateIntrinsicContentSize()

I added it after updating the text layer frame.

func updateTextLayerFrame() {

// ...

textLayer.frame = CGRect(x: self.layer.bounds.origin.x, y: self.layer.bounds.origin.y, width: size.width, height: size.height)
invalidateIntrinsicContentSize()

// ...
}

The documentation says

Call this when something changes in your custom view that invalidates
its intrinsic content size. This allows the constraint-based layout
system to take the new intrinsic content size into account in its next
layout pass.

Where I found this solution:

  • Advanced Auto Layout Toolbox

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 set custom UIView width automatically based on its content?

Thanks to @matt, I found the solution.

There were just 2 errors in my code.

  1. The following line code translatesAutoresizingMaskIntoConstraints = false in my custom view was missing, so intrinsicContentSizewas never called.
  2. As suggested by @matt, inside intrinsicContentSize, I need to provide a value based on the intrinsic widths of the label and the button for the width parameters instead of UIViewNoIntrinsicMetric

Now, the right code is :

class Keyword: UIView {

@IBOutlet weak var label: UILabel!
@IBOutlet weak var btn: UIButton!
@IBOutlet var contentView: UIView!

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

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

fileprivate func commonInit() {
translatesAutoresizingMaskIntoConstraints = false
Bundle.main.loadNibNamed("Keyword", owner: self, options: nil)
addSubview(contentView)
contentView.frame = self.bounds
}

override var intrinsicContentSize: CGSize {
let height = btn.frame.size.height
let width = btn.frame.origin.x + btn.frame.size.width
return CGSize(width: width, height: height)
}

// Only override draw() if you perform custom drawing.
// An empty implementation adversely affects performance during animation.
override func draw(_ rect: CGRect) {
// Drawing code
}

}

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.

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.



Related Topics



Leave a reply



Submit