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 asfinalLayoutAttributesForDisappearingItem(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])
orsnapshot.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)
}
}