From 1d535d5c89727383e74bd6799a523dfa01f00aae Mon Sep 17 00:00:00 2001 From: Daniel Juarez Date: Wed, 5 Jul 2023 18:03:04 -0600 Subject: [PATCH] Create datasource for cells to make lazy-loading possible for collections (#216) * Create datasource for cells to make lazy-loading possible for collections * Add collection test * Changelog --- CHANGELOG.md | 4 + ReactiveLists.podspec | 2 +- ReactiveLists.xcodeproj/project.pbxproj | 19 ++- .../CollectionCellViewModelDataSource.swift | 126 +++++++++++++++ Sources/CollectionViewDriver.swift | 73 ++++++++- Sources/CollectionViewModel.swift | 51 +++++- Sources/Diffing.swift | 151 ++++++++++++++++++ Sources/UICollectionView+Extensions.swift | 8 +- .../CollectionViewLazyDiffingTests.swift | 101 ++++++++++++ 9 files changed, 520 insertions(+), 15 deletions(-) create mode 100644 Sources/CollectionCellViewModelDataSource.swift create mode 100644 Tests/CollectionView/CollectionViewLazyDiffingTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 7344361..5414bc1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The changelog for `ReactiveLists`. Also see the [releases](https://github.com/pl NEXT ---- +0.8.1.8 +----- +- Added support for CollectionView cells lazy-loading. + 0.8.1 ----- - Added support for deselect and willDispaly cells. diff --git a/ReactiveLists.podspec b/ReactiveLists.podspec index 64e6060..a64a3f1 100644 --- a/ReactiveLists.podspec +++ b/ReactiveLists.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = "ReactiveLists" - s.version = "0.8.1.beta.7" + s.version = "0.8.1.beta.8" s.summary = "React-like API for UITableView and UICollectionView" s.homepage = "https://github.com/plangrid/ReactiveLists" diff --git a/ReactiveLists.xcodeproj/project.pbxproj b/ReactiveLists.xcodeproj/project.pbxproj index 03fd5b0..e638599 100644 --- a/ReactiveLists.xcodeproj/project.pbxproj +++ b/ReactiveLists.xcodeproj/project.pbxproj @@ -48,6 +48,8 @@ 357B96DC2019374E0000443F /* CollectionToolCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 357B96D9201934C50000443F /* CollectionToolCell.xib */; }; 357B96E9201956760000443F /* CollectionViewHeaderView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 357B96E8201956760000443F /* CollectionViewHeaderView.xib */; }; 357B96EA2019599C0000443F /* CollectionViewHeaderView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 357B96E8201956760000443F /* CollectionViewHeaderView.xib */; }; + 39A211882A5342EE00288547 /* CollectionCellViewModelDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39A211872A5342EE00288547 /* CollectionCellViewModelDataSource.swift */; }; + 39D64C592A54E4DB0071A7F3 /* CollectionViewLazyDiffingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39D64C582A54E4DB0071A7F3 /* CollectionViewLazyDiffingTests.swift */; }; 7C24B1408A8B3A147C254BCA /* Pods_ReactiveLists.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 978763204EC113AFD1F7EB54 /* Pods_ReactiveLists.framework */; }; A8069332250046AD0036CA11 /* TableViewLazyDiffingTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8069330250042700036CA11 /* TableViewLazyDiffingTest.swift */; }; A8D93CAA24FD9BDF00459EBB /* TableCellViewModelDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8D93CA924FD9BDF00459EBB /* TableCellViewModelDataSource.swift */; }; @@ -132,6 +134,8 @@ 351B0BB420168D2E0034569D /* CollectionViewCells.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CollectionViewCells.swift; sourceTree = ""; }; 357B96D9201934C50000443F /* CollectionToolCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = CollectionToolCell.xib; sourceTree = ""; }; 357B96E8201956760000443F /* CollectionViewHeaderView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = CollectionViewHeaderView.xib; sourceTree = ""; }; + 39A211872A5342EE00288547 /* CollectionCellViewModelDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionCellViewModelDataSource.swift; sourceTree = ""; }; + 39D64C582A54E4DB0071A7F3 /* CollectionViewLazyDiffingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionViewLazyDiffingTests.swift; sourceTree = ""; }; 42A9724B45F9E14DD1B210D1 /* Pods-ReactiveLists.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ReactiveLists.release.xcconfig"; path = "Pods/Target Support Files/Pods-ReactiveLists/Pods-ReactiveLists.release.xcconfig"; sourceTree = ""; }; 45132CB591F9F96746AF8E96 /* Pods-ReactiveListsTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ReactiveListsTests.debug.xcconfig"; path = "Pods/Target Support Files/Pods-ReactiveListsTests/Pods-ReactiveListsTests.debug.xcconfig"; sourceTree = ""; }; 932473A2DAECE0F923C4B570 /* Pods_ReactiveListsTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_ReactiveListsTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -226,6 +230,7 @@ 257A97DB2017AA3500164403 /* CollectionViewMocks.swift */, 257A97CA2017A80B00164403 /* CollectionViewModelTests.swift */, 257A97BC2017A5AA00164403 /* TestCollectionViewModels.swift */, + 39D64C582A54E4DB0071A7F3 /* CollectionViewLazyDiffingTests.swift */, ); path = CollectionView; sourceTree = ""; @@ -279,6 +284,7 @@ 32753F8C201BB8310084DCB1 /* UICollectionView+Extensions.swift */, 32753F8E201BB8470084DCB1 /* UITableView+Extensions.swift */, 32E7A1F7201BADE800B90EBC /* ViewRegistrationInfo.swift */, + 39A211872A5342EE00288547 /* CollectionCellViewModelDataSource.swift */, ); path = Sources; sourceTree = ""; @@ -395,6 +401,7 @@ TargetAttributes = { 2576640A1F29075C00C037E3 = { CreatedOnToolsVersion = 8.3.3; + DevelopmentTeam = 29VP5K5AJ7; LastSwiftMigration = 1000; ProvisioningStyle = Automatic; }; @@ -612,6 +619,7 @@ 32E7A1F8201BADE800B90EBC /* ViewRegistrationInfo.swift in Sources */, 258E31B11F0D8D9C00D6F324 /* CollectionViewDriver.swift in Sources */, 258E31B41F0D8D9C00D6F324 /* TableViewModel.swift in Sources */, + 39A211882A5342EE00288547 /* CollectionCellViewModelDataSource.swift in Sources */, 258E31D31F0D8F3100D6F324 /* AccessibilityFormats.swift in Sources */, 32753F8F201BB8470084DCB1 /* UITableView+Extensions.swift in Sources */, A8D93CAA24FD9BDF00459EBB /* TableCellViewModelDataSource.swift in Sources */, @@ -631,6 +639,7 @@ 257A97D82017A82F00164403 /* CollectionViewDriverTests.swift in Sources */, 257A97C02017A5D300164403 /* TestCollectionViewModels.swift in Sources */, 257A97D92017A82F00164403 /* CollectionViewModelTests.swift in Sources */, + 39D64C592A54E4DB0071A7F3 /* CollectionViewLazyDiffingTests.swift in Sources */, 25B1B0B920195F1C0036545F /* CollectionViewDriverDiffingTests.swift in Sources */, 257A97DA2017A83400164403 /* XCTest+Parameterized.swift in Sources */, 257A97D42017A82900164403 /* TableViewMocks.swift in Sources */, @@ -685,7 +694,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = 29VP5K5AJ7; INFOPLIST_FILE = "$(SRCROOT)/Example/Info.plist"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = com.plangrid.ReactiveListsExample; @@ -701,7 +710,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = 29VP5K5AJ7; INFOPLIST_FILE = "$(SRCROOT)/Example/Info.plist"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = com.plangrid.ReactiveListsExample; @@ -881,7 +890,8 @@ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = ""; CODE_SIGN_STYLE = Manual; DEVELOPMENT_TEAM = ""; - INFOPLIST_FILE = Tests/Info.plist; + INFOPLIST_FILE = "$(SRCROOT)/Tests/Info.plist"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = com.plangrid.ReactiveListsTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -898,7 +908,8 @@ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = ""; CODE_SIGN_STYLE = Manual; DEVELOPMENT_TEAM = ""; - INFOPLIST_FILE = Tests/Info.plist; + INFOPLIST_FILE = "$(SRCROOT)/Tests/Info.plist"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = com.plangrid.ReactiveListsTests; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/Sources/CollectionCellViewModelDataSource.swift b/Sources/CollectionCellViewModelDataSource.swift new file mode 100644 index 0000000..25dfeab --- /dev/null +++ b/Sources/CollectionCellViewModelDataSource.swift @@ -0,0 +1,126 @@ +// +// PlanGrid +// https://www.plangrid.com +// https://medium.com/plangrid-technology +// +// Documentation +// https://plangrid.github.io/ReactiveLists +// +// GitHub +// https://github.com/plangrid/ReactiveLists +// +// License +// Copyright © 2018-present PlanGrid, Inc. +// Released under an MIT license: https://opensource.org/licenses/MIT +// + +import DifferenceKit +import Foundation + +/// Protocol for providing `CollectionViewModel`s to a `CollectionSectionViewModel` +/// +/// It is itself the `Collection` of `CollectionCellViewModel` and +/// also provides hooks for pre-fetching data +/// +/// - Note: `[TableCellViewModel]` has a default implementation +public protocol CollectionCellViewModelDataSourceProtocol: RandomAccessCollection where Element == CollectionCellViewModel, Index == Int { + + /// Called by the equivalent `UITableViewDataSourcePrefetching` method + /// - Parameter indices: The indices in the section, for which to prefetch the models + func prefetchRowsAt(indices: S) where S.Element == Int + + /// Called by the equivalent `UITableViewDataSourcePrefetching` method + /// - Parameter indices: The indices in the section, for which to cancel prefetchign the models + func cancelPrefetchingRowsAt(indices: S) where S.Element == Int + + /// The `ViewRegistrationInfo` for the cells represented by this datasource + var cellRegistrationInfo: [ViewRegistrationInfo] { get } +} + +/// The concrete data source that wraps a provided `TableCellViewModelDataSourceProtocol` implementation +public struct CollectionCellViewModelDataSource: RandomAccessCollection { + + // MARK: `CollectionCellViewModelDataSourceProtocol` wrapper blocks for type erasure + + /// :nodoc: + private let _subscriptBlock: (Int) -> CollectionCellViewModel + + /// :nodoc: + private let _prefetchBlock: (AnySequence) -> Void + + /// :nodoc: + private let _prefetchCancelBlock: (AnySequence) -> Void + + /// Initializes the `CollectionCellViewModelDataSource` with the provided `CollectionCellViewModelDataSourceProtocol` implementation + public init(_ dataSource: DataSource) { + self.init(dataSource, cellRegistrationInfo: dataSource.cellRegistrationInfo) + } + + /// Used internally by the public init and during diffing + /// when cached ``ViewRegistrationInfo` is available + init(_ dataSource: DataSource, cellRegistrationInfo: [ViewRegistrationInfo]) { + self._prefetchBlock = dataSource.prefetchRowsAt + self._prefetchCancelBlock = dataSource.cancelPrefetchingRowsAt + self._subscriptBlock = { dataSource[$0] } + self.startIndex = dataSource.startIndex + self.endIndex = dataSource.endIndex + self.cellRegistrationInfo = cellRegistrationInfo + } + + // MARK: - Protocol Implementation + + /// :nodoc: + public let cellRegistrationInfo: [ViewRegistrationInfo] + + /// :nodoc: + func prefetchRowsAt(indices: S) where S.Element == Int { + self._prefetchBlock(AnySequence(indices)) + } + + /// :nodoc: + func cancelPrefetchingRowsAt(indices: S) where S.Element == Int { self._prefetchCancelBlock(AnySequence(indices)) } + + /// :nodoc: + public typealias Element = CollectionCellViewModel + + /// :nodoc: + public typealias Index = Int + + /// :nodoc: + public subscript(position: Int) -> CollectionCellViewModel { + self._subscriptBlock(position) + } + + /// :nodoc: + public let startIndex: Int + + /// :nodoc: + public let endIndex: Int +} + +extension Array: CollectionCellViewModelDataSourceProtocol where Element == CollectionCellViewModel { + + /// :nodoc: + public func prefetchRowsAt(indices: S) where S.Element == Int {} + + /// :nodoc: + public func cancelPrefetchingRowsAt(indices: S) where S.Element == Int {} + + /// :nodoc: + public var cellRegistrationInfo: [ViewRegistrationInfo] { + self.map { + $0.registrationInfo + } + } +} + +//extension Array where Element == IndexPath {j +// +// /// Helper that transforms `[IndexPath]` to sequence of pairs of sections and row sequences +// func indicesBySection() -> AnySequence<(Int, AnySequence)> { +// let indexPathsBySection = [Int: [IndexPath]](grouping: self) { $0.section } +// return AnySequence(indexPathsBySection.lazy.map { section, indexPaths in +// return (section, AnySequence(indexPaths.lazy.map { $0.row })) +// }) +// } +//} diff --git a/Sources/CollectionViewDriver.swift b/Sources/CollectionViewDriver.swift index 38fb5ba..55d652e 100644 --- a/Sources/CollectionViewDriver.swift +++ b/Sources/CollectionViewDriver.swift @@ -55,6 +55,8 @@ public class CollectionViewDriver: NSObject { private var _shouldDeselectUponSelection: Bool private let _automaticDiffingEnabled: Bool + + private let useDataSource: Bool // MARK: Initialization @@ -72,14 +74,17 @@ public class CollectionViewDriver: NSObject { collectionView: UICollectionView, collectionViewModel: CollectionViewModel? = nil, shouldDeselectUponSelection: Bool = true, + useDataSource: Bool = false, automaticDiffingEnabled: Bool = true) { self._collectionViewModel = collectionViewModel self.collectionView = collectionView self._automaticDiffingEnabled = automaticDiffingEnabled + self.useDataSource = useDataSource self._shouldDeselectUponSelection = shouldDeselectUponSelection super.init() collectionView.dataSource = self collectionView.delegate = self + collectionView.prefetchDataSource = self self._updateCollectionViewModel(from: nil, to: collectionViewModel) } @@ -154,14 +159,31 @@ public class CollectionViewDriver: NSObject { guard let newModel = newModel else { return } if self._automaticDiffingEnabled { - - let old: [CollectionSectionViewModel] = oldModel?.sectionModels ?? [] - let changeset = StagedChangeset(source: old, target: newModel.sectionModels) - if changeset.isEmpty { - self._collectionViewModel = newModel + if self.useDataSource { + let visibleIndexPaths = self.collectionView.indexPathsForVisibleItems + let old: [DiffableCollectionSectionViewModel] = oldModel?.sectionModelsForDiffing(inVisibleIndexPaths: visibleIndexPaths) ?? [] + let changeset = StagedChangeset<[DiffableCollectionSectionViewModel]>( + source: old, + target: newModel.sectionModelsForDiffing(inVisibleIndexPaths: visibleIndexPaths) + ) + + if changeset.isEmpty { + self._collectionViewModel = newModel + } else { + self.collectionView.reload(using: changeset) { + self._collectionViewModel = $0.makeCollectionViewModel() + } + self._collectionViewModel = newModel + } } else { - self.collectionView.reload(using: changeset) { - self._collectionViewModel = CollectionViewModel(sectionModels: $0) + let old: [CollectionSectionViewModel] = oldModel?.sectionModels ?? [] + let changeset = StagedChangeset(source: old, target: newModel.sectionModels) + if changeset.isEmpty { + self._collectionViewModel = newModel + } else { + self.collectionView.reload(using: changeset) { + self._collectionViewModel = CollectionViewModel(sectionModels: $0) + } } } self.refreshViews() @@ -195,6 +217,9 @@ extension CollectionViewDriver: UICollectionViewDataSource { /// :nodoc: public func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { guard let sectionModel = self.collectionViewModel?[ifExists: section] else { return 0 } + if let datasource = sectionModel.cellViewModelDataSource { + return datasource.count + } return sectionModel.cellViewModels.count } @@ -226,6 +251,40 @@ extension CollectionViewDriver: UICollectionViewDataSource { } } +extension CollectionViewDriver: UICollectionViewDataSourcePrefetching { + + private func _enumerateCellDataSourcesForPrefetch( + indexPaths: [IndexPath], + enumerationBlock: (CollectionCellViewModelDataSource, AnySequence) -> Void + ) { + guard let sectionModels = self.collectionViewModel?.sectionModels else { return } + // if this is called during a batch update, sections can shift + // around, which can lead to accessing a bad section + let indexIsValid = sectionModels.indices.contains + for (section, indices) in indexPaths.indicesBySection() where indexIsValid(section) { + guard let dataSource = sectionModels[section].cellViewModelDataSource else { return } + enumerationBlock(dataSource, indices) + } + } + + + public func collectionView(_ collectionView: UICollectionView, prefetchItemsAt indexPaths: [IndexPath]) { + self._enumerateCellDataSourcesForPrefetch( + indexPaths: indexPaths + ) { datasource, indices in + datasource.prefetchRowsAt(indices: indices) + } + } + + public func collectionView(_ collectionView: UICollectionView, cancelPrefetchingForItemsAt indexPaths: [IndexPath]) { + self._enumerateCellDataSourcesForPrefetch( + indexPaths: indexPaths + ) { datasource, indices in + datasource.cancelPrefetchingRowsAt(indices: indices) + } + } +} + extension CollectionViewDriver: UICollectionViewDelegate { /// :nodoc: diff --git a/Sources/CollectionViewModel.swift b/Sources/CollectionViewModel.swift index 03e0b5f..bec1fae 100644 --- a/Sources/CollectionViewModel.swift +++ b/Sources/CollectionViewModel.swift @@ -37,6 +37,9 @@ public protocol CollectionCellViewModel: ReusableCellViewModelProtocol, Diffable /// in the cell model and return the updated cell. /// - Parameter cell: the cell which's content need to be updated. func applyViewModelToCell(_ cell: UICollectionViewCell) + + /// Invoke when a cell will be displayed + func willDisplay(cell: UICollectionViewCell) } /// Default implementations for `CollectionCellViewModel`. @@ -50,6 +53,9 @@ extension CollectionCellViewModel { /// Default implementation, returns `nil`. public var didDeselect: DidDeselectClosure? { return nil } + + /// Default implementation, returns `nil`. + public func willDisplay(cell: UICollectionViewCell) { } } /// A `FlowLayoutCollectionCellViewModel` is a `CollectionCellViewModel` that will be placed in its @@ -132,8 +138,29 @@ public struct CollectionViewModel { /// /// - Parameter indexPath: the index path for the cell that is being retrieved public subscript(ifExists indexPath: IndexPath) -> CollectionCellViewModel? { - guard let section = self[ifExists: indexPath.section], section.cellViewModels.count > indexPath.item else { return nil } - return section.cellViewModels[indexPath.item] + guard let section = self[ifExists: indexPath.section] else { return nil } + + if let dataSource = section.cellViewModelDataSource { + guard indexPath.count >= 2, // In rare cases, we've seen UIKit give us a bad IndexPath + dataSource.count > indexPath.row else { return nil } + return dataSource[indexPath.row] + } else { + guard section.cellViewModels.count > indexPath.item else { return nil } + return section.cellViewModels[indexPath.item] + } + } + + /// A view of `TableSectionViewModel` used for diffing + func sectionModelsForDiffing(inVisibleIndexPaths visibleIndexPaths: [IndexPath]) -> [DiffableCollectionSectionViewModel] { + let visibleIndicesBySection = [Int: AnySequence]( + uniqueKeysWithValues: visibleIndexPaths.indicesBySection() + ).mapValues { Set($0) } + return zip(sectionModels, sectionModels.indices).map { sectionModel, section in + DiffableCollectionSectionViewModel( + sectionModel: sectionModel, + visibleIndices: visibleIndicesBySection[section, default: Set()] + ) + } } } @@ -145,6 +172,9 @@ public struct CollectionSectionViewModel: DiffableViewModel { /// Cells to be shown in this section. let cellViewModels: [CollectionCellViewModel] + /// Datasource for the cells to be shown in this section. + public let cellViewModelDataSource: CollectionCellViewModelDataSource? + /// View model for the header of this section. let headerViewModel: CollectionSupplementaryViewModel? @@ -163,6 +193,9 @@ public struct CollectionSectionViewModel: DiffableViewModel { /// Returns `true` if this section has zero cell view models, `false` otherwise. public var isEmpty: Bool { + if let cellDataSource = self.cellViewModelDataSource { + return cellDataSource.isEmpty + } return self.cellViewModels.isEmpty } @@ -177,10 +210,12 @@ public struct CollectionSectionViewModel: DiffableViewModel { public init( diffingKey: String?, cellViewModels: [CollectionCellViewModel], + cellViewModelDataSource: CollectionCellViewModelDataSource? = nil, headerViewModel: CollectionSupplementaryViewModel? = nil, footerViewModel: CollectionSupplementaryViewModel? = nil ) { self.cellViewModels = cellViewModels + self.cellViewModelDataSource = cellViewModelDataSource self.headerViewModel = headerViewModel self.footerViewModel = footerViewModel self.diffingKey = diffingKey ?? UUID().uuidString @@ -191,21 +226,33 @@ public struct CollectionSectionViewModel: DiffableViewModel { extension CollectionSectionViewModel: Collection { /// :nodoc: public subscript(position: Int) -> CollectionCellViewModel { + if let dataSource = self.cellViewModelDataSource { + return dataSource[position] + } return self.cellViewModels[position] } /// :nodoc: public func index(after i: Int) -> Int { + if let dataSource = self.cellViewModelDataSource { + return dataSource.index(after: i) + } return self.cellViewModels.index(after: i) } /// :nodoc: public var startIndex: Int { + if let dataSource = self.cellViewModelDataSource { + return dataSource.startIndex + } return self.cellViewModels.startIndex } /// :nodoc: public var endIndex: Int { + if let dataSource = self.cellViewModelDataSource { + return dataSource.endIndex + } return self.cellViewModels.endIndex } } diff --git a/Sources/Diffing.swift b/Sources/Diffing.swift index 80209c2..2baee55 100644 --- a/Sources/Diffing.swift +++ b/Sources/Diffing.swift @@ -197,6 +197,52 @@ private final class DiffableTableCellViewModelProxy: TableCellViewModel { } } +private final class DiffableCollectionCellViewModelProxy: CollectionCellViewModel { + + /// Modified again to remove the placeholder diffing key. The model getter is just fetching models that already exist on a section view model, I don't see + /// a large efficiency issue there. The placeholder keys on invisible cells can causes issues either way, whether static or dynamic, the diff is only truly accurate + /// if each model in the list (visible or not) gets to supply its diffing key + + /// When true, we allow diffing to access the real model's diffing key, eagerly loading it + private let _inVisibleBounds: Bool + + /// Closure to load the model + private let _modelGetter: () -> CollectionCellViewModel + + /// Lazy reference to the model + private lazy var model = self._modelGetter() + + init(inVisibleBounds: Bool, modelGetter: @escaping () -> CollectionCellViewModel) { + self._inVisibleBounds = inVisibleBounds + self._modelGetter = modelGetter + } + + /// Only accessed during display, so eager-loading is allowed here + var accessibilityFormat: CellAccessibilityFormat { + self.model.accessibilityFormat + } + + /// Only called during display, so eager-loading is allowed here + func applyViewModelToCell(_ cell: UICollectionViewCell) { + self.model.applyViewModelToCell(cell) + } + + /// Called before cell display + func willDisplay(cell: UICollectionViewCell) { + self.model.willDisplay(cell: cell) + } + + /// Only accessed during display, so eager-loading is allowed here + var registrationInfo: ViewRegistrationInfo { + self.model.registrationInfo + } + + /// Only allows accessing the real model's diffing key for visible models + var diffingKey: DiffingKey { + return self.model.diffingKey + } +} + /// A `DifferentiableSection` that ensures we only allow eager-loading /// of cells that are known to be on-screen, which avoids forcing /// a datasource to potentially load data that hasn't been loaded yet @@ -290,6 +336,100 @@ struct DiffableTableSectionViewModel: Collection, DifferentiableSection { } } +/// A `DifferentiableSection` that ensures we only allow eager-loading +/// of cells that are known to be on-screen, which avoids forcing +/// a datasource to potentially load data that hasn't been loaded yet +/// to create new cell models (expensive) +struct DiffableCollectionSectionViewModel: Collection, DifferentiableSection { + + /// Reference to the original `CollectionSectionViewModel` + let _sectionModel: CollectionSectionViewModel + + /// The set of indices in this section, for which we should allow + /// diffing to access, since these cells are visible (i.e. won't + /// eagerly load data that the data source may not have loaded) + private let _visibleIndices: Set + + /// Initializes a `DiffableTableSectionViewModel` with the + /// section model and visibile indices in this section + init(sectionModel: CollectionSectionViewModel, visibleIndices: Set) { + self._sectionModel = sectionModel + self._visibleIndices = visibleIndices + self.startIndex = sectionModel.startIndex + self.endIndex = sectionModel.endIndex + } + + // MARK: - Protocol Implementations + + /// :nodoc: + init(source: DiffableCollectionSectionViewModel, elements: C) where C.Element == AnyDiffableViewModel { + self._sectionModel = CollectionSectionViewModel( + diffingKey: source._sectionModel.diffingKey, + cellViewModels: [], + cellViewModelDataSource: CollectionCellViewModelDataSource( + // this will always be used for tables, and + // cell models have to be of type TableCellViewModel + //swiftlint:disable:next force_cast + elements.map { $0.model as! CollectionCellViewModel }, + // pass through the already-calculated cell registration + // info to avoid accidental eager loading of cell models + cellRegistrationInfo: source._sectionModel.cellViewModelDataSource?.cellRegistrationInfo ?? [] + ), + headerViewModel: source._sectionModel.headerViewModel, + footerViewModel: source._sectionModel.footerViewModel + ) + self._visibleIndices = source._visibleIndices + self.startIndex = source._sectionModel.startIndex + self.endIndex = source._sectionModel.endIndex + } + + /// :nodoc: + var diffingKey: DiffingKey { self._sectionModel.diffingKey } + + /// :nodoc: + var differenceIdentifier: String { _sectionModel.differenceIdentifier } + + /// :nodoc: + typealias Collection = Self + + /// :nodoc: + typealias DifferenceIdentifier = String + + /// :nodoc: + func isContentEqual(to source: DiffableCollectionSectionViewModel) -> Bool { + self._sectionModel.isContentEqual(to: source._sectionModel) + } + + /// :nodoc: + var elements: DiffableCollectionSectionViewModel { self } + + /// :nodoc: + typealias Element = AnyDiffableViewModel + + /// :nodoc: + typealias Index = Int + + /// :nodoc: + let startIndex: Int + + /// :nodoc: + let endIndex: Int + + /// :nodoc: + subscript(position: Int) -> AnyDiffableViewModel { + return AnyDiffableViewModel( + DiffableCollectionCellViewModelProxy( + inVisibleBounds: self._visibleIndices.contains(position) + ) { self._sectionModel[position] } + ) + } + + /// :nodoc: + func index(after i: Int) -> Int { + return self._sectionModel.index(after: i) + } +} + extension Array where Element == DiffableTableSectionViewModel { /// Creates a new `TableViewModel` from a `[DiffableTableSectionViewModel]` @@ -301,3 +441,14 @@ extension Array where Element == DiffableTableSectionViewModel { ) } } + +extension Array where Element == DiffableCollectionSectionViewModel { + + /// Creates a new `CollectionViewModel` from a `[CollectionViewModel]` + /// for diffing + func makeCollectionViewModel() -> CollectionViewModel { + return CollectionViewModel( + sectionModels: self.map { $0._sectionModel } + ) + } +} diff --git a/Sources/UICollectionView+Extensions.swift b/Sources/UICollectionView+Extensions.swift index 87f586e..3b28d8e 100644 --- a/Sources/UICollectionView+Extensions.swift +++ b/Sources/UICollectionView+Extensions.swift @@ -20,7 +20,13 @@ extension UICollectionView { func registerViews(for model: CollectionViewModel) { model.sectionModels.forEach { - self.registerCellViewModels($0.cellViewModels) + if let dataSource = $0.cellViewModelDataSource { + self.registerCellViewModels(dataSource.cellRegistrationInfo.lazy.map { + AnyReusableCellViewModel(registrationInfo: $0) + }) + } else { + self.registerCellViewModels($0.cellViewModels) + } if let header = $0.headerViewModel { self.registerSupplementaryViewModel(header) diff --git a/Tests/CollectionView/CollectionViewLazyDiffingTests.swift b/Tests/CollectionView/CollectionViewLazyDiffingTests.swift new file mode 100644 index 0000000..908a013 --- /dev/null +++ b/Tests/CollectionView/CollectionViewLazyDiffingTests.swift @@ -0,0 +1,101 @@ +// +// PlanGrid +// https://www.plangrid.com +// https://medium.com/plangrid-technology +// +// Documentation +// https://plangrid.github.io/ReactiveLists +// +// GitHub +// https://github.com/plangrid/ReactiveLists +// +// License +// Copyright © 2018-present PlanGrid, Inc. +// Released under an MIT license: https://opensource.org/licenses/MIT +// + +@testable import ReactiveLists +import XCTest + +final class CollectionViewDiffingTests: XCTestCase { + + var collectionViewDataSource: CollectionViewDriver! + var mockCollectionView: TestCollectionView! + + override func setUp() { + super.setUp() + self.mockCollectionView = TestCollectionView( + frame: .zero, + collectionViewLayout: UICollectionViewFlowLayout() + ) +// self.mockCollectionView.indexPathsForVisibleRowsOverride = [ +// IndexPath(row: 0, section: 0), +// ] + self.collectionViewDataSource = CollectionViewDriver( + collectionView: self.mockCollectionView, + shouldDeselectUponSelection: false, + useDataSource: true, + automaticDiffingEnabled: true + ) + } + + /// Tests that changes to individual rows result in the correct calls to update the + /// table view. + /// + /// - Note: We're only testing one type of row update since this is sufficient to test the + /// communication between the diffing lib and the table view. The diffing lib itself has + /// extensive tests for the various diffing scenarios. + func testChangingRows() { + let userCells = [LazyTestUserCell(user: "Name")] + let dataSource = CollectionCellViewModelDataSource(userCells) + let section = CollectionSectionViewModel( + diffingKey: "default_section", + cellViewModels: [], + cellViewModelDataSource: dataSource + ) + let initialModel = CollectionViewModel( + sectionModels: [section] + ) + + self.collectionViewDataSource.collectionViewModel = initialModel + + let testUser1 = [LazyTestUserCell(user: "TestUser1")] +// [LazyUserCell(user: "TestUser1"), LazyUserCell(user: "TestUser2")] +// let testUser2 = LazyUserCell(user: "TestUser2") + let dataSource1 = CollectionCellViewModelDataSource(testUser1) + let section2 = CollectionSectionViewModel( + diffingKey: "default_section", + cellViewModels: [], + cellViewModelDataSource: dataSource1 + ) + let updatedModel = CollectionViewModel( + sectionModels: [section2] + ) + + self.collectionViewDataSource.collectionViewModel = updatedModel + + XCTAssertEqual(self.mockCollectionView.callsToInsertItems.count, 1) + XCTAssertEqual(self.mockCollectionView.callsToReloadData, 2) + } +} + +final class LazyTestUserCell: CollectionCellViewModel, DiffableViewModel { + var accessibilityFormat: CellAccessibilityFormat = "" + let registrationInfo = ViewRegistrationInfo(classType: UICollectionViewCell.self) + + let user: String + private(set) var diffingKeyAccessed: Bool = false + + init(user: String) { + self.user = user + } + + func applyViewModelToCell(_ cell: UICollectionViewCell) {} + + func willDisplay(cell: UICollectionViewCell) {} + + var diffingKey: String { + self.diffingKeyAccessed = true + return self.user + } +}