diff --git a/Example/Sources/List/ListViewController.swift b/Example/Sources/List/ListViewController.swift index c198ef3..78f2dcd 100644 --- a/Example/Sources/List/ListViewController.swift +++ b/Example/Sources/List/ListViewController.swift @@ -81,9 +81,9 @@ final class ListViewController: ExampleViewController, CellEventCoordinator { private func makeViewModel() -> CollectionViewModel { // Create People Section - let peopleCellViewModels = self.model.people.map { + let peopleCellViewModels = self.model.people.enumerated().map { index, person in let menuConfig = UIContextMenuConfiguration.configFor( - itemId: $0.id, + itemId: person.id, favoriteAction: { [unowned self] in self.toggleFavorite(id: $0) }, @@ -92,9 +92,12 @@ final class ListViewController: ExampleViewController, CellEventCoordinator { } ) + let children = makeViewModel(for: person.subPeople) + return PersonCellViewModelList( - person: $0, - contextMenuConfiguration: menuConfig + person: person, + contextMenuConfiguration: menuConfig, + children: children ).eraseToAnyViewModel() } let peopleHeader = HeaderViewModel(title: "People", style: .small) @@ -134,6 +137,16 @@ final class ListViewController: ExampleViewController, CellEventCoordinator { // Create final view model return CollectionViewModel(id: "list_view", sections: [peopleSection, colorSection]) } + + private func makeViewModel(for people: [PersonModel]) -> [AnyCellViewModel] { + let children: [AnyCellViewModel] = people.map { + PersonCellViewModelList(person: $0, + contextMenuConfiguration: nil, + children: makeViewModel(for: $0.subPeople)).eraseToAnyViewModel() + } + + return children + } } extension ListViewController: UIScrollViewDelegate { diff --git a/Example/Sources/List/PersonCellViewModelList.swift b/Example/Sources/List/PersonCellViewModelList.swift index 6003d24..680e5b0 100644 --- a/Example/Sources/List/PersonCellViewModelList.swift +++ b/Example/Sources/List/PersonCellViewModelList.swift @@ -23,6 +23,8 @@ struct PersonCellViewModelList: CellViewModel { let contextMenuConfiguration: UIContextMenuConfiguration? + let children: [AnyCellViewModel] + func configure(cell: UICollectionViewListCell) { var contentConfiguration = UIListContentConfiguration.subtitleCell() contentConfiguration.text = self.person.name @@ -35,7 +37,7 @@ struct PersonCellViewModelList: CellViewModel { let flagEmoji = UICellAccessory.customView( configuration: .init(customView: label, placement: .leading()) ) - var accessories = [flagEmoji, .disclosureIndicator()] + var accessories = [flagEmoji, .disclosureIndicator(), .outlineDisclosure()] if self.person.isFavorite { let imageView = UIImageView(image: UIImage(systemName: "star.fill")) diff --git a/Example/Sources/PersonModel/PersonModel.swift b/Example/Sources/PersonModel/PersonModel.swift index 071eb8e..8f86bc8 100644 --- a/Example/Sources/PersonModel/PersonModel.swift +++ b/Example/Sources/PersonModel/PersonModel.swift @@ -19,6 +19,7 @@ struct PersonModel: Hashable { let birthdate: Date let nationality: String var isFavorite = false + var subPeople: [PersonModel] = [] var birthDateText: String { self.birthdate.formatted(date: .long, time: .omitted) @@ -45,7 +46,13 @@ extension Date { extension PersonModel { static func makePeople() -> [PersonModel] { [ - PersonModel(name: "Noam Chomsky", birthdate: Date(year: 1_928, month: 12, day: 7), nationality: "πŸ‡ΊπŸ‡Έ"), + PersonModel(name: "Noam Chomsky", birthdate: Date(year: 1_928, month: 12, day: 7), nationality: "πŸ‡ΊπŸ‡Έ", subPeople: [ + .init(name: "Steve Jobs", birthdate: Date(year: 1955, month: 2, day: 24), nationality: "πŸ‡ΊπŸ‡Έ", subPeople: [ + .init(name: "Another Steve Jobs", birthdate: Date(year: 1955, month: 2, day: 24), nationality: "πŸ‡ΊπŸ‡Έ", subPeople: [ + .init(name: "Yet Another Steve Jobs", birthdate: Date(year: 1955, month: 2, day: 24), nationality: "πŸ‡ΊπŸ‡Έ") + ]) + ]) + ]), PersonModel(name: "Emma Goldman", birthdate: Date(year: 1_869, month: 6, day: 27), nationality: "πŸ‡·πŸ‡Ί"), PersonModel(name: "Mikhail Bakunin", birthdate: Date(year: 1_814, month: 5, day: 30), nationality: "πŸ‡·πŸ‡Ί"), PersonModel(name: "Ursula K. Le Guin", birthdate: Date(year: 1_929, month: 10, day: 21), nationality: "πŸ‡ΊπŸ‡Έ"), diff --git a/Sources/CellViewModel.swift b/Sources/CellViewModel.swift index d9f4684..b47c6a1 100644 --- a/Sources/CellViewModel.swift +++ b/Sources/CellViewModel.swift @@ -38,6 +38,10 @@ public protocol CellViewModel: DiffableViewModel, ViewRegistrationProvider { /// This corresponds to the delegate method `collectionView(_:contextMenuConfigurationForItemAt:point:)`. var contextMenuConfiguration: UIContextMenuConfiguration? { get } + /// Returns an array of children for the cell. + /// These children correspond to the items that have been appended to this item as part of a `NSDiffableDataSourceSectionSnapshot`. + var children: [AnyCellViewModel] { get } + /// Configures the provided cell for display in the collection. /// - Parameter cell: The cell to configure. @MainActor @@ -90,6 +94,9 @@ extension CellViewModel { /// Default implementation. Returns `true`. public var shouldHighlight: Bool { true } + /// Default implementation. Returns `[]`. + public var children: [AnyCellViewModel] { [] } + /// Default implementation. Returns `nil`. public var contextMenuConfiguration: UIContextMenuConfiguration? { nil } @@ -198,6 +205,9 @@ public struct AnyCellViewModel: CellViewModel { /// :nodoc: public var shouldHighlight: Bool { self._shouldHighlight } + /// :nodoc: + public var children: [AnyCellViewModel] { self._children } + /// :nodoc: public var contextMenuConfiguration: UIContextMenuConfiguration? { self._contextMenuConfiguration } @@ -251,6 +261,7 @@ public struct AnyCellViewModel: CellViewModel { private let _shouldDeselect: Bool private let _shouldHighlight: Bool private let _contextMenuConfiguration: UIContextMenuConfiguration? + private let _children: [AnyCellViewModel] private let _configure: @Sendable @MainActor (CellType) -> Void private let _didSelect: @Sendable @MainActor (CellEventCoordinator?) -> Void private let _didDeselect: @Sendable @MainActor (CellEventCoordinator?) -> Void @@ -276,6 +287,7 @@ public struct AnyCellViewModel: CellViewModel { self._shouldSelect = viewModel.shouldSelect self._shouldDeselect = viewModel.shouldDeselect self._shouldHighlight = viewModel.shouldHighlight + self._children = viewModel.children self._contextMenuConfiguration = viewModel.contextMenuConfiguration self._configure = { viewModel._configureGeneric(cell: $0) diff --git a/Sources/CollectionViewDriver.swift b/Sources/CollectionViewDriver.swift index e397365..0e23fe3 100644 --- a/Sources/CollectionViewDriver.swift +++ b/Sources/CollectionViewDriver.swift @@ -328,25 +328,25 @@ extension CollectionViewDriver: UICollectionViewDelegate { /// :nodoc: public func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool { - self.viewModel.cellViewModel(at: indexPath).shouldSelect + self.viewModel.cellViewModel(at: indexPath, in: collectionView).shouldSelect } /// :nodoc: public func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { - self.viewModel.cellViewModel(at: indexPath).didSelect(with: self._cellEventCoordinator) + self.viewModel.cellViewModel(at: indexPath, in: collectionView).didSelect(with: self._cellEventCoordinator) } /// :nodoc: public func collectionView(_ collectionView: UICollectionView, shouldDeselectItemAt indexPath: IndexPath) -> Bool { - self.viewModel.cellViewModel(at: indexPath).shouldDeselect + self.viewModel.cellViewModel(at: indexPath, in: collectionView).shouldDeselect } /// :nodoc: public func collectionView(_ collectionView: UICollectionView, didDeselectItemAt indexPath: IndexPath) { - self.viewModel.cellViewModel(at: indexPath).didDeselect(with: self._cellEventCoordinator) + self.viewModel.cellViewModel(at: indexPath, in: collectionView).didDeselect(with: self._cellEventCoordinator) } // MARK: Managing cell highlighting @@ -354,19 +354,19 @@ extension CollectionViewDriver: UICollectionViewDelegate { /// :nodoc: public func collectionView(_ collectionView: UICollectionView, shouldHighlightItemAt indexPath: IndexPath) -> Bool { - self.viewModel.cellViewModel(at: indexPath).shouldHighlight + self.viewModel.cellViewModel(at: indexPath, in: collectionView).shouldHighlight } /// :nodoc: public func collectionView(_ collectionView: UICollectionView, didHighlightItemAt indexPath: IndexPath) { - self.viewModel.cellViewModel(at: indexPath).didHighlight() + self.viewModel.cellViewModel(at: indexPath, in: collectionView).didHighlight() } /// :nodoc: public func collectionView(_ collectionView: UICollectionView, didUnhighlightItemAt indexPath: IndexPath) { - self.viewModel.cellViewModel(at: indexPath).didUnhighlight() + self.viewModel.cellViewModel(at: indexPath, in: collectionView).didUnhighlight() } // MARK: Managing context menus @@ -375,7 +375,7 @@ extension CollectionViewDriver: UICollectionViewDelegate { public func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { - self.viewModel.cellViewModel(at: indexPath).contextMenuConfiguration + self.viewModel.cellViewModel(at: indexPath, in: collectionView).contextMenuConfiguration } // MARK: Tracking the addition and removal of views @@ -384,7 +384,7 @@ extension CollectionViewDriver: UICollectionViewDelegate { public func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { - self.viewModel._safeCellViewModel(at: indexPath)?.willDisplay() + self.viewModel._safeCellViewModel(at: indexPath, in: collectionView)?.willDisplay() } /// :nodoc: @@ -399,7 +399,7 @@ extension CollectionViewDriver: UICollectionViewDelegate { public func collectionView(_ collectionView: UICollectionView, didEndDisplaying cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { - self.viewModel._safeCellViewModel(at: indexPath)?.didEndDisplaying() + self.viewModel._safeCellViewModel(at: indexPath, in: collectionView)?.didEndDisplaying() } /// :nodoc: diff --git a/Sources/CollectionViewModel.swift b/Sources/CollectionViewModel.swift index 1a11c8a..4e6ff7b 100644 --- a/Sources/CollectionViewModel.swift +++ b/Sources/CollectionViewModel.swift @@ -65,16 +65,19 @@ public struct CollectionViewModel: DiffableViewModel { // MARK: Accessing Cells - /// Returns the cell for the specified `id`. + /// Returns the cell for the specified `id` by traversing each section and all of the children of each cell. /// /// - Parameter id: The identifier for the cell. /// - Returns: The cell, if it exists. public func cellViewModel(for id: UniqueIdentifier) -> AnyCellViewModel? { for section in self.sections { - for cell in section.cells where cell.id == id { - return cell + for cell in section.cells { + if let foundViewModel = cellViewModel(in: cell, with: id) { + return foundViewModel + } } } + return nil } @@ -84,14 +87,42 @@ public struct CollectionViewModel: DiffableViewModel { /// - Returns: The cell at `indexPath`. /// /// - Precondition: The specified `indexPath` must be valid. - public func cellViewModel(at indexPath: IndexPath) -> AnyCellViewModel { + public func cellViewModel(at indexPath: IndexPath, in collectionView: UICollectionView) -> AnyCellViewModel { precondition(indexPath.section < self.count) let section = self.sectionViewModel(at: indexPath.section) let cells = section.cells precondition(indexPath.item < cells.count) - return cells[indexPath.item] + guard let diffableDataSource = collectionView.dataSource as? DiffableDataSource + else { + return cells[indexPath.item] + } + + let snapshot = diffableDataSource.snapshot(for: section.id) + let id = snapshot.visibleItems[indexPath.item] + + guard let cellViewModel = cellViewModel(for: id) + else { + return cells[indexPath.item] + } + + return cellViewModel + } + + /// Recursively traverse the children array of each child to locate a matching cell view model + private func cellViewModel(in viewModel: AnyCellViewModel, with id: UniqueIdentifier) -> AnyCellViewModel? { + if viewModel.id == id { + return viewModel + } + + for child in viewModel.children { + if let foundChildViewModel = cellViewModel(in: child, with: id) { + return foundChildViewModel + } + } + + return nil } // MARK: Accessing Supplementary Views @@ -168,12 +199,12 @@ public struct CollectionViewModel: DiffableViewModel { return self.sectionViewModel(at: index) } - func _safeCellViewModel(at indexPath: IndexPath) -> AnyCellViewModel? { + func _safeCellViewModel(at indexPath: IndexPath, in collectionView: UICollectionView) -> AnyCellViewModel? { guard let section = self._safeSectionViewModel(at: indexPath.section), indexPath.item < section.cells.count else { return nil } - return self.cellViewModel(at: indexPath) + return self.cellViewModel(at: indexPath, in: collectionView) } func _safeSupplementaryViewModel(ofKind kind: String, at indexPath: IndexPath) -> AnySupplementaryViewModel? { diff --git a/Sources/DiffableDataSource.swift b/Sources/DiffableDataSource.swift index 3828a53..abe0382 100644 --- a/Sources/DiffableDataSource.swift +++ b/Sources/DiffableDataSource.swift @@ -121,6 +121,60 @@ final class DiffableDataSource: UICollectionViewDiffableDataSource, animated: Bool, completion: SnapshotCompletion? + ) { + // Before applying a snapshot, first check if there's at least one cell that has children. + // If so, nesting is desired and we must use section snapshots, since UIKit only supports + // this behavior through the use of NSDiffableDataSourceSectionSnapshots. + // Otherwise, use a standard snapshot. + let childrenExist = destination.sections.first?.contains(where: \.children.isNotEmpty) ?? false + + if childrenExist { + _applySectionSnapshot(from: source, + to: destination, + withVisibleItems: visibleItemIdentifiers, + animated: animated, + completion: completion) + } + else { + _applyStandardSnapshot(from: source, + to: destination, + withVisibleItems: visibleItemIdentifiers, + animated: animated, + completion: completion) + } + } + + nonisolated private func _applySectionSnapshot( + from source: CollectionViewModel, + to destination: CollectionViewModel, + withVisibleItems visibleItemIdentifiers: Set, + animated: Bool, + completion: SnapshotCompletion? + ) { + // For each section, build the destination section snapshot. + destination.sections.forEach { + let destinationSectionSnapshot = DiffableSectionSnapshot(viewModel: $0) + + // Apply the section snapshot. + // + // Swift 6 complains about 'call to main actor-isolated instance method' here. + // However, call this method from a background thread is valid according to the docs. + self.apply(destinationSectionSnapshot, to: $0.id, animatingDifferences: animated) { [weak self] in + self?._applySnapshotCompletion(source: source, + destination: destination, + completion) + } + } + + + } + + nonisolated private func _applyStandardSnapshot( + from source: CollectionViewModel, + to destination: CollectionViewModel, + withVisibleItems visibleItemIdentifiers: Set, + animated: Bool, + completion: SnapshotCompletion? ) { // Build initial destination snapshot, then make adjustments below. // This takes care of newly added items and newly added sections, @@ -157,49 +211,48 @@ final class DiffableDataSource: UICollectionViewDiffableDataSource + +extension DiffableSectionSnapshot { + init(viewModel: SectionViewModel) { + self.init() + + let allCellIdentifiers = viewModel.cells.map(\.id) + self.append(allCellIdentifiers) + + viewModel.cells.forEach { cell in + appendAllChildren(from: cell, to: cell.id) + } + } + + private mutating func appendAllChildren(from cell: AnyCellViewModel, to parentId: AnyHashable) { + let childIdentifiers = cell.children.map(\.id) + + guard childIdentifiers.isNotEmpty else { return } + + self.append(childIdentifiers, to: parentId) + + for child in cell.children { + appendAllChildren(from: child, to: child.id) + } + } +}