UICollectionView How to make a cell size itself dynamically based on its UIHostingConfiguration?

I have made an UICollectionView in which you can double tap a cell to resize it.

I'm using a CompositionalLayout, a DiffableDataSource and the new UIHostingConfiguration hosting a SwiftUI View which depends on an ObservableObject. The resizing is triggered by updating the height property of the ObservableObject. That causes the SwiftUI View to change its frame which leads to the collectionView automatically resizing the cell. The caveat is that it does so immediately without animation only jumping between the old and the new frame of the view.

The ideal end-goal would be to be able to add a .animation() modifier to the SwiftUI View that then determines animation for both view and cell. Doing so now without additional setup makes the SwiftUI View animate but not the cell.

Is there a way to make the cell (orange) follow the size of the view (green) dynamically?

The proper way to manipulate the cell animation (as far as I known) is to override initialLayoutAttributesForAppearingItem() and finalLayoutAttributesForDisappearingItem() but since the cell just changes and doesn't appear/disappear they don't have an effect.

One could also think of Auto Layout constraints to archive this but I don’t think they are usable with UIHostingConfiguration?

I've also tried:

  • subclassing UICollectionViewCell and overriding apply(_ layoutAttributes: UICollectionViewLayoutAttributes) but it only effects the orange cell-background on initial appearance.
  • to put layout.invalidateLayout() or collectionView.layoutIfNeeded() inside UIView.animate() but it does not seem to have an effect on the size change.

Any thoughts, hints, ideas are greatly appreciated ✌️ Cheers!

Here is the code I used for the first gif:

struct CellContentModel {
    var height: CGFloat? = 100
}

class CellContentController: ObservableObject, Identifiable {
    let id = UUID()
    
    @Published var cellContentModel: CellContentModel
    
    init(cellContentModel: CellContentModel) {
        self.cellContentModel = cellContentModel
    }
}

class DataStore {
    var data: [CellContentController]
    var dataById: [CellContentController.ID: CellContentController]
    
    init(data: [CellContentController]) {
        self.data = data
        self.dataById = Dictionary(uniqueKeysWithValues: data.map { ($0.id, $0) } )
    }
    
    static let testData = [
        CellContentController(cellContentModel: CellContentModel()),
        CellContentController(cellContentModel: CellContentModel(height: 80)),
        CellContentController(cellContentModel: CellContentModel())
    ]
}


class CollectionViewController: UIViewController {
    
    enum Section {
        case first
    }
    
    var dataStore = DataStore(data: DataStore.testData)
    
    private var layout: UICollectionViewCompositionalLayout!
    private var collectionView: UICollectionView!
    private var dataSource: UICollectionViewDiffableDataSource<Section, CellContentController.ID>!
    
    override func loadView() {
        createLayout()
        createCollectionView()
        createDataSource()
        view = collectionView
    }
    
}

// - MARK: Layout
extension CollectionViewController {
    func createLayout() {
        let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .estimated(50))
        let Item = NSCollectionLayoutItem(layoutSize: itemSize)
        
        let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.8), heightDimension: .estimated(300))
        let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [Item])
        
        let section = NSCollectionLayoutSection(group: group)
        
        layout = .init(section: section)
    }
    
}

// - MARK: CollectionView
extension CollectionViewController {
    func createCollectionView() {
        collectionView = .init(frame: .zero, collectionViewLayout: layout)
        
        let doubleTapGestureRecognizer = DoubleTapGestureRecognizer()
        doubleTapGestureRecognizer.doubleTapAction = { [unowned self] touch, _ in
            let touchLocation = touch.location(in: collectionView)
            
            guard let touchedIndexPath = collectionView.indexPathForItem(at: touchLocation) else { return }
            let touchedItemIdentifier = dataSource.itemIdentifier(for: touchedIndexPath)!

            dataStore.dataById[touchedItemIdentifier]!.cellContentModel.height = dataStore.dataById[touchedItemIdentifier]!.cellContentModel.height == 100 ? nil : 100
            
        }
        collectionView.addGestureRecognizer(doubleTapGestureRecognizer)
    }
    
}

// - MARK: DataSource
extension CollectionViewController {
    func createDataSource() {
        let cellRegistration = UICollectionView.CellRegistration<UICollectionViewCell, CellContentController.ID>() { cell, indexPath, itemIdentifier in
            
            let cellContentController = self.dataStore.dataById[itemIdentifier]!
            
            cell.contentConfiguration = UIHostingConfiguration {
                TextView(cellContentController: cellContentController)
            }
            .background(.orange)
        }
        
        dataSource = .init(collectionView: collectionView) { collectionView, indexPath, itemIdentifier in
            return collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: itemIdentifier)
        }
        
        var initialSnapshot = NSDiffableDataSourceSnapshot<Section, CellContentController.ID>()
        initialSnapshot.appendSections([Section.first])
        initialSnapshot.appendItems(dataStore.data.map{ $0.id }, toSection: Section.first)
        dataSource.applySnapshotUsingReloadData(initialSnapshot)
        
    }
}

class DoubleTapGestureRecognizer: UITapGestureRecognizer {
    var doubleTapAction: ((UITouch, UIEvent) -> Void)?

    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
        if touches.first!.tapCount == 2 {
            doubleTapAction?(touches.first!, event)
        }
    }
}

struct TextView: View {
    @StateObject var cellContentController: CellContentController
    
    var body: some View {
        Text(cellContentController.cellContentModel.height?.description ?? "nil")
            .frame(height: cellContentController.cellContentModel.height, alignment: .top)
            .background(.green)
    }
    
}
  • After further experimentation I found the order in which the underling methods are called. It seems that preferredLayoutAttributesFitting() has the final and definitive say about cell size. It knows the current cell layout attributes and the layout attributes the cell wants to have after resizing itself which then gets sent to the cells apply() method. How would one animate the difference between those two layout attributes though?

Add a Comment

Replies

Did you ever get anywhere with this @Moritz_Brunner?

I do wonder if it's something to do with using UIHostingConfiguration. I had a quick go updating your example to use UIKit for demo purposes but didn't get very far.