Post

Replies

Boosts

Views

Activity

How to animate cell size change when using UIHostingConfiguration
I made an UICollectionView that uses a CompositionalLayout, DiffableDataSource, the new UIHostingConfiguration and an ObservableObject. You can resize cells by double tapping them (see gif + example code). The resizing is triggered by updating the ObservableObject. Now I want to set an animation mainly to animate the shrinking as well as the size change of the last cell but I can't seem to find the right way to do so. By default there is no animation when shrinking and on the last cell: While there are many guides on how to create dynamically resizeable cells they all either use changes in Autolayout constraints (which can't be used with UIHostingConfiguration - correct me if I'm wrong) or they add an optional part to the view inside UIHostingConfiguration with "if some condition {MyAdditionalView(); .transition(someTransition)}" instead of changing its frame. I also tried these different ways to manipulate the animation but with no success: setting an animation modifier .animation(): this makes a fading animation appear and the view inside the hosting configuration starts to jump (It seems to be anchored in the centre of the cell). setting a transition modifier .transition(): this has no effect on the animation. subclassing UICompositionalLayout and overriding initialLayoutAttributesForAppearingItem(at itemIndexPath: IndexPath) as well as finalLayoutAttributesForDisappearingItem(at itemIndexPath: IndexPath): this has no effect on the animation. subclassing UICollectionViewCell and overriding apply(_ layoutAttributes: UICollectionViewLayoutAttributes): this only effects the orange cell-background on initial appearance. using snapshot.reloadItems([ItemIdentifier]) or snapshot.reconfigureItems([ItemIdentifier]): they have no effect on the animation and lead to inconsistent behaviour when combined with ObservableObject. setting collectionView.selfSizingInvalidation = .enabledIncludingConstraints: no effect. Setting it to .disabled stops the orange cells from resizing altogether. Is there a way to customize the size change animation of a cell using UIHostingConfiguration and ObservableObject? Code without animation: 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 // <- this triggers the resizing }         collectionView.addGestureRecognizer(doubleTapGestureRecognizer)     } } // - MARK: DataSource extension CollectionViewController {     func createDataSource() {         let cellRegistration = UICollectionView.CellRegistration<C, 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)             .background(.green)     } }
1
1
2.3k
Jul ’22
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) } }
2
1
3.7k
Jul ’22