DEV Community

Cover image for Make Your UICollectionView Cells Resize Themselves in iOS 16 (No reloadData())
nooraja
nooraja

Posted on

Make Your UICollectionView Cells Resize Themselves in iOS 16 (No reloadData())

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
Enter fullscreen mode Exit fullscreen mode

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()
    }
}
Enter fullscreen mode Exit fullscreen mode

The key line is the last one in render():

invalidateIntrinsicContentSize()
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

  1. Keep contentView fully constrained. The list can only infer height correctly when the cell has an unbroken Auto Layout chain from top to bottom.
  2. Keep UI state in the data model. selfSizingInvalidation recalculates size; it does not preserve state during cell reuse. The example saves isExpanded back to articles for 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.

References

Top comments (0)