diff --git a/Example/MagazineLayoutExample/PerformanceDemoViewController.swift b/Example/MagazineLayoutExample/PerformanceDemoViewController.swift index cd3b0ad..f255d52 100644 --- a/Example/MagazineLayoutExample/PerformanceDemoViewController.swift +++ b/Example/MagazineLayoutExample/PerformanceDemoViewController.swift @@ -26,7 +26,7 @@ final class PerformanceDemoViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() - title = "Performance (10K Items)" + title = "Performance (100K Items)" view.backgroundColor = .systemBackground navigationItem.rightBarButtonItems = [ @@ -71,8 +71,8 @@ final class PerformanceDemoViewController: UIViewController { ] private func loadInitialData() { - // Create 10,000 items - items = (0..<10_000).map { index in + // Create 100,000 items + items = (0..<100_000).map { index in let color = colors[index % colors.count] let item = PerformanceItem(id: nextItemID, color: color) nextItemID += 1 diff --git a/Example/MagazineLayoutExample/RootMenuViewController.swift b/Example/MagazineLayoutExample/RootMenuViewController.swift index 7b5cf31..7a7a4b8 100644 --- a/Example/MagazineLayoutExample/RootMenuViewController.swift +++ b/Example/MagazineLayoutExample/RootMenuViewController.swift @@ -198,7 +198,7 @@ private enum DemoOption: String, CaseIterable { case .messageThread: return "Bottom-to-top layout with pagination" case .performance: - return "10,000 items with traditional data source" + return "100,000 items with traditional data source" } } diff --git a/MagazineLayout.podspec b/MagazineLayout.podspec index 6e05b5b..204218a 100644 --- a/MagazineLayout.podspec +++ b/MagazineLayout.podspec @@ -8,8 +8,8 @@ Pod::Spec.new do |s| s.source = { :git => 'https://github.com/airbnb/MagazineLayout.git', :tag => "v#{ s.version.to_s }" } s.swift_version = '4.0' s.source_files = 'MagazineLayout/**/*.{swift,h}' - s.ios.deployment_target = '10.0' - s.tvos.deployment_target = '10.0' + s.ios.deployment_target = '12.0' + s.tvos.deployment_target = '12.0' s.pod_target_xcconfig = { 'APPLICATION_EXTENSION_API_ONLY' => 'YES' diff --git a/MagazineLayout.xcodeproj/project.pbxproj b/MagazineLayout.xcodeproj/project.pbxproj index 3730e5e..2f49eff 100644 --- a/MagazineLayout.xcodeproj/project.pbxproj +++ b/MagazineLayout.xcodeproj/project.pbxproj @@ -468,7 +468,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 10.0; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -477,7 +477,7 @@ SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 4.0; TARGETED_DEVICE_FAMILY = "1,2,3"; - TVOS_DEPLOYMENT_TARGET = 10.0; + TVOS_DEPLOYMENT_TARGET = 12.0; VERSIONING_SYSTEM = "apple-generic"; VERSION_INFO_PREFIX = ""; }; @@ -530,7 +530,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 10.0; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SUPPORTED_PLATFORMS = "iphonesimulator iphoneos appletvsimulator appletvos"; @@ -538,7 +538,7 @@ SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_VERSION = 4.0; TARGETED_DEVICE_FAMILY = "1,2,3"; - TVOS_DEPLOYMENT_TARGET = 10.0; + TVOS_DEPLOYMENT_TARGET = 12.0; VALIDATE_PRODUCT = YES; VERSIONING_SYSTEM = "apple-generic"; VERSION_INFO_PREFIX = ""; diff --git a/MagazineLayout/LayoutCore/SectionModel.swift b/MagazineLayout/LayoutCore/SectionModel.swift index 404ae81..8b780ed 100755 --- a/MagazineLayout/LayoutCore/SectionModel.swift +++ b/MagazineLayout/LayoutCore/SectionModel.swift @@ -38,7 +38,10 @@ struct SectionModel { numberOfRows = 0 updateIndexOfFirstInvalidatedRowIfNecessary(toProposedIndex: 0) - calculateElementFramesIfNecessary() + + if !MagazineLayout._enableExperimentalOptimizations { + calculateElementFramesIfNecessary() + } } // MARK: Internal @@ -257,15 +260,22 @@ struct SectionModel { } mutating func removeFooter() -> Bool { - guard let indexOfFooter = indexOfFooterRow() else { + guard footerModel != nil else { return false } - updateIndexOfFirstInvalidatedRowIfNecessary(toProposedIndex: indexOfFooter) + // `indexOfFooterRow()` is `nil` if the section hasn't been laid out yet (deferred layout + // calculation). In that case the whole section is already invalidated from row 0, so there's no + // additional row to invalidate. + if let indexOfFooter = indexOfFooterRow() { + updateIndexOfFirstInvalidatedRowIfNecessary(toProposedIndex: indexOfFooter) + } footerModel = nil return true } mutating func updateItemHeight(toPreferredHeight preferredHeight: CGFloat, atIndex index: Int) { + calculateElementFramesIfNecessary() + // Accessing this array using an unsafe, untyped (raw) pointer avoids expensive copy-on-writes // and Swift retain / release calls. itemModels.withUnsafeMutableBufferPointer { directlyMutableItemModels in @@ -292,6 +302,8 @@ struct SectionModel { } mutating func updateHeaderHeight(toPreferredHeight preferredHeight: CGFloat) { + calculateElementFramesIfNecessary() + headerModel?.preferredHeight = preferredHeight if let indexOfHeaderRow = indexOfHeaderRow(), let headerModel = headerModel { @@ -312,6 +324,8 @@ struct SectionModel { } mutating func updateFooterHeight(toPreferredHeight preferredHeight: CGFloat) { + calculateElementFramesIfNecessary() + footerModel?.preferredHeight = preferredHeight if let indexOfFooterRow = indexOfFooterRow(), let footerModel = footerModel { @@ -393,7 +407,10 @@ struct SectionModel { } private func indexOfFooterRow() -> Int? { - guard footerModel != nil else { return nil } + // `numberOfRows` is 0 until the section's element frames have been calculated. With deferred + // layout calculation, the footer's row index isn't known yet in that state, so we return `nil` + // rather than a bogus `numberOfRows - 1` (which would be -1). + guard footerModel != nil, numberOfRows > 0 else { return nil } return numberOfRows - 1 } diff --git a/MagazineLayout/Public/MagazineLayout.swift b/MagazineLayout/Public/MagazineLayout.swift index 60175dd..da81127 100755 --- a/MagazineLayout/Public/MagazineLayout.swift +++ b/MagazineLayout/Public/MagazineLayout.swift @@ -13,6 +13,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +import os import UIKit /// A collection view layout that can display items in a grid and list arrangement. @@ -40,8 +41,16 @@ public final class MagazineLayout: UICollectionViewLayout { super.init(coder: aDecoder) } + deinit { + _currentCollectionView = nil + _delegateMagazineLayout = nil + } + // MARK: Public + /// A temporary flag to enable safely testing some optimizations. + public static var _enableExperimentalOptimizations = false + /// The vertical layout direction of items in the collection view. This property changes the behavior of /// scroll-position-preservation when performing batch updates or when the collection view's bounds changes. public var verticalLayoutDirection = MagazineLayoutVerticalLayoutDirection.topToBottom @@ -59,13 +68,28 @@ public final class MagazineLayout: UICollectionViewLayout { } override public var collectionViewContentSize: CGSize { + let signpostID = OSSignpostID(log: signpostLog) + os_signpost(.begin, log: signpostLog, name: SignpostName.collectionViewContentSize, signpostID: signpostID) + defer { + os_signpost(.end, log: signpostLog, name: SignpostName.collectionViewContentSize, signpostID: signpostID) + } + guard collectionView != nil else { return .zero } - return layoutState.contentSize + return updatedLayoutState().contentSize } override public func prepare() { + let prepareSignpostID = OSSignpostID(log: signpostLog) + os_signpost(.begin, log: signpostLog, name: SignpostName.prepare, signpostID: prepareSignpostID) + defer { + os_signpost(.end, log: signpostLog, name: SignpostName.prepare, signpostID: prepareSignpostID) + } + super.prepare() + _currentCollectionView = collectionView + _delegateMagazineLayout = currentCollectionView.delegate as? UICollectionViewDelegateMagazineLayout + // Save the previous collection view width if necessary if prepareActions.contains(.cachePreviousWidth) { cachedCollectionViewWidth = currentCollectionView.bounds.width @@ -78,9 +102,31 @@ public final class MagazineLayout: UICollectionViewLayout { hasPinnedHeaderOrFooter = false } + var reusableIndexPath = IndexPath(item: 0, section: 0) + + // Update widths if necessary (e.g. after rotation or other bounds change) + if prepareActions.contains(.updateWidths) { + let signpostID = OSSignpostID(log: signpostLog) + os_signpost(.begin, log: signpostLog, name: SignpostName.prepareUpdateWidths, signpostID: signpostID) + + for sectionIndex in 0..]() + var reusableIndexPath = IndexPath(item: 0, section: 0) for updateItem in updateItems { let updateAction = updateItem.updateAction @@ -154,7 +225,12 @@ public final class MagazineLayout: UICollectionViewLayout { } if indexPath.item == NSNotFound { - let sectionModel = sectionModelForSection(atIndex: indexPath.section) + if Self._enableExperimentalOptimizations { + reusableIndexPath.section = indexPath.section + } + let sectionModel = sectionModelForSection( + atIndex: indexPath.section, + reusableIndexPath: &reusableIndexPath) updates.append(.sectionReload(sectionIndex: indexPath.section, newSection: sectionModel)) } else { let itemModel = itemModelForItem(at: indexPath) @@ -182,7 +258,12 @@ public final class MagazineLayout: UICollectionViewLayout { } if indexPath.item == NSNotFound { - let sectionModel = sectionModelForSection(atIndex: indexPath.section) + if Self._enableExperimentalOptimizations { + reusableIndexPath.section = indexPath.section + } + let sectionModel = sectionModelForSection( + atIndex: indexPath.section, + reusableIndexPath: &reusableIndexPath) updates.append(.sectionInsert(sectionIndex: indexPath.section, newSection: sectionModel)) } else { let itemModel = itemModelForItem(at: indexPath) @@ -227,6 +308,7 @@ public final class MagazineLayout: UICollectionViewLayout { if let layoutStateBeforeCollectionViewUpdates{ let targetContentOffsetAnchor = layoutStateBeforeCollectionViewUpdates.targetContentOffsetAnchor + let layoutState = updatedLayoutState() let targetYOffset = layoutState.yOffset( for: targetContentOffsetAnchor, isPerformingBatchUpdates: true) @@ -248,7 +330,7 @@ public final class MagazineLayout: UICollectionViewLayout { if currentCollectionView.bounds.size != oldBounds.size { layoutStateBeforeAnimatedBoundsChange = LayoutState( - modelState: layoutState.modelState.copy(), + modelState: modelState.copy(), bounds: oldBounds, contentInset: contentInset, scale: scale, @@ -290,6 +372,12 @@ public final class MagazineLayout: UICollectionViewLayout { // is invoked, enabling them to resolve their layout in time. guard !hasDataSourceCountInvalidationBeforeReceivingUpdateItems else { return nil } + let signpostID = OSSignpostID(log: signpostLog) + os_signpost(.begin, log: signpostLog, name: SignpostName.layoutAttributesForElementsInRect, signpostID: signpostID) + defer { + os_signpost(.end, log: signpostLog, name: SignpostName.layoutAttributesForElementsInRect, signpostID: signpostID) + } + var layoutAttributesInRect = [UICollectionViewLayoutAttributes]() let headerLocationFramePairs = modelState.headerLocationFramePairs(forHeadersIn: rect) @@ -670,13 +758,15 @@ public final class MagazineLayout: UICollectionViewLayout { withOriginalAttributes: originalAttributes) as! MagazineLayoutInvalidationContext context.invalidateLayoutMetrics = false + let layoutState = updatedLayoutState() + switch preferredAttributes.representedElementCategory { case .cell: let targetContentOffsetAnchor = ( layoutStateBeforeRecreateSectionModels ?? layoutStateBeforeCollectionViewUpdates ?? layoutStateBeforeAnimatedBoundsChange ?? - self.layoutState + layoutState ).targetContentOffsetAnchor let targetYOffsetBefore = layoutState.yOffset( for: targetContentOffsetAnchor, @@ -762,6 +852,12 @@ public final class MagazineLayout: UICollectionViewLayout { return } + let signpostID = OSSignpostID(log: signpostLog) + os_signpost(.begin, log: signpostLog, name: SignpostName.invalidateLayout, signpostID: signpostID) + defer { + os_signpost(.end, log: signpostLog, name: SignpostName.invalidateLayout, signpostID: signpostID) + } + // If our layout direction is `bottomToTop`, allow changes to the top and bottom content insets // to automatically adjust the content offset. `UICollectionView` behaves this way by default // when the top content inset changes, so this adds the same behavior. @@ -792,7 +888,12 @@ public final class MagazineLayout: UICollectionViewLayout { screenScale: scale) ?? false if !isSameWidth { - prepareActions.formUnion([.updateLayoutMetrics, .cachePreviousWidth]) + prepareActions.formUnion(.cachePreviousWidth) + if MagazineLayout._enableExperimentalOptimizations { + prepareActions.formUnion(.updateWidths) + } else { + prepareActions.formUnion(.updateLayoutMetrics) + } } if context.invalidateLayoutMetrics && shouldInvalidateLayoutMetrics { @@ -823,7 +924,7 @@ public final class MagazineLayout: UICollectionViewLayout { return super.targetContentOffset(forProposedContentOffset: proposedContentOffset) } - let yOffset = layoutState.yOffset( + let yOffset = updatedLayoutState().yOffset( for: layoutStateBefore.targetContentOffsetAnchor, isPerformingBatchUpdates: layoutStateBeforeCollectionViewUpdates != nil) @@ -873,7 +974,8 @@ public final class MagazineLayout: UICollectionViewLayout { static let recreateSectionModels = PrepareActions(rawValue: 1 << 0) static let updateLayoutMetrics = PrepareActions(rawValue: 1 << 1) - static let cachePreviousWidth = PrepareActions(rawValue: 1 << 2) + static let updateWidths = PrepareActions(rawValue: 1 << 2) + static let cachePreviousWidth = PrepareActions(rawValue: 1 << 3) } private var prepareActions: PrepareActions = [] @@ -885,12 +987,26 @@ public final class MagazineLayout: UICollectionViewLayout { private var cachedCollectionViewWidth: CGFloat? private var previousContentInset: UIEdgeInsets? + /// Strongly retained to avoid weak reference overhead; released in `deinit`. + private var _currentCollectionView: UICollectionView? + private var _delegateMagazineLayout: UICollectionViewDelegateMagazineLayout? + + @inline(__always) private var currentCollectionView: UICollectionView { - guard let collectionView = collectionView else { - preconditionFailure("`collectionView` should not be `nil`") + if MagazineLayout._enableExperimentalOptimizations { + _currentCollectionView ?? collectionView! + } else { + collectionView! } + } - return collectionView + @inline(__always) + private var delegateMagazineLayout: UICollectionViewDelegateMagazineLayout? { + if MagazineLayout._enableExperimentalOptimizations { + _delegateMagazineLayout + } else { + currentCollectionView.delegate as? UICollectionViewDelegateMagazineLayout + } } // Used to provide the model state with the current visible bounds for the sole purpose of @@ -917,10 +1033,6 @@ public final class MagazineLayout: UICollectionViewLayout { height: currentCollectionView.bounds.height - contentInset.top - contentInset.bottom + refreshControlHeight) } - private var delegateMagazineLayout: UICollectionViewDelegateMagazineLayout? { - return currentCollectionView.delegate as? UICollectionViewDelegateMagazineLayout - } - private var scale: CGFloat { collectionView?.traitCollection.nonZeroDisplayScale ?? 1 } @@ -929,7 +1041,16 @@ public final class MagazineLayout: UICollectionViewLayout { currentCollectionView.adjustedContentInset } - private var layoutState: LayoutState { + private var modelState: ModelState { + if MagazineLayout._enableExperimentalOptimizations { + _layoutState.modelState + } else { + updatedLayoutState().modelState + } + } + + /// Relatively expensive compared to just grabbing the `_layoutState`; only use if you need updated metrics. + private func updatedLayoutState() -> LayoutState { _layoutState.bounds = currentCollectionView.bounds _layoutState.contentInset = contentInset _layoutState.scale = scale @@ -937,10 +1058,6 @@ public final class MagazineLayout: UICollectionViewLayout { return _layoutState } - private var modelState: ModelState { - layoutState.modelState - } - private func metricsForSection(atIndex sectionIndex: Int) -> MagazineLayoutSectionMetrics { guard let delegateMagazineLayout = delegateMagazineLayout else { return MagazineLayoutSectionMetrics.defaultSectionMetrics( @@ -1045,9 +1162,23 @@ public final class MagazineLayout: UICollectionViewLayout { } } - private func sectionModelForSection(atIndex sectionIndex: Int) -> SectionModel { - let itemModels = (0.. SectionModel + { + let numberOfItems = currentCollectionView.numberOfItems(inSection: sectionIndex) + var itemModels = [ItemModel]() + itemModels.reserveCapacity(numberOfItems) + + for itemIndex in 0..