From 9ca64ac9f347e8cbe93ec07d27be42809cf19857 Mon Sep 17 00:00:00 2001 From: Anders Ha Date: Sat, 30 Mar 2019 23:59:05 +0000 Subject: [PATCH 1/2] Introduce `AdapterStore` and integrate it with the TableView adapters. --- Bento.xcodeproj/project.pbxproj | 14 +- Bento/Adapters/AdapterProtocol.swift | 31 ++ Bento/Adapters/AdapterStore.swift | 207 +++++++++++ Bento/Adapters/TableViewAdapter.swift | 73 +++- Bento/Bento/UITableViewExtensions.swift | 10 +- .../SizeCachingTableView.swift | 42 +++ Bento/Diff/TableViewSectionDiff.swift | 6 +- .../Helpers/BoxTableViewAdapter.swift | 16 +- BentoTests/AdapterStoreTests.swift | 339 ++++++++++++++++++ 9 files changed, 716 insertions(+), 22 deletions(-) create mode 100644 Bento/Adapters/AdapterProtocol.swift create mode 100644 Bento/Adapters/AdapterStore.swift create mode 100644 Bento/CollectionViews+Public/SizeCachingTableView.swift create mode 100644 BentoTests/AdapterStoreTests.swift diff --git a/Bento.xcodeproj/project.pbxproj b/Bento.xcodeproj/project.pbxproj index 12016fd..ace90d9 100644 --- a/Bento.xcodeproj/project.pbxproj +++ b/Bento.xcodeproj/project.pbxproj @@ -58,6 +58,8 @@ 58FC444A207CFBD700DA3614 /* MovieComponentView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 58FC4440207CFBD700DA3614 /* MovieComponentView.xib */; }; 58FC444D207CFBE200DA3614 /* BookAppointmentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FC444B207CFBE100DA3614 /* BookAppointmentViewController.swift */; }; 58FC444E207CFBE200DA3614 /* MoviesListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FC444C207CFBE200DA3614 /* MoviesListViewController.swift */; }; + 5B02B99322503A370089371A /* AdapterProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B02B99122503A370089371A /* AdapterProtocol.swift */; }; + 5B02B99422503A370089371A /* AdapterStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B02B99222503A370089371A /* AdapterStore.swift */; }; 61B40919208523F60063DE25 /* FoodListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61B40916208523F50063DE25 /* FoodListViewModel.swift */; }; 61B4091A208523F60063DE25 /* FoodListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61B40917208523F50063DE25 /* FoodListViewController.swift */; }; 61B64BB12086561B0092082C /* FoodListRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61B64BB02086561B0092082C /* FoodListRenderer.swift */; }; @@ -65,6 +67,7 @@ 61B64BB6208745730092082C /* CollectionViewContainerCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61B64BB5208745730092082C /* CollectionViewContainerCell.swift */; }; 65020C322203186400DC8F42 /* NativeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65020C312203186400DC8F42 /* NativeView.swift */; }; 651E75C0221005E300130866 /* UIKitContainerDiffApplicationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 651E75BF221005E300130866 /* UIKitContainerDiffApplicationTests.swift */; }; + 653D460B2256665000CF3E4C /* AdapterStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 653D460A2256665000CF3E4C /* AdapterStoreTests.swift */; }; 65496FB8211C323F00511D8A /* TableViewContainerCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587F0717201B355800ACD219 /* TableViewContainerCell.swift */; }; 65496FB9211C323F00511D8A /* TableViewAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9509BC2762C8B4277B973D8 /* TableViewAdapter.swift */; }; 65A69EDF218B8892005D90AC /* UICollectionViewExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65A69EDE218B8891005D90AC /* UICollectionViewExtensions.swift */; }; @@ -183,6 +186,8 @@ 58FC4440207CFBD700DA3614 /* MovieComponentView.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = MovieComponentView.xib; sourceTree = ""; }; 58FC444B207CFBE100DA3614 /* BookAppointmentViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BookAppointmentViewController.swift; sourceTree = ""; }; 58FC444C207CFBE200DA3614 /* MoviesListViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MoviesListViewController.swift; sourceTree = ""; }; + 5B02B99122503A370089371A /* AdapterProtocol.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AdapterProtocol.swift; sourceTree = ""; }; + 5B02B99222503A370089371A /* AdapterStore.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AdapterStore.swift; sourceTree = ""; }; 5B175A6421C0634800590F34 /* ComponentContract.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = ComponentContract.md; sourceTree = ""; }; 61B40916208523F50063DE25 /* FoodListViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FoodListViewModel.swift; sourceTree = ""; }; 61B40917208523F50063DE25 /* FoodListViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FoodListViewController.swift; sourceTree = ""; }; @@ -191,6 +196,7 @@ 61B64BB5208745730092082C /* CollectionViewContainerCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionViewContainerCell.swift; sourceTree = ""; }; 65020C312203186400DC8F42 /* NativeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeView.swift; sourceTree = ""; }; 651E75BF221005E300130866 /* UIKitContainerDiffApplicationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIKitContainerDiffApplicationTests.swift; sourceTree = ""; }; + 653D460A2256665000CF3E4C /* AdapterStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdapterStoreTests.swift; sourceTree = ""; }; 65A69EDE218B8891005D90AC /* UICollectionViewExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UICollectionViewExtensions.swift; sourceTree = ""; }; 65A69EE0218B8AB6005D90AC /* CustomCollectionViewAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomCollectionViewAdapter.swift; sourceTree = ""; }; 65BA922D20AF388F004AEF18 /* UITableViewExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITableViewExtensions.swift; sourceTree = ""; }; @@ -432,6 +438,7 @@ 58FC441E207CF29F00DA3614 /* BentoTests */ = { isa = PBXGroup; children = ( + 653D460A2256665000CF3E4C /* AdapterStoreTests.swift */, 9A3EF77F205D866F00D043AC /* AnyRenderableTests.swift */, 740921B520ACDDDA00B59F5C /* IfTests.swift */, 740921B720ACE5EC00B59F5C /* ConcatenationTests.swift */, @@ -487,6 +494,8 @@ A950945F6360B851C3E87B61 /* Adapters */ = { isa = PBXGroup; children = ( + 5B02B99122503A370089371A /* AdapterProtocol.swift */, + 5B02B99222503A370089371A /* AdapterStore.swift */, A9509BC2762C8B4277B973D8 /* TableViewAdapter.swift */, 5874C80420C9342B004EB5EA /* CollectionViewAdapter.swift */, 58D0F12C207F573B00A24E96 /* TableViewAnimation.swift */, @@ -684,7 +693,7 @@ 5829D269200FB092001E020D /* AppDelegate.swift in Sources */, 04EBE74F222C031B002D38C4 /* Optional+Extension.swift in Sources */, 04C9A0842211967300C70E09 /* SignUpViewController.swift in Sources */, - 61B4091A208523F60063DE25 /* IntroViewController.swift in Sources */, + 61B4091A208523F60063DE25 /* FoodListViewController.swift in Sources */, 61B4091A208523F60063DE25 /* FoodListViewController.swift in Sources */, 58FC4449207CFBD700DA3614 /* IconTitleDetailsView.swift in Sources */, 044329EA2229096E004EFB29 /* SignUpRenderer.swift in Sources */, @@ -700,6 +709,7 @@ files = ( 582D9951217E196E00C67B0D /* MenuItemsResponding.swift in Sources */, 9AF4786A21205E3100F87E21 /* Supplement.swift in Sources */, + 5B02B99322503A370089371A /* AdapterProtocol.swift in Sources */, 65496FB8211C323F00511D8A /* TableViewContainerCell.swift in Sources */, 65496FB9211C323F00511D8A /* TableViewAdapter.swift in Sources */, 65E3ECB02113598700869DF3 /* UIKit+BentoCollectionView.swift in Sources */, @@ -712,6 +722,7 @@ 65A69EDF218B8892005D90AC /* UICollectionViewExtensions.swift in Sources */, 58BA7584201633CC0050D5F1 /* Renderable.swift in Sources */, 65020C322203186400DC8F42 /* NativeView.swift in Sources */, + 5B02B99422503A370089371A /* AdapterStore.swift in Sources */, 5857BECA2056F02C0085EB9C /* If.swift in Sources */, A9509FB12CAD89179FAA03B0 /* Box.swift in Sources */, 582D9987217F87B100C67B0D /* ComponentLifecycleAware.swift in Sources */, @@ -740,6 +751,7 @@ 58FC4427207CF2BB00DA3614 /* AnyRenderableTests.swift in Sources */, 58FC4428207CF2BB00DA3614 /* TestId.swift in Sources */, 58D27BA721B83B2700DC9600 /* DeletableTests.swift in Sources */, + 653D460B2256665000CF3E4C /* AdapterStoreTests.swift in Sources */, 58FC4429207CF2BB00DA3614 /* TestRenderable.swift in Sources */, 5830C5E721F22DDC0029044B /* ComponentLifecycleAware.swift in Sources */, 740921B620ACDDDA00B59F5C /* IfTests.swift in Sources */, diff --git a/Bento/Adapters/AdapterProtocol.swift b/Bento/Adapters/AdapterProtocol.swift new file mode 100644 index 0000000..f1214d5 --- /dev/null +++ b/Bento/Adapters/AdapterProtocol.swift @@ -0,0 +1,31 @@ +/// Provide generic parameter agnostic access to the adapter. +internal protocol AdapterStoreAccessible: AnyObject { + var layoutMargins: UIEdgeInsets { get set } + var boundSize: CGSize { get set } + var cachesSizeInformation: Bool { get set } +} + +/// Provide a default implementation for all adapter store owners. +internal protocol AdapterStoreOwner: AdapterStoreAccessible { + associatedtype SectionID: Hashable + associatedtype ItemID: Hashable + + var store: AdapterStore { get set } +} + +extension AdapterStoreOwner { + var layoutMargins: UIEdgeInsets { + get { return store.layoutMargins } + set { store.layoutMargins = newValue } + } + + var boundSize: CGSize { + get { return store.boundSize } + set { store.boundSize = newValue } + } + + var cachesSizeInformation: Bool { + get { return store.cachesSizeInformation } + set { store.cachesSizeInformation = newValue } + } +} diff --git a/Bento/Adapters/AdapterStore.swift b/Bento/Adapters/AdapterStore.swift new file mode 100644 index 0000000..d051c6a --- /dev/null +++ b/Bento/Adapters/AdapterStore.swift @@ -0,0 +1,207 @@ +import FlexibleDiff + +/// The adapter store which carries the component tree, and also cached sizing information if enabled. +/// +/// The adapter computes sizing information lazily upon request, but it adjusts spaces and performs cache invalidation +/// every time a diff is applied. +/// +/// - important: While the use-as-you-go Bento uses the adapter, the size caching capability should only be enabled in +/// `SizeCachingTableView` and `SizeCachingCollectionView`, since there are specific messages that Bento +/// need to intercept for size caching to function as expected. +struct AdapterStore { + private(set) var sections: [Section] = [] + + var sizingStrategy: SizingStrategy = .fillHorizontally + + var cachesSizeInformation: Bool = false { + didSet { + if cachesSizeInformation && info.isEmpty { + resetCachedInfo() + } else { + info = [] + } + } + } + + var boundSize: CGSize = .unknown { + didSet { + if cachesSizeInformation && oldValue != boundSize { + resetCachedInfo() + } + } + } + + var layoutMargins: UIEdgeInsets = .zero { + didSet { + if cachesSizeInformation && oldValue != layoutMargins { + resetCachedInfo() + } + } + } + + /// All cached information. If it is empty, it means either there is no item, or the cache structure hasn't been + /// setup yet. + private var info: [SectionInfo] = [] + + init() {} + + mutating func size(for supplement: Supplement, inSection section: Int) -> SupplementSizingResult { + guard cachesSizeInformation else { return .cachingDisabled } + guard let component = sections[section].supplements[supplement] else { return .doesNotExist } + + let knownSize = info[section].supplements[supplement, default: .unknown] + + if knownSize == .unknown { + let size = sizingStrategy.size( + of: component, + boundSize: boundSize, + layoutMargins: layoutMargins + ) + + info[section].supplements[supplement] = size + return .size(size) + } else { + return .size(knownSize) + } + } + + mutating func size(forItemAt indexPath: IndexPath) -> CGSize? { + guard cachesSizeInformation else { return nil } + + let knownSize = info[indexPath.section].itemSizes[indexPath.item] + + if knownSize == .unknown { + let size = sizingStrategy.size( + of: sections[indexPath.section].items[indexPath.item].component, + boundSize: boundSize, + layoutMargins: layoutMargins + ) + + info[indexPath.section].itemSizes[indexPath.item] = size + return size + } else { + return knownSize + } + } + + mutating func removeItem(at indexPath: IndexPath) { + sections[indexPath.section].items.remove(at: indexPath.row) + } + + mutating func update(with sections: [Section], knownSupplements: Set, changeset: SectionedChangeset? = nil) { + self.sections = sections + + guard cachesSizeInformation else { return } + + guard let changeset = changeset else { + resetCachedInfo() + return + } + + info.applyIgnoringMutation( + changeset.sections, + newElement: SectionInfo(), + whenInserted: { info, index in + info.itemSizes = Array(repeating: .unknown, count: sections[index].items.count) + } + ) + + // Apply changeset to the old section info for all mutated sections. + for mutatedSection in changeset.mutatedSections { + let index = mutatedSection.destination + + // NOTE: When layout equivalence is implemented, we need to update this to avoid not invalidating entries + // when layout is declared not to have changed. + info[index].supplements = [:] + + info[index].apply(mutatedSection.changeset) + } + } + + mutating func resetCachedInfo() { + info = Array(repeating: SectionInfo(), count: sections.count) + for index in info.indices { + info[index].itemSizes = Array(repeating: .unknown, count: sections[index].items.count) + } + } +} + +enum SupplementSizingResult: Equatable { + case doesNotExist + case cachingDisabled + case size(CGSize) +} + +enum SizingStrategy { + case fillHorizontally + case fillVertically + case compressed + + func size(of component: AnyRenderable, boundSize: CGSize, layoutMargins: UIEdgeInsets) -> CGSize { + switch self { + case .fillHorizontally: + return component.sizeBoundTo(width: boundSize.width, inheritedMargins: layoutMargins) + case .fillVertically: + return component.sizeBoundTo(height: boundSize.height, inheritedMargins: layoutMargins) + case .compressed: + return component.sizeBoundTo(size: UIView.layoutFittingCompressedSize, inheritedMargins: layoutMargins) + } + } +} + +private struct SectionInfo { + var supplements: [Supplement: CGSize] = [:] + var itemSizes: [CGSize] = [] + + init() {} + + mutating func apply(_ changeset: Changeset) { + itemSizes.applyIgnoringMutation(changeset, newElement: .unknown, whenInserted: { _, _ in }) + + for index in changeset.mutations { + itemSizes[index] = .unknown + } + + for move in changeset.moves where move.isMutated { + itemSizes[move.destination] = .unknown + } + } +} + +extension CGSize { + /// A sentinel size representing unknown size. This is not known to be generated by Auto Layout, and the logical + /// size is either zero or of positive decimals. + fileprivate static var unknown: CGSize { + return CGSize(width: -.infinity, height: -.infinity) + } +} + +extension Array { + mutating func applyIgnoringMutation(_ changeset: Changeset, newElement: Element, whenInserted: (inout Element, _ index: Index) -> Void) { + let old = self + + let allRemovals = changeset.removals + .union(IndexSet(changeset.moves.lazy.map { $0.source })) + let allInsertions = changeset.inserts + .union(IndexSet(changeset.moves.lazy.map { $0.destination })) + + // Remove all entries for removed sections, and moved sections at their original position. + for range in allRemovals.rangeView.reversed() { + removeSubrange(range) + } + + // Create all entries for newly inserted sections, and moved sections at their new position. + for range in allInsertions.rangeView { + insert(contentsOf: repeatElement(newElement, count: range.count), at: range.lowerBound) + + for index in range { + whenInserted(&self[index], index) + } + } + + // Copy over the old section info for moved sections. + for move in changeset.moves { + self[move.destination] = old[move.source] + } + } +} diff --git a/Bento/Adapters/TableViewAdapter.swift b/Bento/Adapters/TableViewAdapter.swift index 70f6585..38bad4f 100644 --- a/Bento/Adapters/TableViewAdapter.swift +++ b/Bento/Adapters/TableViewAdapter.swift @@ -3,13 +3,19 @@ import FlexibleDiff public typealias TableViewAdapter = TableViewAdapterBase & UITableViewDataSource & UITableViewDelegate +private let knownSupplements: Set = [.header, .footer] + open class TableViewAdapterBase - : NSObject, FocusEligibilitySourceImplementing { - public private(set) var sections: [Section] = [] + : NSObject, AdapterStoreOwner, FocusEligibilitySourceImplementing { + public var sections: [Section] { + return store.sections + } + internal private(set) weak var tableView: UITableView? + internal var store: AdapterStore public init(with tableView: UITableView) { - self.sections = [] + self.store = AdapterStore() self.tableView = tableView super.init() } @@ -21,11 +27,13 @@ open class TableViewAdapterBase let diff = TableViewSectionDiff(oldSections: self.sections, newSections: sections, animation: animation) - diff.apply(to: tableView, updateAdapter: { self.sections = sections }) + diff.apply(to: tableView, updateAdapter: { changeset in + self.store.update(with: sections, knownSupplements: knownSupplements, changeset: changeset) + }) } func update(sections: [Section]) { - self.sections = sections + store.update(with: sections, knownSupplements: knownSupplements, changeset: nil) tableView?.reloadData() } @@ -63,11 +71,59 @@ open class TableViewAdapterBase } @objc open func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { - return sections[section].supplements.keys.contains(.header) ? UITableView.automaticDimension : .leastNonzeroMagnitude + switch store.size(for: .header, inSection: section) { + case .cachingDisabled: + return tableView.sectionHeaderHeight + case .doesNotExist: + return .leastNonzeroMagnitude + case let .size(size): + return size.height + } } @objc open func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { - return sections[section].supplements.keys.contains(.footer) ? UITableView.automaticDimension : .leastNonzeroMagnitude + switch store.size(for: .footer, inSection: section) { + case .cachingDisabled: + return tableView.sectionFooterHeight + case .doesNotExist: + return .leastNonzeroMagnitude + case let .size(size): + return size.height + } + } + + @objc(tableView:heightForRowAtIndexPath:) + open func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + return store.size(forItemAt: indexPath)?.height + ?? tableView.rowHeight + } + + @objc open func tableView(_ tableView: UITableView, estimatedHeightForHeaderInSection section: Int) -> CGFloat { + switch store.size(for: .header, inSection: section) { + case .cachingDisabled: + return tableView.estimatedSectionHeaderHeight + case .doesNotExist: + return .leastNonzeroMagnitude + case let .size(size): + return size.height + } + } + + @objc open func tableView(_ tableView: UITableView, estimatedHeightForFooterInSection section: Int) -> CGFloat { + switch store.size(for: .footer, inSection: section) { + case .cachingDisabled: + return tableView.estimatedSectionFooterHeight + case .doesNotExist: + return .leastNonzeroMagnitude + case let .size(size): + return size.height + } + } + + @objc(tableView:estimatedHeightForRowAtIndexPath:) + open func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { + return store.size(forItemAt: indexPath)?.height + ?? tableView.estimatedRowHeight } @objc(tableView:editActionsForRowAtIndexPath:) @@ -162,12 +218,11 @@ open class TableViewAdapterBase return } - sections[indexPath.section].items.remove(at: indexPath.row) - CATransaction.begin() CATransaction.setCompletionBlock { component.delete() } + store.removeItem(at: indexPath) tableView?.deleteRows(at: [indexPath], with: .left) actionPerformed?(true) CATransaction.commit() diff --git a/Bento/Bento/UITableViewExtensions.swift b/Bento/Bento/UITableViewExtensions.swift index 5390b40..1626bf7 100644 --- a/Bento/Bento/UITableViewExtensions.swift +++ b/Bento/Bento/UITableViewExtensions.swift @@ -14,9 +14,10 @@ extension UITableView { self.delegate = adapter self.dataSource = adapter + objc_setAssociatedObject(self, AssociatedKey.adapter, adapter, .OBJC_ASSOCIATION_RETAIN) + reloadData() layoutIfNeeded() - objc_setAssociatedObject(self, AssociatedKey.adapter, adapter, .OBJC_ASSOCIATION_RETAIN) } public func render(_ box: Box) { @@ -55,6 +56,13 @@ extension UITableView { return getAdapter() } + var adapterStore: AdapterStoreAccessible { + let adapter = typeErasedAdapter + precondition(adapter != nil, "You must access the adapter store directly only after the adapter has been configured.") + + return typeErasedAdapter as! AdapterStoreAccessible + } + var typeErasedAdapter: AnyObject? { return objc_getAssociatedObject(self, AssociatedKey.adapter) as AnyObject? } diff --git a/Bento/CollectionViews+Public/SizeCachingTableView.swift b/Bento/CollectionViews+Public/SizeCachingTableView.swift new file mode 100644 index 0000000..33c72bb --- /dev/null +++ b/Bento/CollectionViews+Public/SizeCachingTableView.swift @@ -0,0 +1,42 @@ +import UIKit + +open class SizeCachingTableView: UITableView { + public convenience init( + frame: CGRect, + style: UITableView.Style, + sectionIDType: SectionID.Type, + itemIDType: ItemID.Type + ) { + self.init(frame: frame, style: style, adapterClass: BentoTableViewAdapter.self) + } + + public init( + frame: CGRect, + style: UITableView.Style, + adapterClass: TableViewAdapter.Type + ) { + super.init(frame: frame, style: style) + prepareForBoxRendering(with: adapterClass.init(with: self)) + adapterStore.cachesSizeInformation = true + } + + @available(*, unavailable) + public required init?(coder aDecoder: NSCoder) { + fatalError("`BentoTableView` does not support Interface Builder or Storyboard.") + } + + open override func layoutMarginsDidChange() { + super.layoutMarginsDidChange() + adapterStore.layoutMargins = UIEdgeInsets( + top: 0, + left: layoutMargins.left, + bottom: 0, + right: layoutMargins.right + ) + } + + open override func layoutSubviews() { + adapterStore.boundSize = bounds.size + super.layoutSubviews() + } +} diff --git a/Bento/Diff/TableViewSectionDiff.swift b/Bento/Diff/TableViewSectionDiff.swift index c7ec7b5..38c766b 100644 --- a/Bento/Diff/TableViewSectionDiff.swift +++ b/Bento/Diff/TableViewSectionDiff.swift @@ -14,7 +14,7 @@ struct TableViewSectionDiff { self.animation = animation } - func apply(to tableView: UITableView, updateAdapter: @escaping () -> Void) { + func apply(to tableView: UITableView, updateAdapter: @escaping (SectionedChangeset) -> Void) { /// Since we are going to always rebind visible components, there is no point to evaluate /// component equation. However, we still force all instances of components to be treated as /// unequal, so as to preserve all positional information for in-place updates to visible cells. @@ -32,14 +32,14 @@ struct TableViewSectionDiff { if #available(iOS 11, *) { tableView.performBatchUpdates( { - updateAdapter() + updateAdapter(diff) self.apply(diff: diff, to: tableView) }, completion: nil ) } else { tableView.beginUpdates() - updateAdapter() + updateAdapter(diff) apply(diff: diff, to: tableView) tableView.endUpdates() } diff --git a/BentoKit/BentoKit/Helpers/BoxTableViewAdapter.swift b/BentoKit/BentoKit/Helpers/BoxTableViewAdapter.swift index f971b86..aff1ad6 100644 --- a/BentoKit/BentoKit/Helpers/BoxTableViewAdapter.swift +++ b/BentoKit/BentoKit/Helpers/BoxTableViewAdapter.swift @@ -28,7 +28,7 @@ public final class BoxTableViewAdapter return nil } - public func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + override public func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { let height = sections[indexPath.section].items[indexPath.row] .component(as: HeightCustomizing.self) .map { component in @@ -36,7 +36,7 @@ public final class BoxTableViewAdapter inheritedMargins: tableView.layoutMargins.horizontal) + tableView.separatorHeight } - return height ?? tableView.rowHeight + return height ?? super.tableView(tableView, heightForRowAt: indexPath) } override public func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { @@ -59,34 +59,34 @@ public final class BoxTableViewAdapter return height ?? super.tableView(tableView, heightForFooterInSection: section) } - public func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { + override public func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { let height = sections[indexPath.section].items[indexPath.row] .component(as: HeightCustomizing.self) .map { component in return component.estimatedHeight(forWidth: tableView.bounds.width, inheritedMargins: tableView.layoutMargins.horizontal) } - return height ?? tableView.estimatedRowHeight + return height ?? super.tableView(tableView, estimatedHeightForRowAt: indexPath) } - public func tableView(_ tableView: UITableView, estimatedHeightForHeaderInSection section: Int) -> CGFloat { + override public func tableView(_ tableView: UITableView, estimatedHeightForHeaderInSection section: Int) -> CGFloat { let height = sections[section] .component(of: .header, as: HeightCustomizing.self) .map { component in return component.height(forWidth: tableView.bounds.width, inheritedMargins: tableView.layoutMargins.horizontal) } - return height ?? tableView.estimatedSectionHeaderHeight + return height ?? super.tableView(tableView, estimatedHeightForHeaderInSection: section) } - public func tableView(_ tableView: UITableView, estimatedHeightForFooterInSection section: Int) -> CGFloat { + override public func tableView(_ tableView: UITableView, estimatedHeightForFooterInSection section: Int) -> CGFloat { let height = sections[section] .component(of: .footer, as: HeightCustomizing.self) .map { component in return component.height(forWidth: tableView.bounds.width, inheritedMargins: tableView.layoutMargins.horizontal) } - return height ?? tableView.estimatedSectionFooterHeight + return height ?? super.tableView(tableView, estimatedHeightForFooterInSection: section) } private func copyLayoutMargins(from tableView: UITableView, to view: UIView) { diff --git a/BentoTests/AdapterStoreTests.swift b/BentoTests/AdapterStoreTests.swift new file mode 100644 index 0000000..a39910b --- /dev/null +++ b/BentoTests/AdapterStoreTests.swift @@ -0,0 +1,339 @@ +@testable import Bento +import Nimble +import XCTest +import FlexibleDiff + +typealias TestStore = AdapterStore + +class AdapterStoreTests: XCTestCase { + let defaultBoundSize = CGSize(width: 10000, height: 10000) + + func test_should_not_be_enabled_by_default() { + var store = TestStore() + expect(store.cachesSizeInformation) == false + expect(store.size(for: .header, inSection: 0)) == .cachingDisabled + expect(store.size(forItemAt: IndexPath(item: 0, section: 0))).to(beNil()) + expect(store.size(for: .header, inSection: .max)) == .cachingDisabled + expect(store.size(forItemAt: IndexPath(item: .max, section: .max))).to(beNil()) + } + + func test_supplement_should_return_size() { + var store = TestStore() + store.cachesSizeInformation = true + store.boundSize = defaultBoundSize + + store.update( + with: makeSingleSection(withJust: .header, value: 123), + knownSupplements: [.header] + ) + + expect(store.size(for: .header, inSection: 0)) == .size(CGSize(width: 123, height: 123)) + + store.update( + with: makeSingleSection(withJust: .header, value: 456), + knownSupplements: [.header] + ) + + expect(store.size(for: .header, inSection: 0)) == .size(CGSize(width: 456, height: 456)) + } + + func test_item_should_return_size() { + var store = TestStore() + store.cachesSizeInformation = true + store.boundSize = defaultBoundSize + + store.update( + with: makeSingleSectionWithSingleItem(value: 123), + knownSupplements: [] + ) + + expect(store.size(forItemAt: [0, 0])) == CGSize(width: 123, height: 123) + + store.update( + with: makeSingleSectionWithSingleItem(value: 456), + knownSupplements: [] + ) + + expect(store.size(forItemAt: [0, 0])) == CGSize(width: 456, height: 456) + } + + func test_should_return_correct_size_after_deletion_no_changeset() { + var store = TestStore() + store.cachesSizeInformation = true + store.boundSize = defaultBoundSize + + store.update(with: makeStubA(), knownSupplements: []) + assert(&store, matches: makeStubA()) + + store.update(with: makeStubB(), knownSupplements: []) + assert(&store, matches: makeStubB()) + } + + func test_should_return_correct_size_after_insertion_no_changeset() { + var store = TestStore() + store.cachesSizeInformation = true + store.boundSize = defaultBoundSize + + store.update(with: makeStubB(), knownSupplements: []) + assert(&store, matches: makeStubB()) + + store.update(with: makeStubA(), knownSupplements: []) + assert(&store, matches: makeStubA()) + } + + func test_should_return_correct_size_after_deletion_has_changeset() { + var store = TestStore() + store.cachesSizeInformation = true + store.boundSize = defaultBoundSize + + store.update(with: makeStubA(), knownSupplements: []) + assert(&store, matches: makeStubA()) + + store.update(with: makeStubB(), knownSupplements: [], changeset: stubChangesetAToB()) + assert(&store, matches: makeStubB()) + } + + func test_should_return_correct_size_after_insertion_has_changeset() { + var store = TestStore() + store.cachesSizeInformation = true + store.boundSize = defaultBoundSize + + store.update(with: makeStubB(), knownSupplements: []) + assert(&store, matches: makeStubB()) + + store.update(with: makeStubA(), knownSupplements: [], changeset: stubChangesetBToA()) + assert(&store, matches: makeStubA()) + } + + func test_should_return_correct_size_after_sections_have_moved_scenario_1() { + var store = TestStore() + store.cachesSizeInformation = true + store.boundSize = defaultBoundSize + + store.update(with: makeStubC(), knownSupplements: []) + assert(&store, matches: makeStubC()) + + store.update(with: makeStubD(), knownSupplements: [], changeset: stubChangesetCAndD()) + assert(&store, matches: makeStubD()) + } + + func test_should_return_correct_size_after_sections_have_moved_scenario_2() { + var store = TestStore() + store.cachesSizeInformation = true + store.boundSize = defaultBoundSize + + store.update(with: makeStubD(), knownSupplements: []) + assert(&store, matches: makeStubD()) + + store.update(with: makeStubC(), knownSupplements: [], changeset: stubChangesetCAndD()) + assert(&store, matches: makeStubC()) + } + + func test_boundSizeChange_should_invalidate_sizes() { + var store = TestStore() + store.cachesSizeInformation = true + store.boundSize = defaultBoundSize + + store.update( + with: makeSingleSectionWithSingleItem(value: 5000), + knownSupplements: [] + ) + + expect(store.size(forItemAt: [0, 0])) == CGSize(width: 5000, height: 5000) + + store.boundSize = CGSize(width: 1, height: 1) + expect(store.size(forItemAt: [0, 0])) == CGSize(width: 1, height: 5000) + } + + private func makeSingleSectionWithSingleItem(value: CGFloat) -> [Section] { + return [ + Section( + id: 0, + items: [ + Node(id: 0, component: + TestComponent(size: CGSize(width: value, height: value) + )) + ] + ) + ] + } + + private func makeSingleSection(withJust supplement: Supplement, value: CGFloat) -> [Section] { + return [ + Section(id: 0) + .adding( + supplement, + TestComponent(size: CGSize(width: value, height: value)) + ) + ] + } + + private func makeStubA() -> [Section] { + return [ + Section(id: 0, items: [ + Node(id: 0, component: + TestComponent(size: CGSize(width: 123, height: 123) + )), + Node(id: 1, component: + TestComponent(size: CGSize(width: 456, height: 456) + )) + ]), + Section(id: 1, items: [ + Node(id: 2, component: + TestComponent(size: CGSize(width: 789, height: 789) + )), + Node(id: 3, component: + TestComponent(size: CGSize(width: 901, height: 901) + )), + Node(id: 4, component: + TestComponent(size: CGSize(width: 234, height: 234) + )) + ]) + ] + } + + private func makeStubB() -> [Section] { + return [ + Section(id: 1, items: [ + Node(id: 3, component: + TestComponent(size: CGSize(width: 432, height: 432) + )) + ]) + ] + } + + private func stubChangesetAToB() -> SectionedChangeset { + return SectionedChangeset( + sections: Changeset(removals: [0], mutations: [1]), + mutatedSections: [ + SectionedChangeset.MutatedSection( + source: 1, + destination: 0, + changeset: Changeset( + removals: [0, 2], + moves: [Changeset.Move(source: 1, destination: 0, isMutated: true)] + ) + ) + ] + ) + } + + private func stubChangesetBToA() -> SectionedChangeset { + return SectionedChangeset( + sections: Changeset(inserts: [0], mutations: [1]), + mutatedSections: [ + SectionedChangeset.MutatedSection( + source: 0, + destination: 1, + changeset: Changeset( + inserts: [0, 2], + moves: [Changeset.Move(source: 0, destination: 1, isMutated: true)] + ) + ) + ] + ) + } + + private func makeStubC() -> [Section] { + return [ + Section(id: 0, items: [ + Node(id: 0, component: + TestComponent(size: CGSize(width: 123, height: 123) + )) + ]), + Section(id: 50, items: [ + Node(id: 50, component: + TestComponent(size: CGSize(width: 456, height: 456) + )) + ]), + Section(id: 100, items: [ + Node(id: 100, component: + TestComponent(size: CGSize(width: 789, height: 789) + )) + ]) + ] + } + + private func makeStubD() -> [Section] { + return [ + Section(id: 100, items: [ + Node(id: 100, component: + TestComponent(size: CGSize(width: 789, height: 789) + )) + ]), + Section(id: 50, items: [ + Node(id: 50, component: + TestComponent(size: CGSize(width: 1000, height: 1000) + )) + ]), + Section(id: 0, items: [ + Node(id: 0, component: + TestComponent(size: CGSize(width: 123, height: 123) + )) + ]) + ] + } + + private func stubChangesetCAndD() -> SectionedChangeset { + return SectionedChangeset( + sections: Changeset( + mutations: [1], + moves: [ + Changeset.Move(source: 0, destination: 2, isMutated: false), + Changeset.Move(source: 2, destination: 0, isMutated: false), + ] + ), + mutatedSections: [ + SectionedChangeset.MutatedSection( + source: 1, + destination: 1, + changeset: Changeset(mutations: [0]) + ) + ] + ) + } +} + +private func assert( + _ pointer: UnsafeMutablePointer, + matches stub: [Section], + file: FileString = #file, + line: UInt = #line +) { + for (sectionOffset, section) in stub.enumerated() { + for (itemOffset, item) in section.items.enumerated() { + expect( + pointer.pointee.size(forItemAt: [sectionOffset, itemOffset]), + file: file, + line: line + ) == item.component(as: TestComponent.self)?.size + } + } +} + +struct TestComponent: Renderable { + let size: CGSize + + init(size: CGSize) { + self.size = size + } + + func render(in view: TestComponent.View) { + view.size = size + } + + class View: UIView { + var size: CGSize = .zero + + override func systemLayoutSizeFitting( + _ targetSize: CGSize, + withHorizontalFittingPriority horizontalFittingPriority: UILayoutPriority, + verticalFittingPriority: UILayoutPriority + ) -> CGSize { + return CGSize( + width: min(size.width, horizontalFittingPriority == .required ? targetSize.width : .greatestFiniteMagnitude), + height: min(size.height, verticalFittingPriority == .required ? targetSize.height : .greatestFiniteMagnitude) + ) + } + } +} From abf3ddc3ac6e95edafd338929f1ecb3f976e9e70 Mon Sep 17 00:00:00 2001 From: Michael Brown Date: Mon, 8 Apr 2019 13:40:50 +0100 Subject: [PATCH 2/2] Update Bento/Adapters/AdapterStore.swift Co-Authored-By: andersio --- Bento/Adapters/AdapterStore.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Bento/Adapters/AdapterStore.swift b/Bento/Adapters/AdapterStore.swift index d051c6a..6e4b00e 100644 --- a/Bento/Adapters/AdapterStore.swift +++ b/Bento/Adapters/AdapterStore.swift @@ -103,7 +103,7 @@ struct AdapterStore { newElement: SectionInfo(), whenInserted: { info, index in info.itemSizes = Array(repeating: .unknown, count: sections[index].items.count) - } + } ) // Apply changeset to the old section info for all mutated sections.