diff --git a/Sources/EpoxyCollectionView/CollectionView/CollectionView.swift b/Sources/EpoxyCollectionView/CollectionView/CollectionView.swift index 87075dcf..a3c0c164 100644 --- a/Sources/EpoxyCollectionView/CollectionView/CollectionView.swift +++ b/Sources/EpoxyCollectionView/CollectionView/CollectionView.swift @@ -460,8 +460,10 @@ open class CollectionView: UICollectionView { func configure( supplementaryView: CollectionViewReusableView, with model: AnySupplementaryItemModel, + at itemPath: SupplementaryItemPath, animated: Bool) { + supplementaryView.itemPath = itemPath model.configure( reusableView: supplementaryView, traitCollection: traitCollection, @@ -672,25 +674,46 @@ open class CollectionView: UICollectionView { } } - /// Helper function which provides the correct data for a given cell at an index path taking into account the current update state of the - /// collection view. + /// Helper function which provides the correct models for a given cell taking into account the current update state of the collection view. /// - /// This is used in cases where the collection view might be mid-update and we need to find the underlying item for a cell and index - /// path, but there is no guarantee of whether the cell is from the pre-update data or post-update data (so we check both). - private func data(for cell: CollectionViewCell, at indexPath: IndexPath) -> CollectionViewData? { + /// This is used in cases where the collection view might be mid-update and we need to find the underlying item for a cell, but there is + /// no guarantee of whether the cell is from the pre-update data or post-update data (so we check both). + private func itemAndSectionModel( + for cell: CollectionViewCell) + -> (AnyItemModel, SectionModel)? + { guard let itemPath = cell.itemPath else { - EpoxyLogger.shared.assertionFailure("Cell is missing item path.") + EpoxyLogger.shared.assertionFailure("View is missing item path.") return nil } + func itemAndSectionModel( + from data: CollectionViewData?, + for indexPath: IndexPath) + -> (AnyItemModel, SectionModel)? + { + guard + let item = data?.item(at: indexPath), + let section = data?.section(at: indexPath.section) else + { + EpoxyLogger.shared.assertionFailure("Unable to find models in view data.") + return nil + } + return (item, section) + } + switch updateState { case .notUpdating, .preparingUpdate: - return epoxyDataSource.data + guard let indexPath = epoxyDataSource.data?.indexPathForItem(at: itemPath) else { + EpoxyLogger.shared.assertionFailure("Unable to find models in view data.") + return nil + } + return itemAndSectionModel(from: epoxyDataSource.data, for: indexPath) case .updating(from: let oldData): - if oldData.indexPathForItem(at: itemPath) == indexPath { - return oldData - } else if epoxyDataSource.data?.indexPathForItem(at: itemPath) == indexPath { - return epoxyDataSource.data + if let indexPath = oldData.indexPathForItem(at: itemPath) { + return itemAndSectionModel(from: oldData, for: indexPath) + } else if let indexPath = epoxyDataSource.data?.indexPathForItem(at: itemPath) { + return itemAndSectionModel(from: epoxyDataSource.data, for: indexPath) } else { EpoxyLogger.shared.assertionFailure( "Cell not found in either old or new data during an update.") @@ -699,26 +722,54 @@ open class CollectionView: UICollectionView { } } - private func itemForCell(_ cell: CollectionViewCell) -> AnyItemModel? { - guard - let indexPath = indexPath(for: cell), - let model = epoxyDataSource.data?.item(at: indexPath) - else { - EpoxyLogger.shared.assertionFailure("item not found") + /// Helper function which provides the correct models for a given reusable view taking into account the current update state of the + /// collection view. + /// + /// This is used in cases where the collection view might be mid-update and we need to find the underlying supplementary item for a + /// view, but there is no guarantee of whether the view is from the pre-update data or post-update data (so we check both). + private func itemAndSectionModel( + for view: CollectionViewReusableView, + forElementKind elementKind: String) + -> (AnySupplementaryItemModel, SectionModel)? + { + guard let itemPath = view.itemPath else { + EpoxyLogger.shared.assertionFailure("View is missing item path.") return nil } - return model - } - private func sectionForCell(_ cell: CollectionViewCell) -> SectionModel? { - guard - let indexPath = indexPath(for: cell), - let section = epoxyDataSource.data?.section(at: indexPath.section) - else { - EpoxyLogger.shared.assertionFailure("item not found") - return nil + func itemAndSectionModel( + from data: CollectionViewData?, + for indexPath: IndexPath) + -> (AnySupplementaryItemModel, SectionModel)? + { + guard + let item = data?.supplementaryItem(ofKind: elementKind, at: indexPath), + let section = data?.section(at: indexPath.section) else + { + EpoxyLogger.shared.assertionFailure("Unable to find models in view data.") + return nil + } + return (item, section) + } + + switch updateState { + case .notUpdating, .preparingUpdate: + guard let indexPath = epoxyDataSource.data?.indexPathForSupplementaryItem(at: itemPath) else { + EpoxyLogger.shared.assertionFailure("Unable to find models in view data.") + return nil + } + return itemAndSectionModel(from: epoxyDataSource.data, for: indexPath) + case .updating(from: let oldData): + if let indexPath = oldData.indexPathForSupplementaryItem(at: itemPath) { + return itemAndSectionModel(from: oldData, for: indexPath) + } else if let indexPath = epoxyDataSource.data?.indexPathForSupplementaryItem(at: itemPath) { + return itemAndSectionModel(from: epoxyDataSource.data, for: indexPath) + } else { + EpoxyLogger.shared.assertionFailure( + "View not found in either old or new data during an update.") + return nil + } } - return section } } @@ -819,21 +870,14 @@ extension CollectionView: UICollectionViewDelegate { public func collectionView( _: UICollectionView, willDisplay cell: UICollectionViewCell, - forItemAt indexPath: IndexPath) + forItemAt _: IndexPath) { guard let cell = cell as? CollectionViewCell else { EpoxyLogger.shared.assertionFailure("Cell does not match expected type CollectionViewCell.") return } - let data = data(for: cell, at: indexPath) - - guard - let item = data?.item(at: indexPath), - let section = data?.section(at: indexPath.section) - else { - return - } + guard let (item, section) = itemAndSectionModel(for: cell) else { return } handleSection(section, itemWillDisplay: .item(dataID: item.dataID)) @@ -849,21 +893,14 @@ extension CollectionView: UICollectionViewDelegate { public func collectionView( _: UICollectionView, didEndDisplaying cell: UICollectionViewCell, - forItemAt indexPath: IndexPath) + forItemAt _: IndexPath) { guard let cell = cell as? CollectionViewCell else { EpoxyLogger.shared.assertionFailure("Cell does not match expected type CollectionViewCell.") return } - let data = data(for: cell, at: indexPath) - - guard - let item = data?.item(at: indexPath), - let section = data?.section(at: indexPath.section) - else { - return - } + guard let (item, section) = itemAndSectionModel(for: cell) else { return } handleSection(section, itemDidEndDisplaying: .item(dataID: item.dataID)) @@ -880,27 +917,23 @@ extension CollectionView: UICollectionViewDelegate { _: UICollectionView, willDisplaySupplementaryView view: UICollectionReusableView, forElementKind elementKind: String, - at indexPath: IndexPath) + at _: IndexPath) { - // We don't assert since `UICollectionViewCompositionalLayout` can create and configure its own - // supplementary views e.g. with a `.list(using: .init(appearance: .plain))` config. - guard - let section = epoxyDataSource.data?.sectionIfPresent(at: indexPath.section), - let item = epoxyDataSource.data?.supplementaryItemIfPresent(ofKind: elementKind, at: indexPath) - else { + guard let view = view as? CollectionViewReusableView else { + // We don't assert since `UICollectionViewCompositionalLayout` can create and configure its + // own supplementary views e.g. with a `.list(using: .init(appearance: .plain))` config. return } + guard + let (item, section) = itemAndSectionModel( + for: view, + forElementKind: elementKind) else { return } + handleSection( section, itemWillDisplay: .supplementaryItem(elementKind: elementKind, dataID: item.dataID)) - guard let view = view as? CollectionViewReusableView else { - EpoxyLogger.shared.assertionFailure( - "Supplementary view does not match expected type CollectionViewReusableView.") - return - } - item.handleWillDisplay(view, traitCollection: traitCollection, animated: false) (view.view as? DisplayRespondingView)?.didDisplay(true) @@ -917,36 +950,23 @@ extension CollectionView: UICollectionViewDelegate { _: UICollectionView, didEndDisplayingSupplementaryView view: UICollectionReusableView, forElementOfKind elementKind: String, - at indexPath: IndexPath) + at _: IndexPath) { - // When updating, items ending display correspond to items in the old data. - let data: CollectionViewData? - switch updateState { - case .notUpdating, .preparingUpdate: - data = epoxyDataSource.data - case .updating(from: let oldData): - data = oldData + guard let view = view as? CollectionViewReusableView else { + // We don't assert since `UICollectionViewCompositionalLayout` can create and configure its + // own supplementary views e.g. with a `.list(using: .init(appearance: .plain))` config. + return } - // We don't assert since `UICollectionViewCompositionalLayout` can create and configure its own - // supplementary views e.g. with a `.list(using: .init(appearance: .plain))` config. guard - let section = data?.sectionIfPresent(at: indexPath.section), - let item = data?.supplementaryItemIfPresent(ofKind: elementKind, at: indexPath) - else { - return - } + let (item, section) = itemAndSectionModel( + for: view, + forElementKind: elementKind) else { return } handleSection( section, itemDidEndDisplaying: .supplementaryItem(elementKind: elementKind, dataID: item.dataID)) - guard let view = view as? CollectionViewReusableView else { - EpoxyLogger.shared.assertionFailure( - "Supplementary view does not match expected type CollectionViewReusableView.") - return - } - item.handleDidEndDisplaying(view, traitCollection: traitCollection, animated: false) (view.view as? DisplayRespondingView)?.didDisplay(false) @@ -1143,33 +1163,23 @@ extension CollectionView: CollectionViewDataSourceReorderingDelegate { extension CollectionView: CollectionViewCellAccessibilityDelegate { func collectionViewCellDidBecomeFocused(cell: CollectionViewCell) { - guard - let model = itemForCell(cell), - let section = sectionForCell(cell) - else { - return - } + guard let (item, section) = itemAndSectionModel(for: cell) else { return } - lastFocusedDataID = .init(itemDataID: model.dataID, section: .dataID(section.dataID)) + lastFocusedDataID = .init(itemDataID: item.dataID, section: .dataID(section.dataID)) accessibilityDelegate?.collectionView( self, - itemDidBecomeFocused: model, + itemDidBecomeFocused: item, with: cell.view, in: section) } func collectionViewCellDidLoseFocus(cell: CollectionViewCell) { - guard - let model = itemForCell(cell), - let section = sectionForCell(cell) - else { - return - } + guard let (item, section) = itemAndSectionModel(for: cell) else { return } accessibilityDelegate?.collectionView( self, - itemDidLoseFocus: model, + itemDidLoseFocus: item, with: cell.view, in: section) } diff --git a/Sources/EpoxyCollectionView/CollectionView/Internal/CollectionViewData.swift b/Sources/EpoxyCollectionView/CollectionView/Internal/CollectionViewData.swift index 2395cce4..68fe4a93 100644 --- a/Sources/EpoxyCollectionView/CollectionView/Internal/CollectionViewData.swift +++ b/Sources/EpoxyCollectionView/CollectionView/Internal/CollectionViewData.swift @@ -14,11 +14,13 @@ struct CollectionViewData { private init( sections: [SectionModel], sectionIndexMap: SectionIndexMap, - itemIndexMap: ItemIndexMap) + itemIndexMap: ItemIndexMap, + supplementaryItemIndexMap: SupplementaryItemIndexMap) { self.sections = sections self.sectionIndexMap = sectionIndexMap self.itemIndexMap = itemIndexMap + self.supplementaryItemIndexMap = supplementaryItemIndexMap } // MARK: Internal @@ -28,6 +30,7 @@ struct CollectionViewData { static func make(sections: [SectionModel]) -> Self { var sectionIndexMap = SectionIndexMap() var itemIndexMap = ItemIndexMap() + var supplementaryItemIndexMap = SupplementaryItemIndexMap() for sectionIndex in sections.indices { let section = sections[sectionIndex] @@ -40,9 +43,25 @@ struct CollectionViewData { let indexPath = IndexPath(item: itemIndex, section: sectionIndex) itemIndexMap[item.dataID, default: [:]][sectionDataID, default: []].append(indexPath) } + + let sectionSupplementaryItems = section.supplementaryItems + for (elementKind, supplementaryItems) in sectionSupplementaryItems { + var indexMapForElementKind = supplementaryItemIndexMap[elementKind] ?? ItemIndexMap() + for itemIndex in supplementaryItems.indices { + let item = supplementaryItems[itemIndex] + let indexPath = IndexPath(item: itemIndex, section: sectionIndex) + indexMapForElementKind[item.internalItemModel.dataID, default: [:]][sectionDataID, default: []] + .append(indexPath) + } + supplementaryItemIndexMap[elementKind] = indexMapForElementKind + } } - return .init(sections: sections, sectionIndexMap: sectionIndexMap, itemIndexMap: itemIndexMap) + return .init( + sections: sections, + sectionIndexMap: sectionIndexMap, + itemIndexMap: itemIndexMap, + supplementaryItemIndexMap: supplementaryItemIndexMap) } func makeChangeset(from otherData: Self) -> CollectionViewChangeset { @@ -96,30 +115,6 @@ struct CollectionViewData { return sections[index] } - /// Returns the section model at the given index, otherwise `nil` if it does not exist. - func sectionIfPresent(at index: Int) -> SectionModel? { - guard index < sections.count else { return nil } - - return sections[index] - } - - /// Returns the supplementary item model of the provided kind at the given index, otherwise `nil` - /// if it does not exist. - func supplementaryItemIfPresent( - ofKind elementKind: String, - at indexPath: IndexPath) - -> AnySupplementaryItemModel? - { - guard indexPath.section < sections.count else { return nil } - - let section = sections[indexPath.section] - - guard let models = section.supplementaryItems[elementKind] else { return nil } - guard indexPath.item < models.count else { return nil } - - return models[indexPath.item].eraseToAnySupplementaryItemModel() - } - /// Returns the supplementary item model of the provided kind at the given index, asserting if it /// does not exist. func supplementaryItem( @@ -146,70 +141,20 @@ struct CollectionViewData { return model.eraseToAnySupplementaryItemModel() } - /// Returns the `IndexPath` corresponding to the given `ItemPath`, logging a warning if the - /// `ItemPath` corresponds to multiple items due to duplicate data IDs. - func indexPathForItem(at path: ItemPath) -> IndexPath? { - guard let itemIndexMapBySectionID = itemIndexMap[path.itemDataID] else { + /// Returns the `IndexPath` corresponding to the given `SupplementaryItemPath`, logging a warning if the + /// `SupplementaryItemPath` corresponds to multiple supplementary items due to duplicate data IDs. + func indexPathForSupplementaryItem(at path: SupplementaryItemPath) -> IndexPath? { + guard let itemIndexMap = supplementaryItemIndexMap[path.elementKind] else { return nil } - func lastIndexPath(in indexPaths: [IndexPath], sectionID: AnyHashable) -> IndexPath? { - if indexPaths.count > 1 { - EpoxyLogger.shared.warn({ - // `sectionIndexMap` is constructed from the same data as `itemIndexMap` so we can force - // unwrap. - // swiftlint:disable:next force_unwrapping - let sectionIndex = sectionIndexMap[sectionID]! - - return """ - Warning! Attempted to locate item \(path.itemDataID) in section \(sectionID) at indexes \ - \(sectionIndex.map { $0 }) when there are multiple items with that ID at the indexes \ - \(indexPaths.map { $0.item }). Choosing the last index. Items should have unique data \ - IDs within a section as duplicate data IDs cause undefined behavior. - """ - }()) - } - - return indexPaths.last - } - - switch path.section { - case .dataID(let sectionID): - if let indexPaths = itemIndexMapBySectionID[sectionID] { - // If the section ID is specified, just look up the indexes for that section. - return lastIndexPath(in: indexPaths, sectionID: sectionID) - } - return nil - - case .lastWithItemDataID: - // If the section ID is unspecified but there's only one section with this data ID: - if itemIndexMapBySectionID.count == 1, let idAndIndexes = itemIndexMapBySectionID.first { - return lastIndexPath(in: idAndIndexes.value, sectionID: idAndIndexes.key) - } - - // Otherwise there's multiple sections with the same data ID so we pick the last section so - // that it's stable. - let lastSectionID = itemIndexMapBySectionID.max(by: { first, second in - // `sectionIndexMap` is constructed from the same data as `itemIndexMap` so we can safely - // force unwrap. - // swiftlint:disable:next force_unwrapping - sectionIndexMap[first.key]!.last! < sectionIndexMap[second.key]!.last! - }) - - if let sectionID = lastSectionID { - EpoxyLogger.shared.warn(""" - Warning! Attempted to locate item \(path.itemDataID) when there are multiple sections that \ - contain it each with IDs \(itemIndexMapBySectionID.keys) at the indexes \ - \(itemIndexMapBySectionID.keys.map { sectionIndexMap[$0] }). Choosing the last section \ - \(sectionID.key). To fix this warning specify the desired section data ID when \ - constructing your `ItemPath`. - """) - - return lastIndexPath(in: sectionID.value, sectionID: sectionID.key) - } + return indexPath(from: itemIndexMap, for: path.itemDataID, in: path.section) + } - return nil - } + /// Returns the `IndexPath` corresponding to the given `ItemPath`, logging a warning if the + /// `ItemPath` corresponds to multiple items due to duplicate data IDs. + func indexPathForItem(at path: ItemPath) -> IndexPath? { + indexPath(from: itemIndexMap, for: path.itemDataID, in: path.section) } /// Returns the `Int` index corresponding to the given section `dataID`, logging a warning if the @@ -245,8 +190,12 @@ struct CollectionViewData { /// `IndexPath`s with both the item and section `dataID`. private typealias ItemIndexMap = [AnyHashable: [AnyHashable: [IndexPath]]] + /// The item index map for supplementary views keyed by their element kind. + private typealias SupplementaryItemIndexMap = [String: ItemIndexMap] + private let sectionIndexMap: SectionIndexMap private let itemIndexMap: ItemIndexMap + private let supplementaryItemIndexMap: SupplementaryItemIndexMap private func supplementaryItemChangeset( from otherData: Self, @@ -349,4 +298,72 @@ struct CollectionViewData { }()) } + private func indexPath( + from itemIndexMapBySectionID: ItemIndexMap, + for itemDataID: AnyHashable, + in section: ItemSectionPath) + -> IndexPath? + { + guard let itemIndexMapBySectionID = itemIndexMapBySectionID[itemDataID] else { + return nil + } + func lastIndexPath(in indexPaths: [IndexPath], sectionID: AnyHashable) -> IndexPath? { + if indexPaths.count > 1 { + EpoxyLogger.shared.warn({ + // `sectionIndexMap` is constructed from the same data as `itemIndexMap` so we can force + // unwrap. + // swiftlint:disable:next force_unwrapping + let sectionIndex = sectionIndexMap[sectionID]! + + return """ + Warning! Attempted to locate item \(itemDataID) in section \(sectionID) at indexes \ + \(sectionIndex.map { $0 }) when there are multiple items with that ID at the indexes \ + \(indexPaths.map { $0.item }). Choosing the last index. Items should have unique data \ + IDs within a section as duplicate data IDs cause undefined behavior. + """ + }()) + } + + return indexPaths.last + } + + switch section { + case .dataID(let sectionID): + if let indexPaths = itemIndexMapBySectionID[sectionID] { + // If the section ID is specified, just look up the indexes for that section. + return lastIndexPath(in: indexPaths, sectionID: sectionID) + } + return nil + + case .lastWithItemDataID: + // If the section ID is unspecified but there's only one section with this data ID: + if itemIndexMapBySectionID.count == 1, let idAndIndexes = itemIndexMapBySectionID.first { + return lastIndexPath(in: idAndIndexes.value, sectionID: idAndIndexes.key) + } + + // Otherwise there's multiple sections with the same data ID so we pick the last section so + // that it's stable. + let lastSectionID = itemIndexMapBySectionID.max(by: { first, second in + // `sectionIndexMap` is constructed from the same data as `itemIndexMap` so we can safely + // force unwrap. + // swiftlint:disable:next force_unwrapping + sectionIndexMap[first.key]!.last! < sectionIndexMap[second.key]!.last! + }) + + if let sectionID = lastSectionID { + EpoxyLogger.shared.warn(""" + Warning! Attempted to locate item \(itemDataID) when there are multiple sections that \ + contain it each with IDs \(itemIndexMapBySectionID.keys) at the indexes \ + \(itemIndexMapBySectionID.keys.map { sectionIndexMap[$0] }). Choosing the last section \ + \(sectionID.key). To fix this warning specify the desired section data ID when \ + constructing your `ItemPath`. + """) + + return lastIndexPath(in: sectionID.value, sectionID: sectionID.key) + } + + return nil + } + } + } diff --git a/Sources/EpoxyCollectionView/CollectionView/Internal/CollectionViewDataSource.swift b/Sources/EpoxyCollectionView/CollectionView/Internal/CollectionViewDataSource.swift index aeec9757..516a1a8b 100644 --- a/Sources/EpoxyCollectionView/CollectionView/Internal/CollectionViewDataSource.swift +++ b/Sources/EpoxyCollectionView/CollectionView/Internal/CollectionViewDataSource.swift @@ -204,6 +204,7 @@ extension CollectionViewDataSource: UICollectionViewDataSource { { guard let item = data?.supplementaryItem(ofKind: kind, at: indexPath), + let section = data?.section(at: indexPath.section), let reuseID = reuseIDStore.registeredReuseID(for: item.viewDifferentiator) else { // The `supplementaryItem(…)` or `registeredReuseID(…)` methods will assert in this scenario. @@ -219,6 +220,7 @@ extension CollectionViewDataSource: UICollectionViewDataSource { self.collectionView?.configure( supplementaryView: supplementaryView, with: item, + at: .init(elementKind: kind, itemDataID: item.dataID, section: .dataID(section.dataID)), animated: false) } else { EpoxyLogger.shared.assertionFailure( diff --git a/Sources/EpoxyCollectionView/CollectionView/ItemPath.swift b/Sources/EpoxyCollectionView/CollectionView/ItemPath.swift index cf6cb020..61bc6cda 100644 --- a/Sources/EpoxyCollectionView/CollectionView/ItemPath.swift +++ b/Sources/EpoxyCollectionView/CollectionView/ItemPath.swift @@ -8,29 +8,17 @@ public struct ItemPath: Hashable { // MARK: Lifecycle - public init(itemDataID: AnyHashable, section: Section) { + public init(itemDataID: AnyHashable, section: ItemSectionPath) { self.itemDataID = itemDataID self.section = section } // MARK: Public - /// The section in which the item referenced by an `ItemPath` is located. - public enum Section: Hashable { - /// The section identified by the `dataID` on its corresponding `SectionModel`. - case dataID(AnyHashable) - - /// The last section that contains an item with `itemDataID` as its `dataID`. - /// - /// If there are multiple sections with an items that have the same `dataID`, it is not - /// recommended use this case, as the located item may be unstable over time. - case lastWithItemDataID - } - /// The item identified by the `dataID` on its corresponding `ItemModel`. public var itemDataID: AnyHashable /// The section in which the item referenced by this path located. - public var section: Section + public var section: ItemSectionPath } diff --git a/Sources/EpoxyCollectionView/CollectionView/ItemSectionPath.swift b/Sources/EpoxyCollectionView/CollectionView/ItemSectionPath.swift new file mode 100644 index 00000000..adb06174 --- /dev/null +++ b/Sources/EpoxyCollectionView/CollectionView/ItemSectionPath.swift @@ -0,0 +1,16 @@ +// Created by Bryn Bodayle on 8/15/23. +// Copyright © 2023 Airbnb Inc. All rights reserved. + +import Foundation + +/// The section in which an item referenced by an `ItemPath` or `SupplementaryItemPath` is located. +public enum ItemSectionPath: Hashable { + /// The section identified by the `dataID` on its corresponding `SectionModel`. + case dataID(AnyHashable) + + /// The last section that contains an item with `itemDataID` as its `dataID`. + /// + /// If there are multiple sections with items that have the same `dataID`, it is not + /// recommended to use this case, as the located item may be unstable over time. + case lastWithItemDataID +} diff --git a/Sources/EpoxyCollectionView/CollectionView/ReusableViews/CollectionViewReusableView.swift b/Sources/EpoxyCollectionView/CollectionView/ReusableViews/CollectionViewReusableView.swift index 70335200..585204a3 100644 --- a/Sources/EpoxyCollectionView/CollectionView/ReusableViews/CollectionViewReusableView.swift +++ b/Sources/EpoxyCollectionView/CollectionView/ReusableViews/CollectionViewReusableView.swift @@ -69,4 +69,11 @@ public final class CollectionViewReusableView: UICollectionReusableView { return layoutAttributes } + // MARK: Internal + + /// The item path of the supplementary view from its last configuration update. Used to associate the view with the underlying data. When collection + /// view provides view display callbacks, if it is mid update, we need this to see if the view came from pre-update data or + /// post-update data. + var itemPath: SupplementaryItemPath? + } diff --git a/Sources/EpoxyCollectionView/CollectionView/SupplementaryItemPath.swift b/Sources/EpoxyCollectionView/CollectionView/SupplementaryItemPath.swift new file mode 100644 index 00000000..553df4a0 --- /dev/null +++ b/Sources/EpoxyCollectionView/CollectionView/SupplementaryItemPath.swift @@ -0,0 +1,28 @@ +// Created by Bryn Bodayle on 8/15/23. +// Copyright © 2023 Airbnb Inc. All rights reserved. + +import Foundation + +/// A path to a specific supplementary item within a section in a `CollectionView`. +public struct SupplementaryItemPath: Hashable { + + // MARK: Lifecycle + + public init(elementKind: String, itemDataID: AnyHashable, section: ItemSectionPath) { + self.elementKind = elementKind + self.itemDataID = itemDataID + self.section = section + } + + // MARK: Public + + /// The type of supplementary view + public var elementKind: String + + /// The supplementary item identified by the `dataID` on its corresponding `ItemModel`. + public var itemDataID: AnyHashable + + /// The section in which the supplementary item referenced by this path located. + public var section: ItemSectionPath + +} diff --git a/Tests/EpoxyTests/CollectionViewTests/CollectionViewSpec.swift b/Tests/EpoxyTests/CollectionViewTests/CollectionViewSpec.swift index 6bc10852..32f02ea7 100644 --- a/Tests/EpoxyTests/CollectionViewTests/CollectionViewSpec.swift +++ b/Tests/EpoxyTests/CollectionViewTests/CollectionViewSpec.swift @@ -33,6 +33,15 @@ final class CollectionViewSpec: QuickSpec { return cell } + var mockHeaderView: CollectionViewReusableView { + let cell = CollectionViewReusableView(frame: .zero) + cell.itemPath = .init( + elementKind: UICollectionView.elementKindSectionHeader, + itemDataID: DefaultDataID.noneProvided, + section: .dataID(DefaultDataID.noneProvided)) + return cell + } + override func spec() { let itemModel = ItemModel(dataID: DefaultDataID.noneProvided) let supplementaryItemModel = SupplementaryItemModel(dataID: DefaultDataID.noneProvided) @@ -166,7 +175,7 @@ final class CollectionViewSpec: QuickSpec { beforeEach { collectionView.delegate?.collectionView?( collectionView, - willDisplaySupplementaryView: CollectionViewReusableView(), + willDisplaySupplementaryView: self.mockHeaderView, forElementKind: UICollectionView.elementKindSectionHeader, at: IndexPath(item: 0, section: 0)) } @@ -181,7 +190,7 @@ final class CollectionViewSpec: QuickSpec { beforeEach { collectionView.delegate?.collectionView?( collectionView, - didEndDisplayingSupplementaryView: CollectionViewReusableView(), + didEndDisplayingSupplementaryView: self.mockHeaderView, forElementOfKind: UICollectionView.elementKindSectionHeader, at: IndexPath(item: 0, section: 0)) } @@ -275,7 +284,7 @@ final class CollectionViewSpec: QuickSpec { beforeEach { collectionView.delegate?.collectionView?( collectionView, - willDisplaySupplementaryView: CollectionViewReusableView(), + willDisplaySupplementaryView: self.mockHeaderView, forElementKind: UICollectionView.elementKindSectionHeader, at: IndexPath(item: 0, section: 0)) } @@ -292,7 +301,7 @@ final class CollectionViewSpec: QuickSpec { forItemAt: IndexPath(item: 0, section: 0)) collectionView.delegate?.collectionView?( collectionView, - didEndDisplayingSupplementaryView: CollectionViewReusableView(), + didEndDisplayingSupplementaryView: self.mockHeaderView, forElementOfKind: UICollectionView.elementKindSectionHeader, at: IndexPath(item: 0, section: 0)) }