A collection view cell often needs to change height after it is already onscreen: an expandable description, a validation message, or content returned by an asynchronous request are familiar examples.
The instinctive solution is usually reloadData(), reloadItems(at:), or collectionViewLayout.invalidateLayout(). Those APIs still have their place for data and layout changes, but using them solely to re-measure one self-sizing cell is unnecessarily broad.
Since iOS 16, UICollectionView and UITableView expose selfSizingInvalidation. The property tells UIKit how to react when a self-sizing cell needs a new intrinsic size.
The three modes
| Mode | Use it when |
|---|---|
.disabled |
You want UIKit to skip self-sizing invalidation. |
.enabled |
Your cell will explicitly call invalidateIntrinsicContentSize(). |
.enabledIncludingConstraints |
You also want UIKit to invalidate automatically after a constraint change inside the cell’s contentView. |
For an expand/collapse interaction, I prefer .enabled: the cell is explicit about the moment its visible content changes.
collectionView.selfSizingInvalidation = .enabled
UICollectionViewCompositionalLayout.list(using:) provides an excellent self-sizing list layout, so the demo does not need a custom layout subclass or a manual sizeForItemAt implementation.
Complete example
Create a UIKit view controller, set the deployment target to iOS 16 or later, and add this file. The code uses a list-style compositional layout, an Auto Layout-backed cell, and an expandable detail label.
import UIKit
@available(iOS 16.0, *)
final class DynamicSizeViewController: UIViewController {
fileprivate struct Article: Hashable {
let id = UUID()
let title: String
let body: String
var isExpanded = false
}
private var articles = [
Article(title: "What is selfSizingInvalidation?", body: "An iOS 16 UIKit API that re-measures an Auto Layout-backed cell without reloading the entire collection view."),
Article(title: "When is it useful?", body: "Use it when a user expands a description, asynchronous content arrives, or a view inside the cell changes height.")
]
private lazy var collectionView: UICollectionView = {
var configuration = UICollectionLayoutListConfiguration(appearance: .insetGrouped)
configuration.showsSeparators = false
let view = UICollectionView(
frame: .zero,
collectionViewLayout: UICollectionViewCompositionalLayout.list(using: configuration)
)
view.translatesAutoresizingMaskIntoConstraints = false
view.backgroundColor = .systemGroupedBackground
view.dataSource = self
view.register(ExpandableCell.self, forCellWithReuseIdentifier: ExpandableCell.reuseIdentifier)
view.selfSizingInvalidation = .enabled
return view
}()
override func viewDidLoad() {
super.viewDidLoad()
view.addSubview(collectionView)
NSLayoutConstraint.activate([
collectionView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
])
}
}
@available(iOS 16.0, *)
extension DynamicSizeViewController: UICollectionViewDataSource {
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
articles.count
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: ExpandableCell.reuseIdentifier,
for: indexPath
) as! ExpandableCell
let article = articles[indexPath.item]
cell.configure(with: article) { [weak self] isExpanded in
guard let self,
let index = self.articles.firstIndex(where: { $0.id == article.id }) else { return }
self.articles[index].isExpanded = isExpanded
}
return cell
}
}
@available(iOS 16.0, *)
private final class ExpandableCell: UICollectionViewCell {
static let reuseIdentifier = "ExpandableCell"
private let titleLabel = UILabel()
private let bodyLabel = UILabel()
private let toggleButton = UIButton(type: .system)
private var isExpanded = false
private var onToggle: ((Bool) -> Void)?
override init(frame: CGRect) {
super.init(frame: frame)
titleLabel.font = .preferredFont(forTextStyle: .headline)
titleLabel.numberOfLines = 0
bodyLabel.font = .preferredFont(forTextStyle: .body)
bodyLabel.numberOfLines = 0
bodyLabel.textColor = .secondaryLabel
toggleButton.addTarget(self, action: #selector(toggle), for: .touchUpInside)
let stack = UIStackView(arrangedSubviews: [titleLabel, bodyLabel, toggleButton])
stack.axis = .vertical
stack.spacing = 12
stack.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(stack)
NSLayoutConstraint.activate([
stack.topAnchor.constraint(equalTo: contentView.layoutMarginsGuide.topAnchor),
stack.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor),
stack.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor),
stack.bottomAnchor.constraint(equalTo: contentView.layoutMarginsGuide.bottomAnchor)
])
}
required init?(coder: NSCoder) { nil }
func configure(with article: DynamicSizeViewController.Article, onToggle: @escaping (Bool) -> Void) {
titleLabel.text = article.title
bodyLabel.text = article.body
isExpanded = article.isExpanded
self.onToggle = onToggle
render()
}
@objc private func toggle() {
isExpanded.toggle()
render()
onToggle?(isExpanded)
}
private func render() {
bodyLabel.isHidden = !isExpanded
toggleButton.setTitle(isExpanded ? "Show less" : "Read more", for: .normal)
invalidateIntrinsicContentSize()
}
}
The key line is the last one in render():
invalidateIntrinsicContentSize()
With .enabled, that call tells UIKit to measure the cell again. Auto Layout calculates the new height from the stack view and its visible arranged subviews, then the collection view updates the cell. Notice what is intentionally absent:
// No reloadData()
// No reloadItems(at:)
// No collectionView.collectionViewLayout.invalidateLayout()
When should I use .enabledIncludingConstraints?
Use it when the change itself is a constraint mutation inside contentView—for example, activating a height constraint after an image loads. UIKit will automatically call invalidateIntrinsicContentSize() in that scenario.
collectionView.selfSizingInvalidation = .enabledIncludingConstraints
It still supports an explicit invalidateIntrinsicContentSize() call. That is useful when the change is semantic content (for example, text or visibility) and you want the re-measurement point to be obvious in the code.
Two practical rules
-
Keep
contentViewfully constrained. The list can only infer height correctly when the cell has an unbroken Auto Layout chain from top to bottom. -
Keep UI state in the data model.
selfSizingInvalidationrecalculates size; it does not preserve state during cell reuse. The example savesisExpandedback toarticlesfor that reason.
For a visual transition, wrap the property change in UIView.animate(withDuration:). Avoid jumping to a collection-view reload unless the underlying item set or item data genuinely requires one.
Top comments (0)