From 8204f3e093d8446d9bb3f0d5d5f8d5613390b7d0 Mon Sep 17 00:00:00 2001 From: Michael Hughes Date: Fri, 18 Sep 2026 10:52:25 -0400 Subject: [PATCH] Use section and item keys for diffable collection identity --- .../AnyHashableConfig.swift | 5 ++ .../CollectionView/AnyCollectionSection.swift | 4 +- ...nalCollectionData+DiffableDataSource.swift | 18 ++-- ...aSourceFunctionalCollectionDataTests.swift | 82 +++++++++++++++++++ 4 files changed, 101 insertions(+), 8 deletions(-) diff --git a/Sources/FunctionalTableData/AnyHashableConfig.swift b/Sources/FunctionalTableData/AnyHashableConfig.swift index 50403c1..a428198 100644 --- a/Sources/FunctionalTableData/AnyHashableConfig.swift +++ b/Sources/FunctionalTableData/AnyHashableConfig.swift @@ -28,6 +28,11 @@ public struct AnyHashableConfig: Hashable, HashableCellConfigType { } } + init(_ base: CellConfigType, itemPath: ItemPath) { + self.base = base + self.hashable = AnyHashable(itemPath) + } + public init(_ base: HashableCellConfigType) { self.base = base self.hashable = base.hashable diff --git a/Sources/FunctionalTableData/CollectionView/AnyCollectionSection.swift b/Sources/FunctionalTableData/CollectionView/AnyCollectionSection.swift index fc3c079..00465b6 100644 --- a/Sources/FunctionalTableData/CollectionView/AnyCollectionSection.swift +++ b/Sources/FunctionalTableData/CollectionView/AnyCollectionSection.swift @@ -15,14 +15,14 @@ struct AnyCollectionSection: Hashable { private var impl: CollectionSection public var key: String { impl.key } - public var items: [HashableCellConfigType] + public var items: [AnyHashableConfig] public var supplementaries: [CollectionSupplementaryItemConfig] { get { impl.supplementaries } set { impl.supplementaries = newValue } } public init(_ section: CollectionSection) { - items = section.items.map { AnyHashableConfig($0, sectionKey: section.key) } + items = section.items.map { AnyHashableConfig($0, itemPath: ItemPath(sectionKey: section.key, itemKey: $0.key)) } impl = section } diff --git a/Sources/FunctionalTableData/CollectionView/FunctionalCollectionData+DiffableDataSource.swift b/Sources/FunctionalTableData/CollectionView/FunctionalCollectionData+DiffableDataSource.swift index 4068c68..4b06dde 100644 --- a/Sources/FunctionalTableData/CollectionView/FunctionalCollectionData+DiffableDataSource.swift +++ b/Sources/FunctionalTableData/CollectionView/FunctionalCollectionData+DiffableDataSource.swift @@ -19,6 +19,7 @@ final class DiffableDataSourceFunctionalCollectionDataDiffer: FunctionalCollecti guard let collectionView = collectionView else { return } let dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, cellConfig in let section = self.data.sections[indexPath.section] + guard let cellConfig = section.items.first(where: { $0.key == cellConfig.key }) else { return nil } let cell = cellConfig.dequeueCell(from: collectionView, at: indexPath) let accessibilityIdentifier = ItemPath(sectionKey: section.key, itemKey: cellConfig.key).description cellConfig.accessibility.with(defaultIdentifier: accessibilityIdentifier).apply(to: cell) @@ -63,18 +64,23 @@ final class DiffableDataSourceFunctionalCollectionDataDiffer: FunctionalCollecti let changeSet = CollectionSectionChangeSet(old: oldSections, new: localSections, visibleIndexPaths: indexPaths) data.sections = localSections - var snapshot = NSDiffableDataSourceSnapshot() - let sections = localSections.map { AnyCollectionSection($0) } - snapshot.appendSections(sections) - for newSection in sections { - snapshot.appendItems(newSection.items.map { AnyHashableConfig($0) }, toSection: newSection) - } var isFirstRender: Bool = false if let snapshot = dataSource?.snapshot(), snapshot.numberOfSections == 0 { isFirstRender = true } let shouldAnimate = animated && !isFirstRender NSException.catchAndHandle { + var snapshot = NSDiffableDataSourceSnapshot() + let sections = localSections.map { AnyCollectionSection($0) } + snapshot.appendSections(sections) + let oldItems = Dictionary(uniqueKeysWithValues: (self.dataSource?.snapshot().itemIdentifiers ?? []).map { ($0, $0) }) + for newSection in sections { + snapshot.appendItems(newSection.items, toSection: newSection) + snapshot.reloadItems(newSection.items.filter { item in + guard let oldItem = oldItems[item] else { return false } + return !item.isEqual(oldItem) || item.style != oldItem.style + }) + } self.dataSource?.apply(snapshot, animatingDifferences: shouldAnimate, completion: completion) } failure: { exception in if exception.name == NSExceptionName.internalInconsistencyException { diff --git a/Tests/FunctionalTableDataTests/DiffableDataSourceFunctionalCollectionDataTests.swift b/Tests/FunctionalTableDataTests/DiffableDataSourceFunctionalCollectionDataTests.swift index 0a14423..a51850f 100644 --- a/Tests/FunctionalTableDataTests/DiffableDataSourceFunctionalCollectionDataTests.swift +++ b/Tests/FunctionalTableDataTests/DiffableDataSourceFunctionalCollectionDataTests.swift @@ -10,6 +10,80 @@ import XCTest @testable import FunctionalTableData class DiffableDataSourceFunctionalCollectionDataTests: XCTestCase { + func testEqualCellStatesHaveDistinctItemIdentities() { + let (differ, collectionView) = makeDiffer() + let sections = [ + SimpleCollectionSection(key: "first", items: [cell("one"), cell("two")]), + SimpleCollectionSection(key: "second", items: [cell("one")]) + ] + + render(sections, with: differ) + + let snapshot = differ.dataSource.snapshot() + XCTAssertEqual(snapshot.numberOfItems, 3) + XCTAssertEqual(Set(snapshot.itemIdentifiers.map { $0.hashable }), Set([ + AnyHashable(ItemPath(sectionKey: "first", itemKey: "one")), + AnyHashable(ItemPath(sectionKey: "first", itemKey: "two")), + AnyHashable(ItemPath(sectionKey: "second", itemKey: "one")) + ])) + XCTAssertEqual(collectionView.numberOfItems(inSection: 0), 2) + XCTAssertEqual(collectionView.numberOfItems(inSection: 1), 1) + } + + func testContentChangesReloadWithoutChangingIdentity() { + let (differ, collectionView) = makeDiffer() + render([SimpleCollectionSection(key: "section", items: [cell("one"), cell("two")])], with: differ) + let originalItems = differ.dataSource.snapshot().itemIdentifiers + render([SimpleCollectionSection(key: "section", items: [cell("one", state: "changed"), cell("two")])], with: differ) + + let snapshot = differ.dataSource.snapshot() + XCTAssertEqual(snapshot.itemIdentifiers, originalItems) + collectionView.layoutIfNeeded() + let visibleCell = collectionView.cellForItem(at: IndexPath(item: 0, section: 0)) as? CellConfig.CollectionCellType + XCTAssertEqual(visibleCell?.view.text, "changed") + } + + func testEqualStateItemsCanMoveBeInsertedAndBeDeleted() { + let (differ, collectionView) = makeDiffer() + render([SimpleCollectionSection(key: "section", items: [cell("one"), cell("two"), cell("three")])], with: differ) + render([SimpleCollectionSection(key: "section", items: [cell("three"), cell("one"), cell("four")])], with: differ) + + XCTAssertEqual(differ.dataSource.snapshot().itemIdentifiers.map { $0.key }, ["three", "one", "four"]) + XCTAssertEqual(collectionView.numberOfItems(inSection: 0), 3) + } + + func testDuplicateKeysAreHandledDuringSnapshotConstruction() { + let invalidSections: [[CollectionSection]] = [ + [SimpleCollectionSection(key: "section", items: [cell("one"), cell("one")])], + [ + SimpleCollectionSection(key: "section", items: [cell("one")]), + SimpleCollectionSection(key: "section", items: [cell("two")]) + ] + ] + for sections in invalidSections { + let (differ, _) = makeDiffer() + differ.renderAndDiff(sections, animated: false, completion: nil) + XCTAssertEqual(differ.dataSource.snapshot().numberOfItems, 0) + } + } + + private func makeDiffer() -> (DiffableDataSourceFunctionalCollectionDataDiffer, UICollectionView) { + let differ = DiffableDataSourceFunctionalCollectionDataDiffer(name: "test", data: CollectionData()) + let collectionView = UICollectionView(frame: CGRect(x: 0, y: 0, width: 320, height: 480), collectionViewLayout: UICollectionViewFlowLayout()) + differ.collectionView = collectionView + return (differ, collectionView) + } + + private func cell(_ key: String, state: String = "same") -> CellConfig { + return CellConfig(key: key, state: state) + } + + private func render(_ sections: [CollectionSection], with differ: DiffableDataSourceFunctionalCollectionDataDiffer) { + let completed = expectation(description: "render completed") + differ.renderAndDiff(sections, animated: false) { completed.fulfill() } + waitForExpectations(timeout: 1) + } + func testKeyPathFromRowKey() { let collectionData = FunctionalCollectionData(name: nil, diffingStrategy: .diffableDataSource) let collectionView = UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewFlowLayout()) @@ -74,3 +148,11 @@ class DiffableDataSourceFunctionalCollectionDataTests: XCTestCase { } } + +private final class DiffableLabelView: UILabel, ConfigurableView { + func configure(_ state: String) { + text = state + } + + func prepareForReuse() {} +}