Chris
Chris

Reputation: 3817

Why do CompositionalLayout estimated sizes get turned into fixed constraints?

My compositional layout is largely working as intended, with the exception that estimated sizes are being turned into fixed constraints. I want the layout to accommodate the current dynamic type sizing and adapt to changes in it.

If the estimated size is smaller than the initially required size the layout looks broken and there are over-constrained view warnings. If the estimated size is larger than required, the view doesn't shrink to fit.

The image view is constrained as follows:

The label's constraints:

The label's font is set via .preferredFont(forTextStyle:compatibleWith:)

The label has .adjustsFontForContentSizeCategory = true

Adjusting the font size from device Settings takes immediate effect, as expected, in regard to the text size changing and the label frame adjusting. But the estimated size has been turned into a fixed constant constraint, so the view as a whole does not resize as intended/expected.

Appearance with an estimated size larger than required:

Screenshot depicting expected layout

Setting the estimated size too small results in the label disappearing from view. Whatever value of N is passed as the size estimate, it is turned into a seemingly fixed UIView-Encapsulated-Layout-Height: view-height: = N @ 1000 constraint.

From a new iOS app, replacing the entire content of the default ViewController.swift with the code below demonstrates the problem:

(change the values in makeLayout() to see the different outcomes)

import UIKit

struct Model: Hashable {
    let title: String
}

class ImageAndLabelCell: UICollectionViewCell {
    let imageView: UIImageView = {
        let view = UIImageView()
        view.translatesAutoresizingMaskIntoConstraints = false
        view.backgroundColor = .blue
        return view
    }()

    let label: UILabel = {
        let label = UILabel()
        label.translatesAutoresizingMaskIntoConstraints = false
        label.font = .preferredFont(forTextStyle: .subheadline, compatibleWith: .current)
        label.adjustsFontForContentSizeCategory = true
        return label
    }()

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

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

    func setup() {
        contentView.addSubview(imageView)
        contentView.addSubview(label)

        NSLayoutConstraint.activate([
            imageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
            imageView.topAnchor.constraint(equalTo: contentView.topAnchor),
            imageView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
            imageView.heightAnchor.constraint(equalTo: imageView.widthAnchor, multiplier: 9/16),

            label.topAnchor.constraint(equalTo: imageView.bottomAnchor, constant: 10),
            label.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
            label.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
            label.bottomAnchor.constraint(equalTo: contentView.bottomAnchor)
        ])
    }
}

class ViewController: UIViewController {
    private let collection = UICollectionView(frame: .zero,
                                              collectionViewLayout: UICollectionViewFlowLayout())

    override func viewDidLoad() {
        super.viewDidLoad()
        setup()
    }

    private var models = [
        Model(title: "Some text here"),
        Model(title: "Some different here"),
        Model(title: "A third model")
    ]

    private var dataSource: UICollectionViewDiffableDataSource<String, Model>?

    func setup() {
        collection.register(ImageAndLabelCell.self, forCellWithReuseIdentifier: "cell")

        collection.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(collection)

        NSLayoutConstraint.activate([
            collection.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            collection.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            collection.topAnchor.constraint(equalTo: view.topAnchor),
            collection.bottomAnchor.constraint(equalTo: view.bottomAnchor)
        ])

        dataSource = UICollectionViewDiffableDataSource<String, Model>(collectionView: collection, cellProvider: { collectionView, indexPath, itemIdentifier in
            let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath)
            if let cell = cell as? ImageAndLabelCell {
                cell.label.text = self.models[indexPath.row].title
            }
            return cell
        })

        collection.dataSource = dataSource

        dataSource?.apply(currentSnapshot(), animatingDifferences: true)

        collection.setCollectionViewLayout(makeLayout(), animated: true)
    }

    func makeLayout() -> UICollectionViewLayout {
        return UICollectionViewCompositionalLayout { sectionIdx, environment -> NSCollectionLayoutSection? in

            let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                                  heightDimension: .estimated(50))
            let item = NSCollectionLayoutItem(layoutSize: itemSize)

            let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.45),
                                                   heightDimension: .estimated(50))
            let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitem: item, count: 1)

            let section = NSCollectionLayoutSection(group: group)
            section.orthogonalScrollingBehavior = .continuous
            section.interGroupSpacing = 3

            return section
        }
    }

    func currentSnapshot() -> NSDiffableDataSourceSnapshot<String, Model> {
        var snapshot = NSDiffableDataSourceSnapshot<String, Model>()

        snapshot.appendSections(["Main"])
        snapshot.appendItems(models, toSection: "Main")

        return snapshot
    }
}

Update:

Even though the group has only one item in it, switching from .vertical(layoutSize:subItem:count:) to .horizontal(layoutSize:subItem:count:) seems to have helped. The initial rendering no longer causes constraint errors, regardless of whether dynamic type was set large or small.

Changing the font size (either large to small or small to large) once the app is running results in the view trying to resize, which still causes the fixed height constraint to trigger an "Unable to simultaneously satisfy..." error.

Upvotes: 1

Views: 1648

Answers (1)

Anyone who is also struggled with this: I set the bottom/trailing (based on you want dynamic height or dynamic width) constraint priority to 999, and it is gone. Hope it helps.

Upvotes: -1

Related Questions