diff --git a/Sources/FunctionalTableData/AnyHashableConfig.swift b/Sources/FunctionalTableData/AnyHashableConfig.swift new file mode 100644 index 0000000..50403c1 --- /dev/null +++ b/Sources/FunctionalTableData/AnyHashableConfig.swift @@ -0,0 +1,91 @@ +// +// AnyHashableConfig.swift +// FunctionalTableData +// +// Created by Jason Kemp on 2021-10-27. +// + +import UIKit + +public struct AnyHashableConfig: Hashable, HashableCellConfigType { + public static func ==(lhs: AnyHashableConfig, rhs: AnyHashableConfig) -> Bool { + return lhs.hashable == rhs.hashable + } + + private var base: CellConfigType + public let hashable: AnyHashable + + public func hash(into hasher: inout Hasher) { + hasher.combine(hashable) + } + + public init(_ base: CellConfigType, sectionKey: String) { + self.base = base + if let hashableConfig = base as? HashableCellConfigType { + self.hashable = hashableConfig.hashable + } else { + self.hashable = AnyHashable(ItemPath(sectionKey: sectionKey, itemKey: base.key)) + } + } + + public init(_ base: HashableCellConfigType) { + self.base = base + self.hashable = base.hashable + } + + public var key: String { + return base.key + } + + public var style: CellStyle? { + get { return base.style } + set { base.style = newValue } + } + + public var actions: CellActions { + get { return base.actions } + set { base.actions = newValue } + } + + public var accessibility: Accessibility { + get { return base.accessibility } + set { base.accessibility = newValue } + } + + public func dequeueCell(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { + return base.dequeueCell(from: tableView, at: indexPath) + } + + public func dequeueCell(from collectionView: UICollectionView, at indexPath: IndexPath) -> UICollectionViewCell { + return base.dequeueCell(from: collectionView, at: indexPath) + } + + public func update(cell: UITableViewCell, in tableView: UITableView) { + base.update(cell: cell, in: tableView) + } + + public func update(cell: UICollectionViewCell, in collectionView: UICollectionView) { + base.update(cell: cell, in: collectionView) + } + + public func isEqual(_ other: CellConfigType) -> Bool { + guard let other = other as? AnyHashableConfig else { return false } + return base.isEqual(other.base) + } + + public func isSameKind(as other: CellConfigType) -> Bool { + return base.isSameKind(as: other) + } + + public func debugInfo() -> [String : Any] { + return base.debugInfo() + } + + public func register(with tableView: UITableView) { + base.register(with: tableView) + } + + public func register(with collectionView: UICollectionView) { + base.register(with: collectionView) + } +} diff --git a/Sources/FunctionalTableData/CellConfigType.swift b/Sources/FunctionalTableData/CellConfigType.swift index bedb156..e58dcf9 100644 --- a/Sources/FunctionalTableData/CellConfigType.swift +++ b/Sources/FunctionalTableData/CellConfigType.swift @@ -63,82 +63,3 @@ public extension CellConfigType { return type(of: self) == type(of: other) } } - -public struct AnyCellConfigType: CellConfigType, Hashable { - - public static func ==(lhs: AnyCellConfigType, rhs: AnyCellConfigType) -> Bool { - return lhs.sectionKey == rhs.sectionKey && lhs.key == rhs.key - } - - public var key: String { - return base.key - } - - public var style: CellStyle? { - get { return base.style } - set { base.style = newValue } - } - - public var actions: CellActions { - get { return base.actions } - set { base.actions = newValue } - } - - public var accessibility: Accessibility { - get { return base.accessibility } - set { base.accessibility = newValue } - } - - public var base: CellConfigType - private let sectionKey: String - - init(_ base: CellConfigType, sectionKey: String) { - self.base = base - self.sectionKey = sectionKey - } - - public init(_ base: CellConfigType) { - self.init(base, sectionKey: "") - } - - public func hash(into hasher: inout Hasher) { - hasher.combine(key) - hasher.combine(sectionKey) - hasher.combine(style) - } - - public func dequeueCell(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { - return base.dequeueCell(from: tableView, at: indexPath) - } - - public func update(cell: UITableViewCell, in tableView: UITableView) { - base.update(cell: cell, in: tableView) - } - - public func update(cell: UICollectionViewCell, in collectionView: UICollectionView) { - base.update(cell: cell, in: collectionView) - } - - public func isEqual(_ other: CellConfigType) -> Bool { - guard let other = other as? AnyCellConfigType else { return false } - return sectionKey == other.sectionKey && key == other.key - } - - public func isSameKind(as other: CellConfigType) -> Bool { - return base.isSameKind(as: other) - } - - public func debugInfo() -> [String : Any] { - return base.debugInfo() - } - - public func register(with tableView: UITableView) { - base.register(with: tableView) - } - - public func register(with collectionView: UICollectionView) { - base.register(with: collectionView) - } - - -} diff --git a/Sources/FunctionalTableData/CellStyle.swift b/Sources/FunctionalTableData/CellStyle.swift index 9d5a5f4..dd39aac 100644 --- a/Sources/FunctionalTableData/CellStyle.swift +++ b/Sources/FunctionalTableData/CellStyle.swift @@ -43,7 +43,6 @@ public struct CellStyle { public var separatorColor: UIColor? /// Whether the cell is highlighted or not. /// - /// Supported by `UITableView` only. public var highlight: Bool? /// The type of standard accessory control used by a cell. /// You use these constants when setting the value of the [accessoryType](apple-reference-documentation://hspQPOCGHb) property. @@ -162,7 +161,7 @@ public struct CellStyle { } cell.selectedBackgroundView = nil - if let selectionColor = selectionColor { + if let highlight = highlight, highlight, let selectionColor = selectionColor { let selectedBackgroundView = UIView() selectedBackgroundView.backgroundColor = selectionColor cell.selectedBackgroundView = selectedBackgroundView diff --git a/Sources/FunctionalTableData/CollectionSection.swift b/Sources/FunctionalTableData/CollectionSection.swift new file mode 100644 index 0000000..5d6aa54 --- /dev/null +++ b/Sources/FunctionalTableData/CollectionSection.swift @@ -0,0 +1,76 @@ +// +// CollectionSection.swift +// FunctionalTableData +// +// Created by Jason Kemp on 2021-10-05. +// Copyright © 2021 Shopify. All rights reserved. + +import UIKit + +public typealias CellPreparer = (UICollectionViewCell, UICollectionView, IndexPath) -> Void + +public protocol CollectionSection { + var key: String { get } + + var items: [CellConfigType] { get set } + + var supplementaries: [CollectionSupplementaryItemConfig] { get set } + + /// Callback executed when an item is manually moved by the user. It specifies the before and after index position. + var didMoveRow: ((_ from: Int, _ to: Int) -> Void)? { get } + + func prepareCell(_ cell: UICollectionViewCell, in collectionView: UICollectionView, for indexPath: IndexPath) +} + +public protocol HashableCellConfigType: CellConfigType { + var hashable: AnyHashable { get } +} + +public extension CollectionSection { + func supplementaryConfig(ofKind kind: ReusableKind) -> CollectionSupplementaryItemConfig? { + return supplementaries.first(where: { $0.kind == kind }) + } + + var header: CollectionSupplementaryItemConfig? { + return supplementaries.first(where: { $0.kind == .header }) + } + + var footer: CollectionSupplementaryItemConfig? { + return supplementaries.first(where: { $0.kind == .footer }) + } +} + +public struct SimpleCollectionSection: CollectionSection, Hashable { + public static func ==(lhs: SimpleCollectionSection, rhs: SimpleCollectionSection) -> Bool { + return lhs.key == rhs.key + } + + public let key: String + public var items: [CellConfigType] + public var supplementaries: [CollectionSupplementaryItemConfig] + + public init(key: String, + items: [CellConfigType], + supplementaries: [CollectionSupplementaryItemConfig] = [], + didMoveRow: ((Int, Int) -> Void)? = nil, + cellPreparer: CellPreparer? = nil) { + self.key = key + self.items = items + self.supplementaries = supplementaries + self.didMoveRow = didMoveRow + self.cellPreparer = cellPreparer + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(key) + } + + /// Callback executed when an item is manually moved by the user. It specifies the before and after index position. + public let didMoveRow: ((_ from: Int, _ to: Int) -> Void)? + + public var cellPreparer: CellPreparer? + + public func prepareCell(_ cell: UICollectionViewCell, in collectionView: UICollectionView, for indexPath: IndexPath) { + cellPreparer?(cell, collectionView, indexPath) + } +} diff --git a/Sources/FunctionalTableData/CollectionView/AnyCollectionSection.swift b/Sources/FunctionalTableData/CollectionView/AnyCollectionSection.swift new file mode 100644 index 0000000..fc3c079 --- /dev/null +++ b/Sources/FunctionalTableData/CollectionView/AnyCollectionSection.swift @@ -0,0 +1,39 @@ +// +// AnyCollectionSection.swift +// FunctionalTableData +// +// Created by Jason Kemp on 2021-10-28. +// + +import UIKit + +struct AnyCollectionSection: Hashable { + public static func ==(lhs: AnyCollectionSection, rhs: AnyCollectionSection) -> Bool { + return lhs.key == rhs.key + } + + private var impl: CollectionSection + + public var key: String { impl.key } + public var items: [HashableCellConfigType] + public var supplementaries: [CollectionSupplementaryItemConfig] { + get { impl.supplementaries } + set { impl.supplementaries = newValue } + } + + public init(_ section: CollectionSection) { + items = section.items.map { AnyHashableConfig($0, sectionKey: section.key) } + impl = section + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(key) + } + + /// Callback executed when a item is manually moved by the user. It specifies the before and after index position. + public var didMoveRow: ((_ from: Int, _ to: Int) -> Void)? { impl.didMoveRow } + + public func prepareCell(_ cell: UICollectionViewCell, in collectionView: UICollectionView, for indexPath: IndexPath) { + impl.prepareCell(cell, in: collectionView, for: indexPath) + } +} diff --git a/Sources/FunctionalTableData/CollectionView/CellConfig.swift b/Sources/FunctionalTableData/CollectionView/CellConfig.swift new file mode 100644 index 0000000..8afe48a --- /dev/null +++ b/Sources/FunctionalTableData/CollectionView/CellConfig.swift @@ -0,0 +1,70 @@ +// +// CellConfig.swift +// +// +// Created by Jason Kemp on 2021-10-19. +// + +import UIKit + +public struct CellConfig: HashableCellConfigType where View: UIView & ConfigurableView, State: Hashable, View.State == State { + public typealias CollectionCellType = ConfigurableCollectionCell + + public let key: String + public var hashable: AnyHashable { return AnyHashable(state) } + public var style: CellStyle? + public var actions: CellActions + public var accessibility: Accessibility + public let state: State + + public init(key: String, + style: CellStyle? = CellStyle(backgroundColor: nil), + actions: CellActions = CellActions(), + accessibility: Accessibility = Accessibility(), + state: State) { + self.key = key + self.style = style + self.actions = actions + self.accessibility = accessibility + self.state = state + } + + public func isEqual(_ other: CellConfigType) -> Bool { + guard let other = other as? CellConfig else { + return false + } + return state == other.state && accessibility == other.accessibility + } + + public func debugInfo() -> [String : Any] { + return ["key": key, "type": String(describing: type(of: self))] + } + + // MARK: - TableItemConfigType + public func register(with tableView: UITableView) { + // intentionally blank, intended use for CellConfig is for UICollectionView + } + + public func dequeueCell(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { + return UITableViewCell() + } + + public func update(cell: UITableViewCell, in tableView: UITableView) { + // intentionally blank, intended use for CellConfig is for UICollectionView + } + + // MARK: - CollectionItemConfigType + + public func register(with collectionView: UICollectionView) { + collectionView.registerReusableCell(CollectionCellType.self) + } + + public func dequeueCell(from collectionView: UICollectionView, at indexPath: IndexPath) -> UICollectionViewCell { + return collectionView.dequeueReusableCell(CollectionCellType.self, indexPath: indexPath) + } + + public func update(cell: UICollectionViewCell, in collectionView: UICollectionView) { + guard let cell = cell as? CollectionCellType else { return } + cell.configure(state) + } +} diff --git a/Sources/FunctionalTableData/CollectionView/CollectionCell.swift b/Sources/FunctionalTableData/CollectionView/CollectionCell.swift index d266815..20b4eb1 100644 --- a/Sources/FunctionalTableData/CollectionView/CollectionCell.swift +++ b/Sources/FunctionalTableData/CollectionView/CollectionCell.swift @@ -15,6 +15,13 @@ public class CollectionCell: UICollec public override init(frame: CGRect) { view = ViewType() super.init(frame: frame) + // to get identical layouts to TableCell, we need to add the same layoutMargins that UITableViewCell has + // from the view debugger, we've determined them (see below), although they are subject to change + if #available(iOS 11.0, *) { + contentView.directionalLayoutMargins = NSDirectionalEdgeInsets(top: 11.0, leading: 20.0, bottom: 11.0, trailing: 20.0) + } else { + contentView.layoutMargins = UIEdgeInsets(top: 11.0, left: 20.0, bottom: 11.0, right: 20.0) + } contentView.addSubviewsForAutolayout(view) Layout.layoutView(view, inContentView: contentView) } @@ -29,3 +36,43 @@ public class CollectionCell: UICollec prepare = nil } } + +/// A UICollectionReusableView meant to transfer existing TableHeaderFooter views to collection views. +/// Do not use for new supplementary views; prefer ReusableSupplementaryView instead. +public class LegacyTableHeaderFooterView: UICollectionViewCell { + public let view: ViewType + public let topSeparator = Separator(style: Separator.Style.full) + public let bottomSeparator = Separator(style: Separator.Style.full) + + public override init(frame: CGRect) { + view = ViewType() + super.init(frame: frame) + contentView.backgroundColor = UIColor.white + // to get identical layouts to TableHeaderFooter, we need to add the same layoutMargins that UITableViewCell has + // From the view debugger, we've determined them (see below), although they are subject to change + if #available(iOS 11.0, *) { + contentView.directionalLayoutMargins = NSDirectionalEdgeInsets(top: 16.0, leading: 20.0, bottom: 8.0, trailing: 20.0) + } else { + contentView.layoutMargins = UIEdgeInsets(top: 16.0, left: 20.0, bottom: 16.0, right: 20.0) + } + view.layoutMargins = .zero + contentView.addSubviewsForAutolayout(view) + + addSubviewsForAutolayout(topSeparator, bottomSeparator) + topSeparator.constrainToTopOfView(self) + bottomSeparator.constrainToBottomOfView(self) + + Layout.layoutView(view, inContentView: contentView) + } + + public required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +@available(iOS 11.0, *) +private extension NSDirectionalEdgeInsets { + init(_ edgeInsets: UIEdgeInsets) { + self.init(top: edgeInsets.top, leading: edgeInsets.left, bottom: edgeInsets.bottom, trailing: edgeInsets.right) + } +} diff --git a/Sources/FunctionalTableData/CollectionView/CollectionData.swift b/Sources/FunctionalTableData/CollectionView/CollectionData.swift new file mode 100644 index 0000000..fa733f8 --- /dev/null +++ b/Sources/FunctionalTableData/CollectionView/CollectionData.swift @@ -0,0 +1,34 @@ +// +// CollectionData.swift +// FunctionalTableData +// +// Created by Jason Kemp on 2021-10-04. +// Copyright © 2021 Shopify. All rights reserved. + +import Foundation + +class CollectionData { + var header: CollectionSupplementaryItemConfig? + var footer: CollectionSupplementaryItemConfig? + var sections: [CollectionSection] = [] + + subscript(key: IndexPath) -> CellConfigType? { + /// Accessing `.section` or `.row` from an `IndexPath` causes a crash + /// if it doesn't have exactly two elements. + /// + /// This can occur with the following steps: + /// + /// - Enable Voice Control from the iOS Settings app + /// + /// - Navigate to a view controller that uses `FunctionalTableData`. + /// The `tableView(_:leadingSwipeActionsConfigurationForRowAt:)` delegate + /// method gets called with an empty index path. + /// + /// - The FunctionalTableData implementation then calls this subscript + /// with an empty index path. + guard key.count == 2, key.section < sections.count else { return nil } + let item = key.item + let section: CollectionSection = sections[key.section] + return (item < section.items.count) ? section.items[item] : nil + } +} diff --git a/Sources/FunctionalTableData/CollectionView/CollectionItemConfigType.swift b/Sources/FunctionalTableData/CollectionView/CollectionItemConfigType.swift index d3e1cd3..11b6920 100644 --- a/Sources/FunctionalTableData/CollectionView/CollectionItemConfigType.swift +++ b/Sources/FunctionalTableData/CollectionView/CollectionItemConfigType.swift @@ -13,8 +13,13 @@ public protocol CollectionItemConfigType { func dequeueCell(from collectionView: UICollectionView, at indexPath: IndexPath) -> UICollectionViewCell } -extension CollectionItemConfigType { - public func dequeueCell(from collectionView: UICollectionView, at indexPath: IndexPath) -> UICollectionViewCell { - return collectionView.dequeueReusableCell(withReuseIdentifier: UICollectionViewCell.reuseIdentifier, for: indexPath) - } + +public protocol CollectionSupplementaryItemConfig { + var kind: ReusableKind { get } + + func register(with collectionView: UICollectionView) + func dequeueView(collectionView: UICollectionView, indexPath: IndexPath) -> UICollectionReusableView + func update(_ view: UICollectionReusableView, collectionView: UICollectionView, forIndex index: Int) + + func isEqual(_ other: CollectionSupplementaryItemConfig?) -> Bool } diff --git a/Sources/FunctionalTableData/CollectionView/CollectionSectionChangeSet.swift b/Sources/FunctionalTableData/CollectionView/CollectionSectionChangeSet.swift new file mode 100644 index 0000000..a18d3fc --- /dev/null +++ b/Sources/FunctionalTableData/CollectionView/CollectionSectionChangeSet.swift @@ -0,0 +1,332 @@ +// +// CollectionSectionChangeSet.swift +// FunctionalTableData +// +// Created by Jason Kemp on 2021-10-04. +// Copyright © 2021 Shopify. All rights reserved. + +import Foundation + +/// Compares two arrays of `CollectionSectionType`s and produces the operations +/// required to go from one to the other. This is a direct analog to `TableChangeSet` +public final class CollectionSectionChangeSet { + typealias MovedSection = Moved + typealias MovedRow = Moved + + var deletedSections = IndexSet() + var insertedSections = IndexSet() + var reloadedSections = IndexSet() + var movedSections: [MovedSection] = [] + + var deletedRows: [IndexPath] = [] + var insertedRows: [IndexPath] = [] + var reloadedRows: [IndexPath] = [] + var movedRows: [MovedRow] = [] + + struct Update { + let index: IndexPath + let cellConfig: CellConfigType + } + var updates: [Update] = [] + + let old: [CollectionSection] + let new: [CollectionSection] + let visibleIndexPaths: [IndexPath] + + public var isEmpty: Bool { + return deletedSections.isEmpty + && insertedSections.isEmpty + && reloadedSections.isEmpty + && movedSections.isEmpty + && deletedRows.isEmpty + && insertedRows.isEmpty + && reloadedRows.isEmpty + && movedRows.isEmpty + && updates.isEmpty + } + + public var count: Int { + var total = deletedSections.count + total += insertedSections.count + total += reloadedSections.count + total += movedSections.count + total += deletedRows.count + total += insertedRows.count + total += reloadedRows.count + total += movedRows.count + total += updates.count + return total + } + + init(old: [CollectionSection] = [], new: [CollectionSection] = [], visibleIndexPaths: [IndexPath] = []) { + self.old = old + self.new = new + self.visibleIndexPaths = visibleIndexPaths + + calculateChanges() + } + + /* + * This method calculates what set of operations are needed to go from an old list of sections to a new list of sections. + * It does this through a greedy algorithm, that goes through both lists at the same time. + * It won't always have the strictly optimal set of operations, because it only ever moves items up, not down. + * As an example, if we have the following two lists: + * + * old: ABCD + * new: CBDE + * + * It will iterate as follows + * oldIndex = 0, newIndex = 0 + * old item (A) is removed, so add delete(0) and oldIndex++ + * oldIndex = 1, newIndex = 0 + * new item (C) is further down the list than oldIndex currently is, so add move(2, 0) and newIndex++ + * oldIndex = 1, newIndex = 1 + * Both indices are currently pointing at the same item (B), so oldIndex++ and newIndex++ + * oldIndex = 2, newIndex = 2 + * old item (C) has been moved up (and we know the move operation has already been generated because we only every move up), so oldIndex++ + * oldIndex = 3, newIndex = 2 + * Both indices are currently pointing at the same item (D), so oldIndex++ and newIndex++ + * oldIndex = 4, newIndex = 3 + * new item (E) is not in the old list, so add insert(3) and newIndex++ + * Both lists are exhausted, final operations are delete(0), move(2, 0) and insert(3) + * + * Whenever a section was also in the previous list, we compare the sections and perform the exact same algorithm on the individual rows. + */ + private func calculateChanges() { + //Early return for empty cases + if old.isEmpty == true && new.isEmpty == true { + return + } else if old.isEmpty == true && new.isEmpty == false { + insertedSections.insert(integersIn: 0.. = Set() + var oldRows: [String: Int] = [:] + while oldSectionIndex < old.count || newSectionIndex < new.count { + // Skip over all the deleted or moved sections + while oldSectionIndex < old.count { + if !newSections.contains(old[oldSectionIndex].key) { + deletedSections.insert(oldSectionIndex) + oldSectionIndex += 1 + } else if movedSections.contains(where: { $0.from == oldSectionIndex }) { + oldSectionIndex += 1 + } else { + break + } + } + + // Insert and move up sections + while newSectionIndex < new.count { + if oldSectionIndex < old.count && new[newSectionIndex].key == old[oldSectionIndex].key { + // Skip over equal sections + repeat { + if headerOrFooterChanged(oldSectionIndex: oldSectionIndex, newSectionIndex: newSectionIndex) { + reloadedSections.insert(oldSectionIndex) + } else { + compareRows(newRows: &newRows, oldRows: &oldRows, oldSectionIndex: oldSectionIndex, newSectionIndex: newSectionIndex) + } + oldSectionIndex += 1 + newSectionIndex += 1 + } while oldSectionIndex < old.count && + newSectionIndex < new.count && + new[newSectionIndex].key == old[oldSectionIndex].key + // Break because there might be sections that need to be deleted + break + } else if let oldSectionIndexLocation = oldSections[new[newSectionIndex].key] { + // Move up existing section + assert(oldSectionIndexLocation > oldSectionIndex) + movedSections.append(MovedSection( + from: oldSectionIndexLocation, + to: newSectionIndex)) + compareRows(newRows: &newRows, oldRows: &oldRows, oldSectionIndex: oldSectionIndexLocation, newSectionIndex: newSectionIndex) + newSectionIndex += 1 + } else { + insertedSections.insert(newSectionIndex) + newSectionIndex += 1 + } + } + } + } + + private func isRow(new: (section: CollectionSection, row: Int), equalTo old: (section: CollectionSection, row: Int)) -> Bool { + let newRow = new.section.items[new.row] + let oldRow = old.section.items[old.row] + return newRow.isEqual(oldRow) + } + + private func headerOrFooterChanged(oldSectionIndex: Int, newSectionIndex: Int) -> Bool { + let oldHeader = old[oldSectionIndex].header + let newHeader = new[newSectionIndex].header + guard oldHeader?.isEqual(newHeader) ?? (newHeader == nil) else { + return true + } + + let oldFooter = old[oldSectionIndex].footer + let newFooter = new[newSectionIndex].footer + guard oldFooter?.isEqual(newFooter) ?? (newFooter == nil) else { + return true + } + + return false + } + + private func compareRows(newRows: inout Set, oldRows: inout [String: Int], oldSectionIndex: Int, newSectionIndex: Int) { + // Clear the set and dictionary, ensuring we keep the capacity to reduce allocations + newRows.removeAll(keepingCapacity: true) + oldRows.removeAll(keepingCapacity: true) + + let oldSection = old[oldSectionIndex] + let newSection = new[newSectionIndex] + for newRow in newSection.items { + newRows.insert(newRow.key) + } + for (oldSectionIndex, oldRow) in oldSection.items.enumerated() { + oldRows[oldRow.key] = oldSectionIndex + } + + var oldRowIndex = 0 + var newRowIndex = 0 + while oldRowIndex < oldSection.items.count || newRowIndex < newSection.items.count { + // Skip over deleted and moved rows + while oldRowIndex < oldSection.items.count { + if !newRows.contains(oldSection.items[oldRowIndex].key) { + deletedRows.append(IndexPath(row: oldRowIndex, section: oldSectionIndex)) + oldRowIndex += 1 + } else if movedRows.contains(where: { $0.from == IndexPath(row: oldRowIndex, section: oldSectionIndex) }) { + oldRowIndex += 1 + } else { + break + } + } + + // Insert and move up rows + while newRowIndex < newSection.items.count { + if oldRowIndex < oldSection.items.count && + newSection.items[newRowIndex].key == oldSection.items[oldRowIndex].key { + // Skip over all the rows that are the same (as a tight loop here because it's the most common case) + repeat { + let newRow = newSection.items[newRowIndex] + // Compare existing row + if visibleIndexPaths.contains(IndexPath(row: oldRowIndex, section: oldSectionIndex)) && !isRow(new: (section: newSection, row: newRowIndex), equalTo: (section: oldSection, row: oldRowIndex)) { + if newRow.isSameKind(as: oldSection.items[oldRowIndex]) { + updates.append(Update( + index: IndexPath(row: newRowIndex, section: newSectionIndex), + cellConfig: newRow + )) + } else { + reloadedRows.append(IndexPath(row: oldRowIndex, section: oldSectionIndex)) + } + } + oldRowIndex += 1 + newRowIndex += 1 + } while oldRowIndex < oldSection.items.count && + newRowIndex < newSection.items.count && + newSection.items[newRowIndex].key == oldSection.items[oldRowIndex].key + // Break because there might be rows that need to be deleted + break + } else if let oldRowIndexLocation = oldRows[newSection.items[newRowIndex].key] { + let newRow = newSection.items[newRowIndex] + // Move up existing row + assert(oldRowIndexLocation > oldRowIndex) + movedRows.append(MovedRow( + from: IndexPath(row: oldRowIndexLocation, section: oldSectionIndex), + to: IndexPath(row: newRowIndex, section: newSectionIndex))) + if visibleIndexPaths.contains(IndexPath(row: oldRowIndexLocation, section: oldSectionIndex)) && !isRow(new: (section: newSection, row: newRowIndex), equalTo: (section: oldSection, row: oldRowIndexLocation)) { + if newRow.isSameKind(as: oldSection.items[oldRowIndexLocation]) { + updates.append(Update( + index: IndexPath(row: newRowIndex, section: newSectionIndex), + cellConfig: newRow + )) + } else { + reloadedRows.append(IndexPath(row: oldRowIndexLocation, section: oldSectionIndex)) + } + } + newRowIndex += 1 + } else { + // Insert new row + insertedRows.append(IndexPath(row: newRowIndex, section: newSectionIndex)) + newRowIndex += 1 + } + } + } + } + + public func jsonDebugInfo() -> [String: Any] { + var debugSections: [[String: Any]] = [] + for index in insertedSections { + debugSections.append([ + "operation": "insert", + "index": index, + ]) + } + for index in deletedSections { + debugSections.append([ + "operation": "deleted", + "index": index, + ]) + } + for index in reloadedSections { + debugSections.append([ + "operation": "update", + "index": index, + ]) + } + for move in movedSections { + debugSections.append([ + "operation": "move", + "from": move.from, + "to": move.to, + ]) + } + + func stringFromIndexPath(_ indexPath: IndexPath) -> String { + return "\(indexPath.section),\(indexPath.row)" + } + var debugRows: [[String: Any]] = [] + for indexPath in insertedRows { + debugRows.append([ + "operation": "insert", + "indexPath": stringFromIndexPath(indexPath), + ]) + } + for indexPath in deletedRows { + debugRows.append([ + "operation": "deleted", + "indexPath": stringFromIndexPath(indexPath), + ]) + } + for indexPath in reloadedRows { + debugRows.append([ + "operation": "update", + "indexPath": stringFromIndexPath(indexPath), + ]) + } + for move in movedRows { + debugRows.append([ + "operation": "move", + "from": stringFromIndexPath(move.from), + "to": stringFromIndexPath(move.to), + ]) + } + + return [ + "sections": debugSections, + "rows": debugRows, + ] + } +} diff --git a/Sources/FunctionalTableData/CollectionView/ConfigurableView.swift b/Sources/FunctionalTableData/CollectionView/ConfigurableView.swift new file mode 100644 index 0000000..f8e2b1a --- /dev/null +++ b/Sources/FunctionalTableData/CollectionView/ConfigurableView.swift @@ -0,0 +1,65 @@ +// +// ConfigurableView.swift +// FunctionalTableData +// +// Created by Jason Kemp on 2021-10-19. +// Copyright © 2021 Shopify. All rights reserved. + +import UIKit + +public protocol ReusableView { + func prepareForReuse() +} + +public protocol ConfigurableView: ReusableView { + associatedtype State + + func configure(_ state: State) +} + +/// Represents a view that can be highlighted. +/// Intended for custom highlighting. Set CellStyle.highlight to false and implement this protocol to customize how the +/// cell is highlighted. +public protocol HighlightableView: UIView { + var isHighlighted: Bool { get set } +} + +public final class ConfigurableCollectionCell: UICollectionViewCell where View.State == State { + let view: View + + override public var isHighlighted: Bool { + get { super.isHighlighted } + set { + super.isHighlighted = newValue + if let view = view as? HighlightableView { + view.isHighlighted = newValue + } + } + } + + public override init(frame: CGRect) { + view = View() + super.init(frame: frame) + contentView.addSubview(view) + view.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + view.topAnchor.constraint(equalTo: contentView.topAnchor), + view.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + view.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + view.bottomAnchor.constraint(equalTo: contentView.bottomAnchor) + ]) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public override func prepareForReuse() { + super.prepareForReuse() + view.prepareForReuse() + } + + public func configure(_ state: State) { + view.configure(state) + } +} diff --git a/Sources/FunctionalTableData/CollectionView/FunctionalCollectionData+ClassicDiff.swift b/Sources/FunctionalTableData/CollectionView/FunctionalCollectionData+ClassicDiff.swift new file mode 100644 index 0000000..f485a7e --- /dev/null +++ b/Sources/FunctionalTableData/CollectionView/FunctionalCollectionData+ClassicDiff.swift @@ -0,0 +1,201 @@ +// +// FunctionalCollectionData+ClassicDiff.swift +// FunctionalTableData +// +// Created by Jason Kemp on 2021-10-27. +// Copyright © 2021 Shopify. All rights reserved. + +import UIKit + +final class ClassicFunctionalCollectionDataDiffer: FunctionalCollectionDataDiffer { + + var collectionView: UICollectionView? { + didSet { + guard let collectionView = collectionView else { return } + collectionView.dataSource = dataSource + } + } + + var isRendering: Bool { renderAndDiffQueue.isSuspended } + + func renderAndDiff(_ newSections: [CollectionSection], animated: Bool, completion: (() -> Void)?) { + let blockOperation = BlockOperation { [weak self] in + guard let strongSelf = self else { + if let completion = completion { + DispatchQueue.main.async(execute: completion) + } + return + } + + if strongSelf.unitTesting { + newSections.validateKeyUniqueness(senderName: strongSelf.name) + } else { + NSException.catchAndRethrow({ + newSections.validateKeyUniqueness(senderName: strongSelf.name) + }, failure: { + if $0.name == NSExceptionName.internalInconsistencyException { + guard let exceptionHandler = FunctionalCollectionData.exceptionHandler else { return } + let changes = CollectionSectionChangeSet() + let viewFrame = DispatchQueue.main.sync { strongSelf.collectionView?.frame ?? .zero } + let exception = FunctionalCollectionData.Exception(name: $0.name.rawValue, newSections: newSections, oldSections: strongSelf.sections, changes: changes, visible: [], viewFrame: viewFrame, reason: $0.reason, userInfo: $0.userInfo) + exceptionHandler.handle(exception: exception) + } + }) + } + + strongSelf.doRenderAndDiff(newSections, animated: animated, completion: completion) + } + renderAndDiffQueue.addOperation(blockOperation) + } + private let name: String + private let dataSource: FunctionalCollectionData.DataSource + private let renderAndDiffQueue: OperationQueue + private let unitTesting: Bool + + private var data: CollectionData { dataSource.data } + private var sections: [CollectionSection] { data.sections } + + init(name: String, data: CollectionData) { + self.name = name + dataSource = FunctionalCollectionData.DataSource(data: data) + unitTesting = NSClassFromString("XCTestCase") != nil + + renderAndDiffQueue = OperationQueue() + renderAndDiffQueue.name = self.name + renderAndDiffQueue.maxConcurrentOperationCount = 1 + } + + private func doRenderAndDiff(_ newSections: [CollectionSection], animated: Bool, completion: (() -> Void)?) { + guard let collectionView = collectionView else { + if let completion = completion { + DispatchQueue.main.async(execute: completion) + } + return + } + + let oldSections = sections + + let visibleIndexPaths = DispatchQueue.main.sync { + collectionView.indexPathsForVisibleItems.filter { + let section = oldSections[$0.section] + return $0.item < section.items.count + } + } + + let localSections = newSections.filter { $0.items.count > 0 } + let changes = calculateTableChanges(oldSections: oldSections, newSections: localSections, visibleIndexPaths: visibleIndexPaths) + + // Use dispatch_sync because the collection updates have to be processed before this function returns + // or another queued renderAndDiff could get the incorrect state to diff against. + DispatchQueue.main.sync { [weak self] in + guard let self = self else { + completion?() + return + } + + self.renderAndDiffQueue.isSuspended = true + collectionView.registerCellsForSections(localSections) + if oldSections.isEmpty || changes.count > FunctionalCollectionData.reloadEntireTableThreshold || collectionView.isDecelerating || !animated { + + self.data.sections = localSections + + collectionView.reloadData() + self.finishRenderAndDiff() + completion?() + } else { + if self.unitTesting { + self.applyTableChanges(changes, localSections: localSections, completion: { + self.finishRenderAndDiff() + completion?() + }) + } else { + NSException.catchAndRethrow({ + self.applyTableChanges(changes, localSections: localSections, completion: { + self.finishRenderAndDiff() + completion?() + }) + }, failure: { exception in + if exception.name == NSExceptionName.internalInconsistencyException { + self.dumpDebugInfoForChanges(changes, previousSections: oldSections, visibleIndexPaths: visibleIndexPaths, exceptionReason: exception.reason, exceptionUserInfo: exception.userInfo) + } + }) + } + } + } + } + + internal func calculateTableChanges(oldSections: [CollectionSection], newSections: [CollectionSection], visibleIndexPaths: [IndexPath]) -> CollectionSectionChangeSet { + return CollectionSectionChangeSet(old: oldSections, new: newSections, visibleIndexPaths: visibleIndexPaths) + } + + private func applyTableChanges(_ changes: CollectionSectionChangeSet, localSections: [CollectionSection], completion: (() -> Void)?) { + guard let collectionView = collectionView else { + if let completion = completion { + DispatchQueue.main.async(execute: completion) + } + return + } + + if changes.isEmpty { + data.sections = localSections + if let completion = completion { + DispatchQueue.main.async(execute: completion) + } + return + } + + func applyTableSectionChanges(_ changes: CollectionSectionChangeSet) { + if !changes.insertedSections.isEmpty { + collectionView.insertSections(changes.insertedSections) + } + if !changes.deletedSections.isEmpty { + collectionView.deleteSections(changes.deletedSections) + } + for movedSection in changes.movedSections { + collectionView.moveSection(movedSection.from, toSection: movedSection.to) + } + if !changes.reloadedSections.isEmpty { + collectionView.reloadSections(changes.reloadedSections) + } + + if !changes.insertedRows.isEmpty { + collectionView.insertItems(at: changes.insertedRows) + } + if !changes.deletedRows.isEmpty { + collectionView.deleteItems(at: changes.deletedRows) + } + for movedRow in changes.movedRows { + collectionView.moveItem(at: movedRow.from, to: movedRow.to) + } + if !changes.reloadedRows.isEmpty { + collectionView.reloadItems(at: changes.reloadedRows) + } + } + + func applyTransitionChanges(_ changes: CollectionSectionChangeSet) { + for update in changes.updates { + if let cell = collectionView.cellForItem(at: update.index) { + update.cellConfig.update(cell: cell, in: collectionView) + } + } + } + + collectionView.performBatchUpdates({ + data.sections = localSections + applyTableSectionChanges(changes) + }) { finished in + applyTransitionChanges(changes) + completion?() + } + } + + private func finishRenderAndDiff() { + renderAndDiffQueue.isSuspended = false + } + + private func dumpDebugInfoForChanges(_ changes: CollectionSectionChangeSet, previousSections: [CollectionSection], visibleIndexPaths: [IndexPath], exceptionReason: String?, exceptionUserInfo: [AnyHashable: Any]?) { + guard let exceptionHandler = FunctionalCollectionData.exceptionHandler else { return } + let exception = FunctionalCollectionData.Exception(name: name, newSections: sections, oldSections: previousSections, changes: changes, visible: visibleIndexPaths, viewFrame: collectionView?.frame ?? .zero, reason: exceptionReason, userInfo: exceptionUserInfo) + exceptionHandler.handle(exception: exception) + } +} diff --git a/Sources/FunctionalTableData/CollectionView/FunctionalCollectionData+DiffableDataSource.swift b/Sources/FunctionalTableData/CollectionView/FunctionalCollectionData+DiffableDataSource.swift new file mode 100644 index 0000000..4068c68 --- /dev/null +++ b/Sources/FunctionalTableData/CollectionView/FunctionalCollectionData+DiffableDataSource.swift @@ -0,0 +1,102 @@ +// +// DiffableDataSourceFunctionalCollectionDataDiffer.swift +// FunctionalTableData +// +// Created by Jason Kemp on 2021-10-27. +// Copyright © 2021 Shopify. All rights reserved. + +import UIKit + +@available(iOS 13.0, *) +final class DiffableDataSourceFunctionalCollectionDataDiffer: FunctionalCollectionDataDiffer { + let name: String + let data: CollectionData + var sections: [CollectionSection] { data.sections } + var isRendering: Bool = false + var dataSource: UICollectionViewDiffableDataSource! + var collectionView: UICollectionView? { + didSet { + guard let collectionView = collectionView else { return } + let dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, cellConfig in + let section = self.data.sections[indexPath.section] + 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) + cellConfig.update(cell: cell, in: collectionView) + let style = cellConfig.style ?? CellStyle() + style.configure(cell: cell, at: indexPath, in: collectionView) + section.prepareCell(cell, in: collectionView, for: indexPath) + return cell + } + dataSource.supplementaryViewProvider = { (collectionView, kind, indexPath) in + let kind = ReusableKind(kind) + // we need to check the global header/footer first, because the collectionView will request them without a "valid" indexPath. + // there's a crashing exception or an assertion failure if we have an indexPath with only one index and call IndexPath.section + if let header = self.data.header, kind == header.kind { + let headerView = header.dequeueView(collectionView: collectionView, indexPath: indexPath) + header.update(headerView, collectionView: collectionView, forIndex: -1) + return headerView + } + if let footer = self.data.footer, kind == footer.kind { + let footerView = footer.dequeueView(collectionView: collectionView, indexPath: indexPath) + footer.update(footerView, collectionView: collectionView, forIndex: -1) + return footerView + } + let sectionData = self.data.sections[indexPath.section] + guard let reusableKindConfig = sectionData.supplementaryConfig(ofKind: kind) else { + fatalError("We MUST return a non-null UICollectionReusableView that was previously registered with the collectionView. There's a crash otherwise. If you're seeing this error, check to see if you have registered a view of kind \(kind).") + } + let reusableView = reusableKindConfig.dequeueView(collectionView: collectionView, indexPath: indexPath) + reusableKindConfig.update(reusableView, collectionView: collectionView, forIndex: indexPath.item) + return reusableView + } + self.dataSource = dataSource + } + } + + func renderAndDiff(_ newSections: [CollectionSection], animated: Bool, completion: (() -> Void)?) { + isRendering = true + let indexPaths = collectionView?.indexPathsForVisibleItems ?? [] + let localSections = newSections.filter { $0.items.count > 0 } + collectionView?.registerCellsForSections(localSections) + let oldSections = data.sections + 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 { + self.dataSource?.apply(snapshot, animatingDifferences: shouldAnimate, completion: completion) + } failure: { exception in + if exception.name == NSExceptionName.internalInconsistencyException { + + dumpDebugInfoForChanges(changeSet, + previousSections: oldSections, + visibleIndexPaths: indexPaths, + exceptionReason: exception.reason, + exceptionUserInfo: exception.userInfo) + } + } + + } + + init(name: String, data: CollectionData) { + self.name = name + self.data = data + } + + private func dumpDebugInfoForChanges(_ changes: CollectionSectionChangeSet, previousSections: [CollectionSection], visibleIndexPaths: [IndexPath], exceptionReason: String?, exceptionUserInfo: [AnyHashable: Any]?) { + guard let exceptionHandler = FunctionalCollectionData.exceptionHandler else { return } + let exception = FunctionalCollectionData.Exception(name: name, newSections: sections, oldSections: previousSections, changes: changes, visible: visibleIndexPaths, viewFrame: collectionView?.frame ?? .zero, reason: exceptionReason, userInfo: exceptionUserInfo) + exceptionHandler.handle(exception: exception) + } +} diff --git a/Sources/FunctionalTableData/CollectionView/FunctionalCollectionData+UICollectionViewDataSource.swift b/Sources/FunctionalTableData/CollectionView/FunctionalCollectionData+UICollectionViewDataSource.swift index aed20f3..ebe1467 100644 --- a/Sources/FunctionalTableData/CollectionView/FunctionalCollectionData+UICollectionViewDataSource.swift +++ b/Sources/FunctionalTableData/CollectionView/FunctionalCollectionData+UICollectionViewDataSource.swift @@ -10,9 +10,9 @@ import UIKit extension FunctionalCollectionData { class DataSource: NSObject, UICollectionViewDataSource { - private let data: TableData + let data: CollectionData - init(data: TableData) { + init(data: CollectionData) { self.data = data } @@ -21,36 +21,59 @@ extension FunctionalCollectionData { } public func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { - return data.sections[section].rows.count + return data.sections[section].items.count } public func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { let sectionData = data.sections[indexPath.section] let row = indexPath.item - let cellConfig = sectionData[row] + let cellConfig = sectionData.items[row] let cell = cellConfig.dequeueCell(from: collectionView, at: indexPath) let accessibilityIdentifier = ItemPath(sectionKey: sectionData.key, itemKey: cellConfig.key).description cellConfig.accessibility.with(defaultIdentifier: accessibilityIdentifier).apply(to: cell) cellConfig.update(cell: cell, in: collectionView) let style = cellConfig.style ?? CellStyle() style.configure(cell: cell, at: indexPath, in: collectionView) - + sectionData.prepareCell(cell, in: collectionView, for: indexPath) return cell } + public func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView { + let kind = ReusableKind(kind) + // we need to check the global header/footer first, because the collectionView will request them without a "valid" indexPath. + // there's a crashing exception or an assertion failure if we have an indexPath with only one index and call IndexPath.section + if let header = data.header, kind == header.kind { + let headerView = header.dequeueView(collectionView: collectionView, indexPath: indexPath) + header.update(headerView, collectionView: collectionView, forIndex: -1) + return headerView + } + if let footer = data.footer, kind == footer.kind { + let footerView = footer.dequeueView(collectionView: collectionView, indexPath: indexPath) + footer.update(footerView, collectionView: collectionView, forIndex: -1) + return footerView + } + let sectionData = data.sections[indexPath.section] + guard let reusableKindConfig = sectionData.supplementaryConfig(ofKind: kind) else { + fatalError("We MUST return a non-null UICollectionReusableView that was previously registered with the collectionView. There's a crash otherwise. If you're seeing this error, check to see if you have registered a view of kind \(kind).") + } + let reusableView = reusableKindConfig.dequeueView(collectionView: collectionView, indexPath: indexPath) + reusableKindConfig.update(reusableView, collectionView: collectionView, forIndex: indexPath.item) + return reusableView + } + public func collectionView(_ collectionView: UICollectionView, moveItemAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) { // Should only ever be moving within section assert(sourceIndexPath.section == destinationIndexPath.section) // Update internal state to match move - let cell = data.sections[sourceIndexPath.section].rows.remove(at: sourceIndexPath.item) - data.sections[destinationIndexPath.section].rows.insert(cell, at: destinationIndexPath.item) + let cell = data.sections[sourceIndexPath.section].items.remove(at: sourceIndexPath.item) + data.sections[destinationIndexPath.section].items.insert(cell, at: destinationIndexPath.item) data.sections[sourceIndexPath.section].didMoveRow?(sourceIndexPath.item, destinationIndexPath.item) } public func collectionView(_ collectionView: UICollectionView, canMoveItemAt indexPath: IndexPath) -> Bool { - return data.sections[indexPath]?.actions.canBeMoved ?? false + return data[indexPath]?.actions.canBeMoved ?? false } } } diff --git a/Sources/FunctionalTableData/CollectionView/FunctionalCollectionData+UICollectionViewDelegate.swift b/Sources/FunctionalTableData/CollectionView/FunctionalCollectionData+UICollectionViewDelegate.swift index 483acb1..c1e97a9 100644 --- a/Sources/FunctionalTableData/CollectionView/FunctionalCollectionData+UICollectionViewDelegate.swift +++ b/Sources/FunctionalTableData/CollectionView/FunctionalCollectionData+UICollectionViewDelegate.swift @@ -13,9 +13,9 @@ extension FunctionalCollectionData { weak var scrollViewDelegate: UIScrollViewDelegate? var backwardsCompatScrollViewDelegate = ScrollViewDelegate() - private let data: TableData + private let data: CollectionData - init(data: TableData) { + init(data: CollectionData) { self.data = data } @@ -47,12 +47,12 @@ extension FunctionalCollectionData { } public func collectionView(_ collectionView: UICollectionView, shouldHighlightItemAt indexPath: IndexPath) -> Bool { - return data.sections[indexPath]?.actions.selectionAction != nil + return data[indexPath]?.actions.selectionAction != nil } public func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { guard let cell = collectionView.cellForItem(at: indexPath) else { return } - let cellConfig = data.sections[indexPath] + let cellConfig = data[indexPath] let selectionState = cellConfig?.actions.selectionAction?(cell) ?? .deselected if selectionState == .deselected { @@ -64,7 +64,7 @@ extension FunctionalCollectionData { public func collectionView(_ collectionView: UICollectionView, didDeselectItemAt indexPath: IndexPath) { guard let cell = collectionView.cellForItem(at: indexPath) else { return } - let cellConfig = data.sections[indexPath] + let cellConfig = data[indexPath] let selectionState = cellConfig?.actions.deselectionAction?(cell) ?? .deselected if selectionState == .selected { @@ -77,25 +77,25 @@ extension FunctionalCollectionData { public func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { guard indexPath.section < data.sections.count else { return } - if let cellConfig = data.sections[indexPath] { + if let cellConfig = data[indexPath] { cellConfig.actions.visibilityAction?(cell, true) return } } public func collectionView(_ collectionView: UICollectionView, didEndDisplaying cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { - if let cellConfig = data.sections[indexPath] { + if let cellConfig = data[indexPath] { cellConfig.actions.visibilityAction?(cell, false) return } } public func collectionView(_ collectionView: UICollectionView, shouldShowMenuForItemAt indexPath: IndexPath) -> Bool { - return data.sections[indexPath]?.actions.canPerformAction != nil + return data[indexPath]?.actions.canPerformAction != nil } public func collectionView(_ collectionView: UICollectionView, canPerformAction action: Selector, forItemAt indexPath: IndexPath, withSender sender: Any?) -> Bool { - return data.sections[indexPath]?.actions.canPerformAction?(action) ?? false + return data[indexPath]?.actions.canPerformAction?(action) ?? false } public func collectionView(_ collectionView: UICollectionView, performAction action: Selector, forItemAt indexPath: IndexPath, withSender sender: Any?) { @@ -107,7 +107,7 @@ extension FunctionalCollectionData { return originalIndexPath } - guard let proposedCell = data.sections[proposedIndexPath], proposedCell.actions.canBeMoved else { + guard let proposedCell = data[proposedIndexPath], proposedCell.actions.canBeMoved else { return originalIndexPath } @@ -117,13 +117,13 @@ extension FunctionalCollectionData { @available(iOS 13.0, *) public func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { guard data.sections.indices.contains(indexPath.section), - data.sections[indexPath.section].rows.indices.contains(indexPath.row) + data.sections[indexPath.section].items.indices.contains(indexPath.row) else { return nil } let section = data.sections[indexPath.section] - let row = section.rows[indexPath.row] - let itemPath = ItemPath(sectionKey: section.key, itemKey: row.key) - let cellConfig = data.sections[indexPath] + let item = section.items[indexPath.row] + let itemPath = ItemPath(sectionKey: section.key, itemKey: item.key) + let cellConfig = data[indexPath] return cellConfig?.actions.contextMenuConfiguration?.asUIContextMenuConfiguration(with: ItemPathCopyable(itemPath: itemPath)) } @@ -133,9 +133,9 @@ extension FunctionalCollectionData { let keyPath = itemPathCopyable.itemPath guard let sectionIndex = data.sections.firstIndex(where: { $0.key == keyPath.sectionKey }), - let rowIndex = data.sections[sectionIndex].rows.firstIndex(where: { $0.key == keyPath.itemKey }) + let rowIndex = data.sections[sectionIndex].items.firstIndex(where: { $0.key == keyPath.itemKey }) else { return } - let cellConfig = data.sections[sectionIndex].rows[rowIndex] + let cellConfig = data.sections[sectionIndex].items[rowIndex] animator.addCompletion { cellConfig.actions.contextMenuConfiguration?.previewContentCommitter?(animator.previewViewController) diff --git a/Sources/FunctionalTableData/CollectionView/FunctionalCollectionData.swift b/Sources/FunctionalTableData/CollectionView/FunctionalCollectionData.swift index 3e34935..08cd623 100644 --- a/Sources/FunctionalTableData/CollectionView/FunctionalCollectionData.swift +++ b/Sources/FunctionalTableData/CollectionView/FunctionalCollectionData.swift @@ -8,31 +8,44 @@ import UIKit +public protocol FunctionalCollectionDataExceptionHandler { + func handle(exception: FunctionalCollectionData.Exception) +} + +protocol FunctionalCollectionDataDiffer { + var collectionView: UICollectionView? { get set } + var isRendering: Bool { get } + func renderAndDiff(_ newSections: [CollectionSection], animated: Bool, completion: (() -> Void)?) +} + /// A renderer for `UICollectionView`. /// /// By providing a complete description of your view state using an array of `TableSection`. `FunctionalCollectionData` compares it with the previous render call to insert, update, and remove everything that have changed. This massively simplifies state management of complex UI. public class FunctionalCollectionData { + /// A type that provides the information about an exception. + public struct Exception { + public let name: String + public let newSections: [CollectionSection] + public let oldSections: [CollectionSection] + public let changes: CollectionSectionChangeSet + public let visible: [IndexPath] + public let viewFrame: CGRect + public let reason: String? + public let userInfo: [AnyHashable: Any]? + } /// Specifies the desired exception handling behaviour. - public static var exceptionHandler: FunctionalTableDataExceptionHandler? + public static var exceptionHandler: FunctionalCollectionDataExceptionHandler? public typealias KeyPath = ItemPath - - private func dumpDebugInfoForChanges(_ changes: TableSectionChangeSet, previousSections: [TableSection], visibleIndexPaths: [IndexPath], exceptionReason: String?, exceptionUserInfo: [AnyHashable: Any]?) { - guard let exceptionHandler = FunctionalTableData.exceptionHandler else { return } - let exception = FunctionalTableData.Exception(name: name, newSections: sections, oldSections: previousSections, changes: changes, visible: visibleIndexPaths, viewFrame: collectionView?.frame ?? .zero, reason: exceptionReason, userInfo: exceptionUserInfo) - exceptionHandler.handle(exception: exception) - } - - private let data = TableData() - private var sections: [TableSection] { + + private let data = CollectionData() + var sections: [CollectionSection] { return data.sections } - private static let reloadEntireTableThreshold = 20 + static let reloadEntireTableThreshold = 20 - private let renderAndDiffQueue: OperationQueue private let name: String - let dataSource: DataSource let delegate: Delegate /// Enclosing `UICollectionView` that presents all the `TableSection` data. @@ -42,13 +55,15 @@ public class FunctionalCollectionData { public var collectionView: UICollectionView? { didSet { guard let collectionView = collectionView else { return } - collectionView.dataSource = dataSource + differ.collectionView = collectionView collectionView.delegate = delegate + data.header?.register(with: collectionView) + data.footer?.register(with: collectionView) } } public subscript(indexPath: IndexPath) -> CellConfigType? { - return sections[indexPath] + return sections[indexPath.section].items[indexPath.item] } /// An object to receive various [UIScrollViewDelegate](https://developer.apple.com/documentation/uikit/uiscrollviewdelegate) related events @@ -65,24 +80,25 @@ public class FunctionalCollectionData { } } - private let unitTesting: Bool - /// A Boolean value that returns `true` when a `renderAndDiff` pass is currently running. - public var isRendering: Bool { - return renderAndDiffQueue.isSuspended - } + public var isRendering: Bool { differ.isRendering } + + private let diffingStrategy: DiffingStrategy + + private lazy var differ: FunctionalCollectionDataDiffer = { + if #available(iOS 13.0, *), self.diffingStrategy == .diffableDataSource { + return DiffableDataSourceFunctionalCollectionDataDiffer(name: self.name, data: data) + } + return ClassicFunctionalCollectionDataDiffer(name: self.name, data: data) + }() /// Initializes a FunctionalCollectionData. To configure its view, provide a UICollectionView after initialization. /// /// - Parameter name: String identifying this instance of FunctionalCollectionData, useful when several instances are displayed on the same screen. This also value names the queue doing all the rendering work, useful for debugging. - public init(name: String? = nil) { + public init(name: String? = nil, diffingStrategy: DiffingStrategy = .classic) { self.name = name ?? "FunctionalCollectionDataRenderAndDiff" - unitTesting = NSClassFromString("XCTestCase") != nil - renderAndDiffQueue = OperationQueue() - renderAndDiffQueue.name = self.name - renderAndDiffQueue.maxConcurrentOperationCount = 1 + self.diffingStrategy = diffingStrategy - self.dataSource = DataSource(data: data) self.delegate = Delegate(data: data) } @@ -91,8 +107,8 @@ public class FunctionalCollectionData { /// - Parameter keyPath: A key path identifying the cell to look up. /// - Returns: A `CellConfigType` instance corresponding to the key path or `nil` if the key path is invalid. public func rowForKeyPath(_ keyPath: KeyPath) -> CellConfigType? { - if let sectionIndex = sections.firstIndex(where: { $0.key == keyPath.sectionKey }), let rowIndex = sections[sectionIndex].rows.firstIndex(where: { $0.key == keyPath.itemKey }) { - return sections[sectionIndex].rows[rowIndex] + if let sectionIndex = sections.firstIndex(where: { $0.key == keyPath.sectionKey }), let rowIndex = sections[sectionIndex].items.firstIndex(where: { $0.key == keyPath.itemKey }) { + return sections[sectionIndex].items[rowIndex] } return nil @@ -104,8 +120,8 @@ public class FunctionalCollectionData { /// - Returns: A `ItemPath` that matches the key or `nil` if there is no match. public func keyPathForRowKey(_ key: String) -> ItemPath? { for section in sections { - for row in section where row.key == key { - return ItemPath(sectionKey: section.key, itemKey: row.key) + for item in section.items where item.key == key { + return ItemPath(sectionKey: section.key, itemKey: item.key) } } @@ -120,25 +136,20 @@ public class FunctionalCollectionData { /// - Returns: The key representation of the supplied `IndexPath`. public func keyPathForIndexPath(indexPath: IndexPath) -> ItemPath { let section = sections[indexPath.section] - let row = section.rows[indexPath.item] - return ItemPath(sectionKey: section.key, itemKey: row.key) + let item = section.items[indexPath.item] + return ItemPath(sectionKey: section.key, itemKey: item.key) + } + + public func registerCollectionHeader(_ config: CollectionSupplementaryItemConfig) { + data.header = config + guard let collectionView = collectionView else { return } + config.register(with: collectionView) } - /// Populates the collection with the specified sections, and asynchronously updates the collection view to reflect the cells and sections that have changed. - /// - /// - Parameters: - /// - newSections: An array of TableSection instances to populate the collection with. These will replace the previous sections and update any cells that have changed between the old and new sections. - /// - keyPath: The key path identifying which cell to scroll into view after the render occurs. - /// - animated: `true` to animate the changes to the collection cells, or `false` if the `UICollectionView` should be updated with no animation. - /// - completion: Callback that will be called on the main thread once the `UICollectionView` has finished updating and animating any changes. - @available(*, deprecated, message: "Call `scroll(to:animated:scrollPosition:)` in the completion handler instead.") - public func renderAndDiff(_ newSections: [TableSection], keyPath: ItemPath?, animated: Bool = true, completion: (() -> Void)? = nil) { - renderAndDiff(newSections, animated: animated) { [weak self] in - if let strongSelf = self, let keyPath = keyPath { - strongSelf.scroll(to: keyPath) - } - completion?() - } + public func registerCollectionFooter(_ config: CollectionSupplementaryItemConfig) { + data.footer = config + guard let collectionView = collectionView else { return } + config.register(with: collectionView) } /// Populates the collection with the specified sections, and asynchronously updates the collection view to reflect the cells and sections that have changed. @@ -147,163 +158,11 @@ public class FunctionalCollectionData { /// - newSections: An array of TableSection instances to populate the collection with. These will replace the previous sections and update any cells that have changed between the old and new sections. /// - animated: `true` to animate the changes to the collection cells, or `false` if the `UICollectionView` should be updated with no animation. /// - completion: Callback that will be called on the main thread once the `UICollectionView` has finished updating and animating any changes. - public func renderAndDiff(_ newSections: [TableSection], animated: Bool = true, completion: (() -> Void)? = nil) { - let blockOperation = BlockOperation { [weak self] in - guard let strongSelf = self else { - if let completion = completion { - DispatchQueue.main.async(execute: completion) - } - return - } - - if strongSelf.unitTesting { - newSections.validateKeyUniqueness(senderName: strongSelf.name) - } else { - NSException.catchAndRethrow({ - newSections.validateKeyUniqueness(senderName: strongSelf.name) - }, failure: { - if $0.name == NSExceptionName.internalInconsistencyException { - guard let exceptionHandler = FunctionalTableData.exceptionHandler else { return } - let changes = TableSectionChangeSet() - let viewFrame = DispatchQueue.main.sync { strongSelf.collectionView?.frame ?? .zero } - let exception = FunctionalTableData.Exception(name: $0.name.rawValue, newSections: newSections, oldSections: strongSelf.sections, changes: changes, visible: [], viewFrame: viewFrame, reason: $0.reason, userInfo: $0.userInfo) - exceptionHandler.handle(exception: exception) - } - }) - } - - strongSelf.doRenderAndDiff(newSections, animated: animated, completion: completion) - } - renderAndDiffQueue.addOperation(blockOperation) - } - - private func doRenderAndDiff(_ newSections: [TableSection], animated: Bool, completion: (() -> Void)?) { - guard let collectionView = collectionView else { - if let completion = completion { - DispatchQueue.main.async(execute: completion) - } - return - } - - let oldSections = sections - - let visibleIndexPaths = DispatchQueue.main.sync { - collectionView.indexPathsForVisibleItems.filter { - let section = oldSections[$0.section] - return $0.item < section.rows.count - } - } - - let localSections = newSections.filter { $0.rows.count > 0 } - let changes = calculateTableChanges(oldSections: oldSections, newSections: localSections, visibleIndexPaths: visibleIndexPaths) - - // Use dispatch_sync because the collection updates have to be processed before this function returns - // or another queued renderAndDiff could get the incorrect state to diff against. - DispatchQueue.main.sync { [weak self] in - guard let self = self else { - completion?() - return - } - - self.renderAndDiffQueue.isSuspended = true - collectionView.registerCellsForSections(localSections) - if oldSections.isEmpty || changes.count > FunctionalCollectionData.reloadEntireTableThreshold || collectionView.isDecelerating || !animated { - - self.data.sections = localSections - - collectionView.reloadData() - self.finishRenderAndDiff() - completion?() - } else { - if self.unitTesting { - self.applyTableChanges(changes, localSections: localSections, completion: { - self.finishRenderAndDiff() - completion?() - }) - } else { - NSException.catchAndRethrow({ - self.applyTableChanges(changes, localSections: localSections, completion: { - self.finishRenderAndDiff() - completion?() - }) - }, failure: { exception in - if exception.name == NSExceptionName.internalInconsistencyException { - self.dumpDebugInfoForChanges(changes, previousSections: oldSections, visibleIndexPaths: visibleIndexPaths, exceptionReason: exception.reason, exceptionUserInfo: exception.userInfo) - } - }) - } - } - } + public func renderAndDiff(_ newSections: [CollectionSection], animated: Bool = true, completion: (() -> Void)? = nil) { + differ.renderAndDiff(newSections, animated: animated, completion: completion) } - private func applyTableChanges(_ changes: TableSectionChangeSet, localSections: [TableSection], completion: (() -> Void)?) { - guard let collectionView = collectionView else { - if let completion = completion { - DispatchQueue.main.async(execute: completion) - } - return - } - - if changes.isEmpty { - data.sections = localSections - if let completion = completion { - DispatchQueue.main.async(execute: completion) - } - return - } - - func applyTableSectionChanges(_ changes: TableSectionChangeSet) { - if !changes.insertedSections.isEmpty { - collectionView.insertSections(changes.insertedSections) - } - if !changes.deletedSections.isEmpty { - collectionView.deleteSections(changes.deletedSections) - } - for movedSection in changes.movedSections { - collectionView.moveSection(movedSection.from, toSection: movedSection.to) - } - if !changes.reloadedSections.isEmpty { - collectionView.reloadSections(changes.reloadedSections) - } - - if !changes.insertedRows.isEmpty { - collectionView.insertItems(at: changes.insertedRows) - } - if !changes.deletedRows.isEmpty { - collectionView.deleteItems(at: changes.deletedRows) - } - for movedRow in changes.movedRows { - collectionView.moveItem(at: movedRow.from, to: movedRow.to) - } - if !changes.reloadedRows.isEmpty { - collectionView.reloadItems(at: changes.reloadedRows) - } - } - - func applyTransitionChanges(_ changes: TableSectionChangeSet) { - for update in changes.updates { - if let cell = collectionView.cellForItem(at: update.index) { - update.cellConfig.update(cell: cell, in: collectionView) - - let section = sections[update.index.section] - let style = section.mergedStyle(for: update.index.item) - style.configure(cell: cell, at: update.index, in: collectionView) - } - } - } - - collectionView.performBatchUpdates({ - data.sections = localSections - applyTableSectionChanges(changes) - }) { finished in - applyTransitionChanges(changes) - completion?() - } - } - private func finishRenderAndDiff() { - renderAndDiffQueue.isSuspended = false - } /// Selects a row in the collection view identified by a key path. /// @@ -347,15 +206,21 @@ public class FunctionalCollectionData { /// - Parameter keyPath: The path representing the desired indexPath. /// - Returns: The IndexPath of the item at the provided keyPath. public func indexPathFromKeyPath(_ keyPath: ItemPath) -> IndexPath? { - if let sectionIndex = sections.firstIndex(where: { $0.key == keyPath.sectionKey }), let rowIndex = sections[sectionIndex].rows.firstIndex(where: { $0.key == keyPath.itemKey }) { + if let sectionIndex = sections.firstIndex(where: { $0.key == keyPath.sectionKey }), let rowIndex = sections[sectionIndex].items.firstIndex(where: { $0.key == keyPath.itemKey }) { return IndexPath(item: rowIndex, section: sectionIndex) } return nil } - internal func calculateTableChanges(oldSections: [TableSection], newSections: [TableSection], visibleIndexPaths: [IndexPath]) -> TableSectionChangeSet { - return TableSectionChangeSet(old: oldSections, new: newSections, visibleIndexPaths: visibleIndexPaths) + /// Returns the drawing area for a row identified by key path. + /// + /// - Parameter keyPath: A key path identifying the cell to look up. + /// - Returns: A rectangle defining the area in which the table view draws the row or `nil` if the key path is invalid. + public func rectForKeyPath(_ keyPath: ItemPath) -> CGRect? { + guard let indexPath = indexPathFromKeyPath(keyPath) else { return nil } + guard let layoutAttr = collectionView?.layoutAttributesForItem(at: indexPath) else { return nil } + return layoutAttr.frame } public func previewingContext(_ previewingContext: UIViewControllerPreviewing, viewControllerForLocation location: CGPoint) -> UIViewController? { @@ -367,12 +232,16 @@ public class FunctionalCollectionData { } extension UICollectionView { - fileprivate func registerCellsForSections(_ sections: [TableSection]) { + func registerCellsForSections(_ sections: [CollectionSection]) { for section in sections { - for cellConfig in section { + for cellConfig in section.items { cellConfig.register(with: self) } + for supplementaryConfig in section.supplementaries { + supplementaryConfig.register(with: self) + } } + } /// Initiates a layout pass of UICollectionView and its items. Necessary for calculating new diff --git a/Sources/FunctionalTableData/CollectionView/ReusableKind.swift b/Sources/FunctionalTableData/CollectionView/ReusableKind.swift new file mode 100644 index 0000000..3cd8845 --- /dev/null +++ b/Sources/FunctionalTableData/CollectionView/ReusableKind.swift @@ -0,0 +1,36 @@ +// +// ReusableKind.swift +// FunctionalTableData +// +// Created by Jason Kemp on 2021-10-15. +// Copyright © 2021 Shopify. All rights reserved. + +import Foundation + +/// A strongly-typed identifier for supplementary view kinds. +public struct ReusableKind: Equatable, Hashable, RawRepresentable, ExpressibleByStringLiteral, CustomStringConvertible { + public let rawValue: String + + public var description: String { rawValue } + + public init?(rawValue: String) { + self.rawValue = rawValue + } + + public init(stringLiteral: String) { + self.rawValue = stringLiteral + } + + public init(_ value: String) { + self.rawValue = value + } +} + +public extension ReusableKind { + /// Represents a header supplementary view kind + static let header: ReusableKind = "Header" + /// Represents a footer supplementary view kind + static let footer: ReusableKind = "Footer" + /// Represents a separator supplymentary view kind + static let separator: ReusableKind = "Separator" +} diff --git a/Sources/FunctionalTableData/CollectionView/ReusableSupplementaryView.swift b/Sources/FunctionalTableData/CollectionView/ReusableSupplementaryView.swift new file mode 100644 index 0000000..b77420f --- /dev/null +++ b/Sources/FunctionalTableData/CollectionView/ReusableSupplementaryView.swift @@ -0,0 +1,36 @@ +// +// ReusableSupplementaryView.swift +// FunctionalTableData +// +// Created by Jason Kemp on 2021-09-27. +// Copyright © 2021 Shopify. All rights reserved. +// + +import UIKit + +/// A container view for any supplementary view in a CollectionSection +public final class ReusableSupplementaryView: UICollectionReusableView { + public let view: V + + public override init(frame: CGRect) { + view = V() + super.init(frame: frame) + addSubview(view) + view.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + view.topAnchor.constraint(equalTo: topAnchor), + view.leadingAnchor.constraint(equalTo: leadingAnchor), + view.trailingAnchor.constraint(equalTo: trailingAnchor), + view.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) + } + + public required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public override func prepareForReuse() { + super.prepareForReuse() + view.prepareForReuse() + } +} diff --git a/Sources/FunctionalTableData/CollectionView/SeparatorView.swift b/Sources/FunctionalTableData/CollectionView/SeparatorView.swift new file mode 100644 index 0000000..701cb53 --- /dev/null +++ b/Sources/FunctionalTableData/CollectionView/SeparatorView.swift @@ -0,0 +1,76 @@ +// +// SeparatorView.swift +// FunctionalTableData +// +// Created by Jason Kemp on 2021-09-30. +// Copyright © 2021 Shopify. All rights reserved. + +import Foundation +import UIKit + +public struct SeparatorState: Hashable { + public let color: UIColor + public let leadingInset: CGFloat + public let trailingInset: CGFloat + public let isHidden: Bool + + public init(color: UIColor, leadingInset: CGFloat = 0.0, trailingInset: CGFloat = 0.0, isHidden: Bool = false) { + self.color = color + self.leadingInset = leadingInset + self.trailingInset = trailingInset + self.isHidden = isHidden + } +} + +extension SeparatorState { + init(style: Separator.Style?, color: UIColor = .clear) { + guard let style = style else { + isHidden = true + leadingInset = 0.0 + trailingInset = 0.0 + self.color = .clear + return + } + self.color = color + // the 20.0 here is to support conversion from TableCell, it's the value for leading/trailing layoutMargins + leadingInset = style.leadingInset.respectingLayoutMargins ? 20.0 + style.leadingInset.value : style.leadingInset.value + trailingInset = style.trailingInset.respectingLayoutMargins ? 20.0 : style.trailingInset.value + self.isHidden = false + } +} + +public final class SeparatorView: UIView, ConfigurableView { + let separator: UIView + var leadingConstraint: NSLayoutConstraint! + var trailingConstraint: NSLayoutConstraint! + + init() { + separator = UIView() + super.init(frame: .zero) + addSubview(separator) + separator.translatesAutoresizingMaskIntoConstraints = false + leadingConstraint = separator.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8.0) + trailingConstraint = separator.trailingAnchor.constraint(equalTo: trailingAnchor, constant: 0.0) + NSLayoutConstraint.activate([ + leadingConstraint, + trailingConstraint, + separator.topAnchor.constraint(equalTo: topAnchor), + separator.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public func prepareForReuse() { + separator.backgroundColor = .clear + } + + public func configure(_ state: SeparatorState) { + self.isHidden = state.isHidden + separator.backgroundColor = state.color + leadingConstraint.constant = state.leadingInset + trailingConstraint.constant = state.trailingInset + } +} diff --git a/Sources/FunctionalTableData/CollectionView/SupplementaryConfig.swift b/Sources/FunctionalTableData/CollectionView/SupplementaryConfig.swift new file mode 100644 index 0000000..532213e --- /dev/null +++ b/Sources/FunctionalTableData/CollectionView/SupplementaryConfig.swift @@ -0,0 +1,118 @@ +// +// SupplementaryConfig.swift +// FunctionalTableData +// +// Created by Jason Kemp on 2021-10-01. +// Copyright © 2021 Shopify. All rights reserved. + +import UIKit + +public struct Supplementary: CollectionSupplementaryItemConfig where View: UICollectionReusableView { + public let kind: ReusableKind + + public init(kind: ReusableKind) { + self.kind = kind + } + + public func register(with collectionView: UICollectionView) { + collectionView.register(View.self, forSupplementaryViewOfKind: kind.rawValue, withReuseIdentifier: View.reuseIdentifier) + } + + public func dequeueView(collectionView: UICollectionView, indexPath: IndexPath) -> UICollectionReusableView { + collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: View.reuseIdentifier, for: indexPath) + } + + public func update(_ view: UICollectionReusableView, collectionView: UICollectionView, forIndex index: Int) { + //intentionally blank + } + + public func isEqual(_ other: CollectionSupplementaryItemConfig?) -> Bool { + guard let other = other as? Supplementary else { return false } + return kind == other.kind + } +} + +public struct SupplementaryConfig: CollectionSupplementaryItemConfig, Hashable where View: UIView & ConfigurableView, State: Hashable, View.State == State { + public static func ==(lhs: SupplementaryConfig, rhs: SupplementaryConfig) -> Bool { + lhs.state == rhs.state && lhs.kind == rhs.kind + } + + public let state: State + private let supplementary: Supplementary> + + public init(kind: ReusableKind, state: State) { + self.supplementary = Supplementary>(kind: kind) + self.state = state + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(kind) + hasher.combine(state) + } + + public var kind: ReusableKind { supplementary.kind } + + public func register(with collectionView: UICollectionView) { + supplementary.register(with: collectionView) + } + + public func dequeueView(collectionView: UICollectionView, indexPath: IndexPath) -> UICollectionReusableView { + supplementary.dequeueView(collectionView: collectionView, indexPath: indexPath) + } + + public func update(_ view: UICollectionReusableView, collectionView: UICollectionView, forIndex index: Int) { + guard let view = view as? ReusableSupplementaryView else { return } + view.view.configure(state) + } + + public func isEqual(_ other: CollectionSupplementaryItemConfig?) -> Bool { + guard let other = other as? SupplementaryConfig else { return false } + return supplementary.isEqual(other.supplementary) && state == other.state + } +} + +public struct IndexableSupplementaryConfig: CollectionSupplementaryItemConfig, Hashable where View: UIView & ConfigurableView, State: Hashable, View.State == State { + public static func == (lhs: IndexableSupplementaryConfig, rhs: IndexableSupplementaryConfig) -> Bool { + lhs.state == rhs.state && lhs.kind == rhs.kind + } + + public let state: [State] + public let hideLast: Bool + private let supplementary: Supplementary> + + public init(kind: ReusableKind, state: [State], hideLast: Bool = true) { + self.supplementary = Supplementary>(kind: kind) + self.state = state + self.hideLast = hideLast + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(kind) + hasher.combine(state) + } + + public var kind: ReusableKind { supplementary.kind } + + public func register(with collectionView: UICollectionView) { + supplementary.register(with: collectionView) + } + + public func dequeueView(collectionView: UICollectionView, indexPath: IndexPath) -> UICollectionReusableView { + supplementary.dequeueView(collectionView: collectionView, indexPath: indexPath) + } + + public func update(_ view: UICollectionReusableView, collectionView: UICollectionView, forIndex index: Int) { + guard let view = view as? ReusableSupplementaryView, state.indices.contains(index) else { return } + view.isHidden = false + if state.indices.endIndex - 1 == index, hideLast { + view.isHidden = true + } else { + view.view.configure(state[index]) + } + } + + public func isEqual(_ other: CollectionSupplementaryItemConfig?) -> Bool { + guard let other = other as? IndexableSupplementaryConfig else { return false } + return supplementary.isEqual(other.supplementary) && state == other.state + } +} diff --git a/Sources/FunctionalTableData/CollectionView/UICollectionView+Reusable.swift b/Sources/FunctionalTableData/CollectionView/UICollectionView+Reusable.swift index 1611dd2..4a1e08a 100644 --- a/Sources/FunctionalTableData/CollectionView/UICollectionView+Reusable.swift +++ b/Sources/FunctionalTableData/CollectionView/UICollectionView+Reusable.swift @@ -18,7 +18,15 @@ public extension UICollectionView { fatalError("Failed to dequeue a cell with identifier \(cellType.reuseIdentifier) matching type \(cellType.self)") } return cell - } + } + + func register(viewClass: AnyClass?, forSupplementaryViewOfKind kind: ReusableKind, withReuseIdentifier reuseIdentifier: String) { + register(viewClass, forSupplementaryViewOfKind: kind.rawValue, withReuseIdentifier: reuseIdentifier) + } + + func dequeueReusableSupplementaryView(ofKind kind: ReusableKind, withReuseIdentifier reuseIdentifier: String, for indexPath: IndexPath) -> UICollectionReusableView { + return dequeueReusableSupplementaryView(ofKind: kind.rawValue, withReuseIdentifier: reuseIdentifier, for: indexPath) + } } -extension UICollectionViewCell: Reusable { } +extension UICollectionReusableView: Reusable { } diff --git a/Sources/FunctionalTableData/Extensions/Array+TableSection.swift b/Sources/FunctionalTableData/Extensions/Array+TableSection.swift index afd0a47..1d8d2c2 100644 --- a/Sources/FunctionalTableData/Extensions/Array+TableSection.swift +++ b/Sources/FunctionalTableData/Extensions/Array+TableSection.swift @@ -30,6 +30,28 @@ extension Array where Element: TableSectionType { } } +extension Array where Element == CollectionSection { + func validateKeyUniqueness(senderName: String) { + let sectionKeys = map { $0.key } + if Set(sectionKeys).count != count { + let dupKeys = map{ $0.key }.duplicates() + let reason = "\(senderName) : Duplicate Section keys" + let userInfo: [String: Any] = ["Duplicates": dupKeys] + NSException(name: NSExceptionName.internalInconsistencyException, reason: reason, userInfo: userInfo).raise() + } + + for section in self { + let itemKeys = section.items.map { $0.key } + if Set(itemKeys).count != section.items.count { + let dupKeys = section.items.duplicateKeys() + let reason = "\(senderName) : Duplicate Section.Row keys" + let userInfo: [String: Any] = ["Section": section.key, "Duplicates": dupKeys] + NSException(name: NSExceptionName.internalInconsistencyException, reason: reason, userInfo: userInfo).raise() + } + } + } +} + extension Array where Element: TableSectionType { func indexPath(from itemPath: ItemPath) -> IndexPath? { if let sectionIndex = self.firstIndex(where: { $0.key == itemPath.sectionKey }), let rowIndex = self[sectionIndex].rows.firstIndex(where: { $0.key == itemPath.rowKey }) { @@ -63,3 +85,9 @@ private extension Array where Element == CellConfigType { return map { $0.key }.duplicates() } } + +private extension Array where Element: HashableCellConfigType { + func duplicateKeys() -> [String] { + return map { $0.key }.duplicates() + } +} diff --git a/Sources/FunctionalTableData/Reusable.swift b/Sources/FunctionalTableData/Reusable.swift index 60a11f3..15a6382 100644 --- a/Sources/FunctionalTableData/Reusable.swift +++ b/Sources/FunctionalTableData/Reusable.swift @@ -9,7 +9,7 @@ import UIKit /// A type that identifies a dequeueable object. Used by `FunctionalTableData` to increase performance by reusing objects when it needs to, just like `UITableView` and `UICollectionView`. -public protocol Reusable: class { +public protocol Reusable: AnyObject { /// Unique identifier for the object. static var reuseIdentifier: String { get } } diff --git a/Sources/FunctionalTableData/TableSection.swift b/Sources/FunctionalTableData/TableSection.swift index 1f1776c..e3597e6 100644 --- a/Sources/FunctionalTableData/TableSection.swift +++ b/Sources/FunctionalTableData/TableSection.swift @@ -31,9 +31,11 @@ public protocol TableSectionType { /// `FunctionalTableData` deals in arrays of `TableSection` instances. Each section, at a minimum, has a string value unique within the table itself, and an array of `CellConfigType` instances that represent the items of the section. Additionally there may be a header and footer for the section. public struct TableSection: Sequence, TableSectionType { public let key: String - public var header: TableHeaderFooterConfigType? = nil - public var footer: TableHeaderFooterConfigType? = nil + public var header: TableHeaderFooterConfigType? + public var footer: TableHeaderFooterConfigType? + public var rows: [CellConfigType] + /// Specifies visual attributes to be applied to the section. This includes item separators to use at the top, bottom, and between items of the section. public var style: SectionStyle? public var headerVisibilityAction: ((_ view: UIView, _ visible: Bool) -> Void)? = nil @@ -114,6 +116,53 @@ public struct TableSection: Sequence, TableSectionType { } } +extension TableSection: CollectionSection { + public var items: [CellConfigType] { + get { rows } + set { rows = newValue } + } + + public var supplementaries: [CollectionSupplementaryItemConfig] { + get { + var supps: [CollectionSupplementaryItemConfig] = [] + if let header = header as? CollectionSupplementaryItemConfig { + supps.append(header) + } + if let footer = self.footer as? CollectionSupplementaryItemConfig { + supps.append(footer) + } + if hasSeparators { + let separator = IndexableSupplementaryConfig(kind: ReusableKind.separator, state: self.mergeStyles().map { SeparatorState(style: $0.bottomSeparator ?? nil, color: separatorColor($0)) }) + supps.append(separator) + } + return supps + } + set { + + } + } + + public func supplementaryConfig(ofKind kind: ReusableKind) -> CollectionSupplementaryItemConfig? { + return supplementaries.first(where: { $0.kind == kind }) + } + + public var hasSeparators: Bool { + mergeStyles().contains { $0.bottomSeparator != nil } + } + + public func prepareCell(_ cell: UICollectionViewCell, in collectionView: UICollectionView, for indexPath: IndexPath) { + // intentionally blank + } + + private func separatorColor(_ style: CellStyle) -> UIColor { + style.separatorColor ?? Separator.appearance().backgroundColor ?? .clear + } + + private func mergeStyles() -> [CellStyle] { + rows.indices.map(mergedStyle) + } +} + public struct SectionStyle: Equatable, Hashable { public struct Separators: Equatable, Hashable { public static let `default` = Separators(top: .full, bottom: .full, interitem: .inset) @@ -203,8 +252,8 @@ struct DiffableTableSection: Equatable, Hashable { let tableSection: TableSection var key: String { tableSection.key } - var anyRows: [AnyCellConfigType] { - return tableSection.rows.map { AnyCellConfigType($0, sectionKey: key) } + var anyRows: [AnyHashableConfig] { + return tableSection.rows.map { AnyHashableConfig($0, sectionKey: key) } } init(_ section: TableSection) { diff --git a/Sources/FunctionalTableData/TableView/FunctionalTableData+DiffableDataSource.swift b/Sources/FunctionalTableData/TableView/FunctionalTableData+DiffableDataSource.swift index 6937a4f..bde72f9 100644 --- a/Sources/FunctionalTableData/TableView/FunctionalTableData+DiffableDataSource.swift +++ b/Sources/FunctionalTableData/TableView/FunctionalTableData+DiffableDataSource.swift @@ -10,7 +10,7 @@ import UIKit extension FunctionalTableData { @available(iOS 13.0, *) - class DiffableDataSource: UITableViewDiffableDataSource { + class DiffableDataSource: UITableViewDiffableDataSource { let cellStyler: CellStyler var data: TableData { @@ -81,7 +81,7 @@ extension FunctionalTableData { let changeSet = TableSectionChangeSet(old: oldSections, new: localSections, visibleIndexPaths: indexPaths) datasource.data.sections = localSections - var snapshot = NSDiffableDataSourceSnapshot() + var snapshot = NSDiffableDataSourceSnapshot() let diffableSections = localSections.map { DiffableTableSection($0) } snapshot.appendSections(diffableSections) for newSection in diffableSections { diff --git a/Tests/FunctionalTableDataTests/CollectionSectionChangeSetTests.swift b/Tests/FunctionalTableDataTests/CollectionSectionChangeSetTests.swift new file mode 100644 index 0000000..b714e93 --- /dev/null +++ b/Tests/FunctionalTableDataTests/CollectionSectionChangeSetTests.swift @@ -0,0 +1,661 @@ +// +// CollectionSectionChangeSetTests.swift +// +// +// Created by Jason Kemp on 2021-10-29. +// + +import XCTest +@testable import FunctionalTableData + +class CollectionSectionChangeSetTests: XCTestCase { + func testIsEmpty() { + let changeset = CollectionSectionChangeSet() + XCTAssertTrue(changeset.isEmpty) + XCTAssertEqual(changeset.count, 0) + } + + func testAddingSectionsInsertsSections() { + let oldItems: [TableSection] = [] + let newItems: [TableSection] = [TableSection(key: "section1"), TableSection(key: "section2")] + + let changes = CollectionSectionChangeSet(old: oldItems, new: newItems, visibleIndexPaths: []) + + XCTAssertFalse(changes.isEmpty) + XCTAssertEqual(changes.count, 2) + XCTAssertEqual(changes.insertedSections, IndexSet([0, 1])) + } + + func testInsertSectionBefore() { + let oldItems: [TableSection] = [TableSection(key: "section2"), TableSection(key: "section3"), TableSection(key: "section4")] + let newItems: [TableSection] = [TableSection(key: "section1"), TableSection(key: "section2"), TableSection(key: "section3"), TableSection(key: "section4")] + + let changes = CollectionSectionChangeSet(old: oldItems, new: newItems, visibleIndexPaths: []) + + XCTAssertFalse(changes.isEmpty) + XCTAssertEqual(changes.count, 1) + XCTAssertEqual(changes.movedSections, []) + XCTAssertEqual(changes.insertedSections, IndexSet(integer: 0)) + XCTAssertEqual(changes.reloadedSections, IndexSet()) + XCTAssertEqual(changes.deletedSections, IndexSet()) + } + + func testInsertAndMoveDown() { + let oldItems: [TableSection] = [ + TableSection(key: "section2"), + TableSection(key: "section3") + ] + let newItems: [TableSection] = [ + TableSection(key: "section1"), + TableSection(key: "section4"), + TableSection(key: "section3"), + TableSection(key: "section2"), + ] + + let changes = CollectionSectionChangeSet(old: oldItems, new: newItems, visibleIndexPaths: []) + + XCTAssertFalse(changes.isEmpty) + XCTAssertEqual(changes.count, 3) + XCTAssertEqual(changes.movedSections, [CollectionSectionChangeSet.MovedSection(from: 1, to: 2)]) + XCTAssertEqual(changes.insertedSections, IndexSet([0, 1])) + XCTAssertEqual(changes.reloadedSections, IndexSet()) + XCTAssertEqual(changes.deletedSections, IndexSet()) + } + + func testSectionNotReloadedWithEqualHeaders() { + let oldItems: [TableSection] = [TableSection(key: "section1", header: TestHeaderFooter(state: TestHeaderFooterState(data: "green", kind: .header)))] + let newItems: [TableSection] = [TableSection(key: "section1", header: TestHeaderFooter(state: TestHeaderFooterState(data: "green", kind: .header)))] + let changes = CollectionSectionChangeSet(old: oldItems, new: newItems, visibleIndexPaths: []) + + XCTAssertTrue(changes.isEmpty) + XCTAssertEqual(changes.movedSections, []) + XCTAssertEqual(changes.insertedSections, IndexSet()) + XCTAssertEqual(changes.reloadedSections, IndexSet()) + XCTAssertEqual(changes.deletedSections, IndexSet()) + } + + func testSectionNotReloadedWithEqualFooters() { + let oldItems: [TableSection] = [TableSection(key: "section1", footer: TestHeaderFooter(state: TestHeaderFooterState(data: "blue", kind: .footer)))] + let newItems: [TableSection] = [TableSection(key: "section1", footer: TestHeaderFooter(state: TestHeaderFooterState(data: "blue", kind: .footer)))] + let changes = CollectionSectionChangeSet(old: oldItems, new: newItems, visibleIndexPaths: []) + + XCTAssertTrue(changes.isEmpty) + XCTAssertEqual(changes.movedSections, []) + XCTAssertEqual(changes.insertedSections, IndexSet()) + XCTAssertEqual(changes.reloadedSections, IndexSet()) + XCTAssertEqual(changes.deletedSections, IndexSet()) + } + + func testRowsComparedIfHeadersEqual() { + let oldItems: [TableSection] = [TableSection(key: "section1", header: TestHeaderFooter(state: TestHeaderFooterState(data: "blue", kind: .header)))] + let newItems: [TableSection] = [TableSection(key: "section1", rows: [ + TestCaseCell(key: "row1", state: TestCaseState(data: "red"), cellUpdater: TestCaseState.updateView) + ], header: TestHeaderFooter(state: TestHeaderFooterState(data: "blue", kind: .header)) + )] + let changes = CollectionSectionChangeSet(old: oldItems, new: newItems, visibleIndexPaths: []) + + XCTAssertFalse(changes.isEmpty) + XCTAssertEqual(changes.count, 1) + XCTAssertEqual(changes.reloadedSections, IndexSet()) + XCTAssertEqual(changes.insertedRows, [IndexPath(item: 0, section: 0)]) + } + + func testSectionReloadedWithUnequalHeaders() { + let oldItems: [TableSection] = [TableSection(key: "section1", rows: [ + TestCaseCell(key: "row1", state: TestCaseState(data: "red"), cellUpdater: TestCaseState.updateView) + ], header: TestHeaderFooter(state: TestHeaderFooterState(data: "green", kind: .header)) + )] + let newItems: [TableSection] = [TableSection(key: "section1", rows: [ + TestCaseCell(key: "row1", state: TestCaseState(data: "red"), cellUpdater: TestCaseState.updateView), + TestCaseCell(key: "row2", state: TestCaseState(data: "blue"), cellUpdater: TestCaseState.updateView) + ], header: TestHeaderFooter(state: TestHeaderFooterState(data: "purple", kind: .header)) + )] + let changes = CollectionSectionChangeSet(old: oldItems, new: newItems, visibleIndexPaths: []) + + XCTAssertFalse(changes.isEmpty) + XCTAssertEqual(changes.count, 1) + XCTAssertEqual(changes.movedSections, []) + XCTAssertEqual(changes.insertedSections, IndexSet()) + XCTAssertEqual(changes.reloadedSections, IndexSet([0])) + XCTAssertEqual(changes.deletedSections, IndexSet()) + XCTAssertEqual(changes.insertedRows, []) + XCTAssertEqual(changes.deletedRows, []) + XCTAssertEqual(changes.reloadedRows, []) + } + + func testSectionReloadedWithUnequalFooters() { + let oldItems: [TableSection] = [TableSection(key: "section1", rows: [ + TestCell(key: "row1", state: TestCaseState(data: "red")), + TestCell(key: "row2", state: TestCaseState(data: "blue")) + ], footer: TestHeaderFooter(state: TestHeaderFooterState(data: "green", kind: .footer)) + )] + let newItems: [TableSection] = [TableSection(key: "section1", rows: [ + TestCell(key: "row3", state: TestCaseState(data: "pink")) + ], footer: TestHeaderFooter(state: TestHeaderFooterState(data: "purple", kind: .footer)) + )] + let changes = CollectionSectionChangeSet(old: oldItems, new: newItems, visibleIndexPaths: []) + + XCTAssertFalse(changes.isEmpty) + XCTAssertEqual(changes.count, 1) + XCTAssertEqual(changes.movedSections, []) + XCTAssertEqual(changes.insertedSections, IndexSet()) + XCTAssertEqual(changes.reloadedSections, IndexSet([0])) + XCTAssertEqual(changes.deletedSections, IndexSet()) + XCTAssertEqual(changes.insertedRows, []) + XCTAssertEqual(changes.deletedRows, []) + XCTAssertEqual(changes.reloadedRows, []) + } + + func testCorrectSectionReloadedWithDelete() { + let oldItems: [TableSection] = [ + TableSection(key: "section1"), + TableSection(key: "section2", footer: TestHeaderFooter(state: TestHeaderFooterState(data: "green", kind: .footer))) + ] + let newItems: [TableSection] = [TableSection(key: "section2", footer: TestHeaderFooter(state: TestHeaderFooterState(data: "purple", kind: .footer)))] + let changes = CollectionSectionChangeSet(old: oldItems, new: newItems, visibleIndexPaths: []) + + XCTAssertFalse(changes.isEmpty) + XCTAssertEqual(changes.count, 2) + XCTAssertEqual(changes.movedSections, []) + XCTAssertEqual(changes.insertedSections, IndexSet()) + XCTAssertEqual(changes.reloadedSections, IndexSet([1])) + XCTAssertEqual(changes.deletedSections, IndexSet([0])) + } + + func testCorrectSectionReloadedWithInsert() { + let oldItems: [TableSection] = [ + TableSection(key: "section1", footer: TestHeaderFooter(state: TestHeaderFooterState(data: "green", kind: .footer))), + TableSection(key: "section2") + ] + let newItems: [TableSection] = [ + TableSection(key: "section3"), + TableSection(key: "section1", footer: TestHeaderFooter(state: TestHeaderFooterState(data: "purple", kind: .footer))), + TableSection(key: "section2") + ] + let changes = CollectionSectionChangeSet(old: oldItems, new: newItems, visibleIndexPaths: []) + + XCTAssertFalse(changes.isEmpty) + XCTAssertEqual(changes.count, 2) + XCTAssertEqual(changes.movedSections, []) + XCTAssertEqual(changes.insertedSections, IndexSet([0])) + XCTAssertEqual(changes.reloadedSections, IndexSet([0])) + XCTAssertEqual(changes.deletedSections, IndexSet()) + } + + // Shows the algorithm is greedy + func testMoveDown() { + let oldItems: [TableSection] = [ + TableSection(key: "section1"), + TableSection(key: "section2"), + TableSection(key: "section3") + ] + let newItems: [TableSection] = [ + TableSection(key: "section2"), + TableSection(key: "section3"), + TableSection(key: "section1"), + ] + + let changes = CollectionSectionChangeSet(old: oldItems, new: newItems, visibleIndexPaths: []) + + XCTAssertFalse(changes.isEmpty) + XCTAssertEqual(changes.count, 2) + XCTAssertEqual(changes.movedSections, [ + CollectionSectionChangeSet.MovedSection(from: 1, to: 0), + CollectionSectionChangeSet.MovedSection(from: 2, to: 1), + ]) + XCTAssertEqual(changes.insertedSections, IndexSet([])) + XCTAssertEqual(changes.reloadedSections, IndexSet()) + XCTAssertEqual(changes.deletedSections, IndexSet()) + } + + func testSwap() { + let oldItems: [TableSection] = [ + TableSection(key: "section1"), + TableSection(key: "section2"), + TableSection(key: "section3"), + TableSection(key: "section4"), + TableSection(key: "section5"), + ] + let newItems: [TableSection] = [ + TableSection(key: "section3"), + TableSection(key: "section2"), + TableSection(key: "section1"), + TableSection(key: "section4"), + TableSection(key: "section5"), + ] + + let changes = CollectionSectionChangeSet(old: oldItems, new: newItems, visibleIndexPaths: []) + + XCTAssertFalse(changes.isEmpty) + XCTAssertEqual(changes.count, 2) + XCTAssertEqual(changes.movedSections, [ + CollectionSectionChangeSet.MovedSection(from: 2, to: 0), + CollectionSectionChangeSet.MovedSection(from: 1, to: 1), + ]) + XCTAssertEqual(changes.insertedSections, IndexSet([])) + XCTAssertEqual(changes.reloadedSections, IndexSet()) + XCTAssertEqual(changes.deletedSections, IndexSet()) + } + + func testRemovingSectionsDeletesSections() { + let oldItems: [TableSection] = [TableSection(key: "section1"), TableSection(key: "section2")] + let newItems: [TableSection] = [] + + let changes = CollectionSectionChangeSet(old: oldItems, new: newItems, visibleIndexPaths: []) + + XCTAssertFalse(changes.isEmpty) + XCTAssertEqual(changes.count, 2) + XCTAssertEqual(changes.deletedSections, IndexSet([0, 1])) + } + + func testRemovingPreviousSectionDoesntCauseMove() { + let oldItems: [TableSection] = [TableSection(key: "section1"), TableSection(key: "section2")] + let newItems: [TableSection] = [TableSection(key: "section2")] + + let changes = CollectionSectionChangeSet(old: oldItems, new: newItems, visibleIndexPaths: []) + + XCTAssertFalse(changes.isEmpty) + XCTAssertEqual(changes.count, 1) + XCTAssertEqual(changes.deletedSections, IndexSet([0])) + XCTAssertEqual(changes.movedSections, []) + } + + func testReloadingSection() { + let oldItems: [TableSection] = [TableSection(key: "section1")] + let newItems: [TableSection] = [TableSection(key: "section2")] + + let changes = CollectionSectionChangeSet(old: oldItems, new: newItems, visibleIndexPaths: []) + + XCTAssertFalse(changes.isEmpty) + XCTAssertEqual(changes.count, 2) + XCTAssertEqual(changes.deletedSections, IndexSet(integer: 0)) + XCTAssertEqual(changes.insertedSections, IndexSet(integer: 0)) + XCTAssertEqual(changes.reloadedSections, IndexSet()) + XCTAssertEqual(changes.movedSections, []) + } + + func testAddingSectionAndRowOnlyInsertsSection() { + let oldItems: [TableSection] = [] + let rows: [CellConfigType] = [TestCaseCell(key: "row1", state: TestCaseState(data: "red"), cellUpdater: TestCaseState.updateView)] + let newSection = TableSection(key: "section1", rows: rows) + + let changes = CollectionSectionChangeSet(old: oldItems, new: [newSection], visibleIndexPaths: []) + + XCTAssertFalse(changes.isEmpty) + XCTAssertEqual(changes.count, 1) + XCTAssertEqual(changes.insertedSections, IndexSet([0])) + } + + func testDeletingSectionAndRowOnlyDeletesSection() { + let newItems: [TableSection] = [] + let rows: [CellConfigType] = [TestCaseCell(key: "row1", state: TestCaseState(data: "red"), cellUpdater: TestCaseState.updateView)] + let oldSection = TableSection(key: "section1", rows: rows) + + let changes = CollectionSectionChangeSet(old: [oldSection], new: newItems, visibleIndexPaths: []) + + XCTAssertFalse(changes.isEmpty) + XCTAssertEqual(changes.count, 1) + XCTAssertEqual(changes.deletedSections, IndexSet([0])) + } + + func testAddingNewRowsInsertsRows() { + let oldSection = TableSection(key: "section1", rows: [ + TestCaseCell(key: "row1", state: TestCaseState(data: "red"), cellUpdater: TestCaseState.updateView), + ]) + let newSection = TableSection(key: "section1", rows: [ + TestCaseCell(key: "row1", state: TestCaseState(data: "red"), cellUpdater: TestCaseState.updateView), + TestCaseCell(key: "row2", state: TestCaseState(data: "blue"), cellUpdater: TestCaseState.updateView) + ]) + + let changes = CollectionSectionChangeSet(old: [oldSection], new: [newSection], visibleIndexPaths: []) + + XCTAssertFalse(changes.isEmpty) + XCTAssertEqual(changes.count, 1) + XCTAssertEqual(changes.insertedRows, [IndexPath(row: 1, section: 0)]) + } + + func testAddingNewRowsBefore() { + let oldSection = TableSection(key: "section1", rows: [ + TestCaseCell(key: "row2", state: TestCaseState(data: "blue"), cellUpdater: TestCaseState.updateView), + TestCaseCell(key: "row3", state: TestCaseState(data: "pink"), cellUpdater: TestCaseState.updateView), + TestCaseCell(key: "row4", state: TestCaseState(data: "cyan"), cellUpdater: TestCaseState.updateView) + ]) + let newSection = TableSection(key: "section1", rows: [ + TestCaseCell(key: "row1", state: TestCaseState(data: "red"), cellUpdater: TestCaseState.updateView), + TestCaseCell(key: "row2", state: TestCaseState(data: "blue"), cellUpdater: TestCaseState.updateView), + TestCaseCell(key: "row3", state: TestCaseState(data: "pink"), cellUpdater: TestCaseState.updateView), + TestCaseCell(key: "row4", state: TestCaseState(data: "cyan"), cellUpdater: TestCaseState.updateView) + ]) + + let changes = CollectionSectionChangeSet(old: [oldSection], new: [newSection], visibleIndexPaths: []) + + XCTAssertFalse(changes.isEmpty) + XCTAssertEqual(changes.count, 1) + XCTAssertEqual(changes.insertedRows, [IndexPath(row: 0, section: 0)]) + XCTAssertEqual(changes.movedRows, []) + } + + func testRemovingRowRemovesRowsFromTable() { + let oldSection = TableSection(key: "section1", rows: [ + TestCaseCell(key: "row1", state: TestCaseState(data: "red"), cellUpdater: TestCaseState.updateView), + TestCaseCell(key: "row2", state: TestCaseState(data: "blue"), cellUpdater: TestCaseState.updateView) + ]) + + let newSection = TableSection(key: "section1", rows: [ + TestCaseCell(key: "row1", state: TestCaseState(data: "red"), cellUpdater: TestCaseState.updateView) + ]) + + let changes = CollectionSectionChangeSet(old: [oldSection], new: [newSection], visibleIndexPaths: []) + + XCTAssertFalse(changes.isEmpty) + XCTAssertEqual(changes.count, 1) + XCTAssertEqual(changes.deletedRows, [IndexPath(row: 1, section: 0)]) + } + + func testRemovingPreviousRowDoesntCauseMove() { + let oldSection = TableSection(key: "section1", rows: [ + TestCaseCell(key: "row1", state: TestCaseState(data: "red"), cellUpdater: TestCaseState.updateView), + TestCaseCell(key: "row2", state: TestCaseState(data: "blue"), cellUpdater: TestCaseState.updateView) + ]) + + let newSection = TableSection(key: "section1", rows: [ + TestCaseCell(key: "row2", state: TestCaseState(data: "blue"), cellUpdater: TestCaseState.updateView) + ]) + + let changes = CollectionSectionChangeSet(old: [oldSection], new: [newSection], visibleIndexPaths: []) + + XCTAssertFalse(changes.isEmpty) + XCTAssertEqual(changes.count, 1) + XCTAssertEqual(changes.deletedRows, [IndexPath(row: 0, section: 0)]) + XCTAssertEqual(changes.movedRows, []) + } + + func testSwappingRows() { + let oldSection = TableSection(key: "section1", rows: [ + TestCaseCell(key: "row1", state: TestCaseState(data: "red"), cellUpdater: TestCaseState.updateView), + TestCaseCell(key: "row2", state: TestCaseState(data: "blue"), cellUpdater: TestCaseState.updateView), + ]) + + let newSection = TableSection(key: "section1", rows: [ + TestCaseCell(key: "row2", state: TestCaseState(data: "blue"), cellUpdater: TestCaseState.updateView), + TestCaseCell(key: "row1", state: TestCaseState(data: "red"), cellUpdater: TestCaseState.updateView) + ]) + + let changes = CollectionSectionChangeSet(old: [oldSection], new: [newSection], visibleIndexPaths: []) + + XCTAssertFalse(changes.isEmpty) + XCTAssertEqual(changes.count, 1) + XCTAssertEqual(changes.movedRows, [ + CollectionSectionChangeSet.MovedRow( + from: IndexPath(row: 1, section: 0), + to: IndexPath(row: 0, section: 0) + ) + ]) + } + + func testRemoveOneAddTwo() { + let oldSection = TableSection(key: "section1", rows: [ + TestCaseCell(key: "row1", state: TestCaseState(data: "red"), cellUpdater: TestCaseState.updateView), + TestCaseCell(key: "row2", state: TestCaseState(data: "blue"), cellUpdater: TestCaseState.updateView), + ]) + + let newSection = TableSection(key: "section1", rows: [ + TestCaseCell(key: "row3", state: TestCaseState(data: "green"), cellUpdater: TestCaseState.updateView), + TestCaseCell(key: "row4", state: TestCaseState(data: "purple"), cellUpdater: TestCaseState.updateView), + TestCaseCell(key: "row2", state: TestCaseState(data: "blue"), cellUpdater: TestCaseState.updateView) + ]) + + let changes = CollectionSectionChangeSet(old: [oldSection], new: [newSection], visibleIndexPaths: []) + + XCTAssertFalse(changes.isEmpty) + XCTAssertEqual(changes.count, 3) + XCTAssertEqual(changes.deletedRows, [IndexPath(row: 0, section: 0)]) + XCTAssertEqual(changes.insertedRows, [ + IndexPath(row: 0, section: 0), + IndexPath(row: 1, section: 0), + ]) + XCTAssertEqual(changes.reloadedRows, []) + XCTAssertEqual(changes.movedRows, []) + } + + func testChangingSingleItemUpdatesRow() { + let oldSection = TableSection(key: "section1", rows: [ + TestCaseCell(key: "row1", state: TestCaseState(data: "red"), cellUpdater: TestCaseState.updateView), + ]) + + let newSection = TableSection(key: "section1", rows: [ + TestCaseCell(key: "row2", state: TestCaseState(data: "blue"), cellUpdater: TestCaseState.updateView), + ]) + + let changes = CollectionSectionChangeSet(old: [oldSection], new: [newSection], visibleIndexPaths: []) + + XCTAssertFalse(changes.isEmpty) + XCTAssertEqual(changes.count, 2) + XCTAssertEqual(changes.deletedRows, [IndexPath(row: 0, section: 0)]) + XCTAssertEqual(changes.insertedRows, [IndexPath(row: 0, section: 0)]) + XCTAssertEqual(changes.reloadedRows, []) + } + + func testInsertSectionAndMoveRowInNext() { + let staticSection1 = TableSection(key: "staticSection", rows: [ + TestCaseCell(key: "row1", state: TestCaseState(data: "red"), cellUpdater: TestCaseState.updateView), + TestCaseCell(key: "row2", state: TestCaseState(data: "blue"), cellUpdater: TestCaseState.updateView), + ]) + let staticSection2 = TableSection(key: "staticSection", rows: staticSection1.rows.reversed()) + + let newSection = TableSection(key: "section1", rows: [ + TestCaseCell(key: "row", state: TestCaseState(data: "blue"), cellUpdater: TestCaseState.updateView), + ]) + + let changes = CollectionSectionChangeSet(old: [staticSection1], new: [newSection, staticSection2], visibleIndexPaths: []) + + XCTAssertFalse(changes.isEmpty) + XCTAssertEqual(changes.count, 2) + XCTAssertEqual(changes.insertedSections, IndexSet(integer: 0)) + XCTAssertEqual(changes.movedRows, [ + CollectionSectionChangeSet.MovedRow( + from: IndexPath(row: 1, section: 0), + to: IndexPath(row: 0, section: 1) + ) + ]) + XCTAssertTrue(changes.movedSections.isEmpty) + } + + func testRemoveSectionAndReplaceRowInNextSection() { + let section1 = TableSection(key: "section1", rows: [ + TestCaseCell(key: "row2", state: TestCaseState(data: "blue"), cellUpdater: TestCaseState.updateView) + ]) + let newSection1 = TableSection(key: "section1", rows: [ + TestCaseCell(key: "row1", state: TestCaseState(data: "red"), cellUpdater: TestCaseState.updateView) + ]) + + let section2 = TableSection(key: "section2", rows: [ + TestCaseCell(key: "row1", state: TestCaseState(data: "blue"), cellUpdater: TestCaseState.updateView) + ]) + + let changes = CollectionSectionChangeSet(old: [section2, section1], new: [newSection1], visibleIndexPaths: []) + + XCTAssertFalse(changes.isEmpty) + XCTAssertEqual(changes.count, 3) + XCTAssertEqual(changes.insertedSections, IndexSet()) + XCTAssertEqual(changes.deletedSections, IndexSet(integer: 0)) + XCTAssertEqual(changes.reloadedSections, IndexSet()) + XCTAssertTrue(changes.movedSections.isEmpty) + // Is section 1 because reload indexpaths are pre-transaction + XCTAssertEqual(changes.reloadedRows, []) + XCTAssertEqual(changes.insertedRows, [IndexPath(row: 0, section: 0)]) + XCTAssertEqual(changes.deletedRows, [IndexPath(row: 0, section: 1)]) + XCTAssertEqual(changes.movedRows, []) + } + + func testSwapSections() { + let section1 = TableSection(key: "section1") + let section2 = TableSection(key: "section2") + + let changes = CollectionSectionChangeSet( + old: [section1, section2], + new: [section2, section1], + visibleIndexPaths: [] + ) + + XCTAssertFalse(changes.isEmpty) + XCTAssertEqual(changes.count, 1) + XCTAssertEqual(changes.insertedSections, IndexSet()) + XCTAssertEqual(changes.movedSections, [CollectionSectionChangeSet.MovedSection(from: 1, to: 0)]) + } + + func testRemovingPreviousAndUpdating() { + let oldSection = TableSection(key: "section1", rows: [ + TestCell(key: "row1", state: TestCaseState(data: "red")), + TestCell(key: "row2", state: TestCaseState(data: "blue")), + TestCell(key: "row3", state: TestCaseState(data: "green")) + ]) + + let newSection = TableSection(key: "section1", rows: [ + TestCell(key: "row2", state: TestCaseState(data: "purple")), + TestCell(key: "row3", state: TestCaseState(data: "cyan")) + ]) + + let changes = CollectionSectionChangeSet( + old: [oldSection], + new: [newSection], + visibleIndexPaths: [IndexPath(row: 1, section: 0), IndexPath(row: 2, section: 0)] + ) + + XCTAssertFalse(changes.isEmpty) + XCTAssertEqual(changes.count, 3) + XCTAssertEqual(changes.deletedRows, [IndexPath(row: 0, section: 0)]) + XCTAssertEqual(changes.updates.map { $0.index }, [ + IndexPath(row: 0, section: 0), + IndexPath(row: 1, section: 0), + ]) + } + + func testUpdateRowState() { + let oldState = TestCaseState(data: "Old state") + let oldSection = TableSection(key: "section1", rows: [ + TestCell(key: "row1", state: oldState) + ]) + + let newState = TestCaseState(data: "Plain text") + let newSection = TableSection(key: "section1", rows: [ + TestCell(key: "row1", state: newState) + ]) + + let changes = CollectionSectionChangeSet( + old: [oldSection], + new: [newSection], + visibleIndexPaths: [IndexPath(row: 0, section: 0)] + ) + XCTAssertEqual(changes.count, 1) + XCTAssertEqual(changes.updates.map { $0.index }, [ + IndexPath(row: 0, section: 0), + ]) + } + + func testUpdateRowConfig() { + let oldSection = TableSection(key: "section1", rows: [ + TestCaseCell(key: "row1", state: TestCaseState(data: "red"), cellUpdater: TestCaseState.updateView) + ]) + + let newSection = TableSection(key: "section1", rows: [ + LabelCell(key: "row1", state: "green") { view, state in + view.text = state + } + ]) + + let changes = CollectionSectionChangeSet( + old: [oldSection], + new: [newSection], + visibleIndexPaths: [IndexPath(row: 0, section: 0)] + ) + XCTAssertEqual(changes.count, 1) + XCTAssertEqual(changes.reloadedRows, [IndexPath(row: 0, section: 0)]) + } + + func testAccessibilityChange() { + let oldSection = TableSection(key: "section1", rows: [ + TestCell(key: "row1", accessibility: Accessibility(identifier: "initial", userInputLabels: ["row1"]), state: TestCaseState(data: "red")) + ]) + + let newSection = TableSection(key: "section1", rows: [ + TestCell(key: "row1", accessibility: Accessibility(identifier: "new", userInputLabels: ["row1"]), state: TestCaseState(data: "red")) + ]) + + let changes = CollectionSectionChangeSet( + old: [oldSection], + new: [newSection], + visibleIndexPaths: [IndexPath(row: 0, section: 0)] + ) + XCTAssertEqual(changes.count, 1) + XCTAssertEqual(changes.updates.map { $0.index }, [IndexPath(row: 0, section: 0)]) + } +} + +fileprivate typealias LabelCell = HostCell +fileprivate typealias TestCell = CellConfig + +private class TestView: UIView, ConfigurableView { + func prepareForReuse() { + + } + + func configure(_ state: TestCaseState) { + + } +} + +fileprivate struct TestHeaderFooterState: TableHeaderFooterStateType, Equatable { + let insets: UIEdgeInsets = .zero + let height: CGFloat = 0 + let topSeparatorHidden: Bool = true + let bottomSeparatorHidden: Bool = true + var data: String + var kind: ReusableKind +} + +fileprivate struct TestHeaderFooter: TableHeaderFooterConfigType, CollectionSupplementaryItemConfig { + var kind: ReusableKind { state?.kind ?? "test" } + + func register(with collectionView: UICollectionView) { + collectionView.register(viewClass: CollHeaderFooter.self, forSupplementaryViewOfKind: kind, withReuseIdentifier: CollHeaderFooter.reuseIdentifier) + } + + func dequeueView(collectionView: UICollectionView, indexPath: IndexPath) -> UICollectionReusableView { + collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: CollHeaderFooter.reuseIdentifier, for: indexPath) + } + + func update(_ view: UICollectionReusableView, collectionView: UICollectionView, forIndex index: Int) { + // intentionally empty + } + + func isEqual(_ other: CollectionSupplementaryItemConfig?) -> Bool { + guard let other = other as? TestHeaderFooter else { return false } + return state == other.state + } + + typealias HeaderFooter = TableHeaderFooter + typealias CollHeaderFooter = LegacyTableHeaderFooterView + let state: TestHeaderFooterState? + + func register(with tableView: UITableView) { + tableView.registerReusableHeaderFooterView(HeaderFooter.self) + } + + func dequeueCell(from tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { + UITableViewCell() + } + + func dequeueHeaderFooter(from tableView: UITableView) -> UITableViewHeaderFooterView? { + return tableView.dequeueReusableHeaderFooterView(HeaderFooter.self) + } + + func isEqual(_ other: TableHeaderFooterConfigType?) -> Bool { + guard let other = other as? TestHeaderFooter else { return false } + return state == other.state + } + + var height: CGFloat { + return state?.height ?? 0 + } +} diff --git a/Tests/FunctionalTableDataTests/DiffableDataSourceFunctionalCollectionDataTests.swift b/Tests/FunctionalTableDataTests/DiffableDataSourceFunctionalCollectionDataTests.swift new file mode 100644 index 0000000..0a14423 --- /dev/null +++ b/Tests/FunctionalTableDataTests/DiffableDataSourceFunctionalCollectionDataTests.swift @@ -0,0 +1,76 @@ +// +// DiffableDataSourceFunctionalDataTests.swift +// FunctionalTableDataTests +// +// +// Created by Jason Kemp on 2021-10-31. +// + +import XCTest +@testable import FunctionalTableData + +class DiffableDataSourceFunctionalCollectionDataTests: XCTestCase { + func testKeyPathFromRowKey() { + let collectionData = FunctionalCollectionData(name: nil, diffingStrategy: .diffableDataSource) + let collectionView = UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewFlowLayout()) + + collectionData.collectionView = collectionView + + let expectation1 = expectation(description: "first append") + let cellConfigC1 = TestCaseCell(key: "color1", state: TestCaseState(data: "red"), cellUpdater: TestCaseState.updateView) + let cellConfigC2 = TestCaseCell(key: "red2", state: TestCaseState(data: "green"), cellUpdater: TestCaseState.updateView) + let sectionC1 = TableSection(key: "colors Section", rows: [cellConfigC1, cellConfigC2]) + + let cellConfigS1 = TestCaseCell(key: "size1", state: TestCaseState(data: "medium"), cellUpdater: TestCaseState.updateView) + let cellConfigS2 = TestCaseCell(key: "size2", state: TestCaseState(data: "large"), cellUpdater: TestCaseState.updateView) + let sectionS1 = TableSection(key: "sizes Section", rows: [cellConfigS1, cellConfigS2]) + + collectionData.renderAndDiff([sectionC1, sectionS1]) { [weak collectionData] in + expectation1.fulfill() + + if let tableData = collectionData, let keyPath = tableData.keyPathForRowKey("size1") { + XCTAssertTrue(keyPath.sectionKey == "sizes Section" && keyPath.rowKey == "size1") + } else { + XCTFail() + } + } + + waitForExpectations(timeout: 1, handler: nil) + } + + func testRetrievingIndexPathFromInvalidKeyPath() { + let collectionData = FunctionalCollectionData(name: nil, diffingStrategy: .diffableDataSource) + let collectionView = UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewFlowLayout()) + + collectionData.collectionView = collectionView + let expectation1 = expectation(description: "first append") + let cellConfigC1 = TestCaseCell(key: "red1", state: TestCaseState(data: "red"), cellUpdater: TestCaseState.updateView) + let sectionC1 = TableSection(key: "colors Section", rows: [cellConfigC1]) + collectionData.renderAndDiff([sectionC1]) { + expectation1.fulfill() + } + waitForExpectations(timeout: 1, handler: nil) + + XCTAssertNil(collectionData.indexPathFromKeyPath(FunctionalTableData.KeyPath(sectionKey: "Invalid Section", rowKey: "red1"))) + XCTAssertNil(collectionData.indexPathFromKeyPath(FunctionalTableData.KeyPath(sectionKey: "colors Section", rowKey: "Invalid Row"))) + } + + func testRetrievingIndexPathFromValidKeyPath() { + let collectionData = FunctionalCollectionData(name: nil, diffingStrategy: .diffableDataSource) + let collectionView = UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewFlowLayout()) + + collectionData.collectionView = collectionView + let expectation1 = expectation(description: "first append") + let cellConfigC1 = TestCaseCell(key: "red1", state: TestCaseState(data: "red"), cellUpdater: TestCaseState.updateView) + let sectionC1 = TableSection(key: "colors Section", rows: [cellConfigC1]) + + collectionData.renderAndDiff([sectionC1]) { + expectation1.fulfill() + } + waitForExpectations(timeout: 1, handler: nil) + + let indexPath = collectionData.indexPathFromKeyPath(FunctionalTableData.KeyPath(sectionKey: "colors Section", rowKey: "red1")) + XCTAssertNotNil(indexPath) + } + +} diff --git a/Tests/FunctionalTableDataTests/FunctionalCollectionDataPerformanceTests.swift b/Tests/FunctionalTableDataTests/FunctionalCollectionDataPerformanceTests.swift new file mode 100644 index 0000000..f5ffb33 --- /dev/null +++ b/Tests/FunctionalTableDataTests/FunctionalCollectionDataPerformanceTests.swift @@ -0,0 +1,79 @@ +// +// FunctionalCollectionDataPerformanceTests.swift +// +// +// Created by Jason Kemp on 2021-10-31. +// Copyright © 2021 Shopify. All rights reserved. + +import XCTest +@testable import FunctionalTableData + +class FunctionalCollectionDataPerformanceTests: XCTestCase { + private let functionalData = ClassicFunctionalCollectionDataDiffer(name: "test", data: CollectionData()) + + func testPerformanceByMovingRows() { + let totalSections = 5 + let totalRows = 200 + + let oldSections = mockSections(sectionsCount: totalSections, rowsCount: totalRows) { index -> String in + return "row-\(index)" + } + + let newSections: [TableSection] = mockSections(sectionsCount: totalSections, rowsCount: totalRows) { index -> String in + return "row-\((totalRows - 1) - index)" + } + + measure { + _ = functionalData.calculateTableChanges(oldSections: oldSections, newSections: newSections, visibleIndexPaths: []) + } + } + + func testPerformanceByAddingRows() { + let newSections: [TableSection] = mockSections(sectionsCount: 10, rowsCount: 10_000) { index -> String in + return "row-\(index)" + } + + measure { + _ = functionalData.calculateTableChanges(oldSections: [], newSections: newSections, visibleIndexPaths: []) + } + } + + func testPerformanceByDeletingRows() { + let oldSections: [TableSection] = mockSections(sectionsCount: 10, rowsCount: 10_000) { index -> String in + return "row-\(index)" + } + + measure { + _ = functionalData.calculateTableChanges(oldSections: oldSections, newSections: [], visibleIndexPaths: []) + } + } + + func testPerformanceByKeepingRows() { + let oldSections: [TableSection] = mockSections(sectionsCount: 10, rowsCount: 10_000) { index -> String in + return "row-\(index)" + } + + let newSections = oldSections + + measure { + _ = functionalData.calculateTableChanges(oldSections: oldSections, newSections: newSections, visibleIndexPaths: []) + } + } + + private func mockSections(sectionsCount: Int, rowsCount: Int, keyBuilder: (Int) -> String) -> [TableSection] { + var sections: [TableSection] = [] + for i in 0.. -struct TestCaseState: Equatable { +struct TestCaseState: Hashable { var data: String static func ==(lhs: TestCaseState, rhs: TestCaseState) -> Bool {