From 920d5e13e26780c32a5c419331d467ebaafb25c0 Mon Sep 17 00:00:00 2001 From: Bryan Keller Date: Fri, 5 Jun 2026 11:51:16 -0700 Subject: [PATCH 1/7] Update demo project --- .../PerformanceDemoViewController.swift | 6 +++--- Example/MagazineLayoutExample/RootMenuViewController.swift | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) 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" } } From 6d014d71cdc464909de4d0dbfb0ee410b1a3f5c7 Mon Sep 17 00:00:00 2001 From: Bryan Keller Date: Sat, 6 Jun 2026 15:56:56 -0700 Subject: [PATCH 2/7] Add signpost logging --- MagazineLayout.podspec | 4 +- MagazineLayout.xcodeproj/project.pbxproj | 8 +-- MagazineLayout/Public/MagazineLayout.swift | 57 +++++++++++++++++++ ...MagazineLayoutCollectionReusableView.swift | 7 +++ .../MagazineLayoutCollectionViewCell.swift | 7 +++ Package.swift | 4 +- 6 files changed, 79 insertions(+), 8 deletions(-) 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/Public/MagazineLayout.swift b/MagazineLayout/Public/MagazineLayout.swift index 60175dd..93fa6cd 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. @@ -59,11 +60,23 @@ 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 } 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() // Save the previous collection view width if necessary @@ -80,6 +93,9 @@ public final class MagazineLayout: UICollectionViewLayout { // Update layout metrics if necessary if prepareActions.contains(.updateLayoutMetrics) { + let signpostID = OSSignpostID(log: signpostLog) + os_signpost(.begin, log: signpostLog, name: SignpostName.prepareUpdateLayoutMetrics, signpostID: signpostID) + for sectionIndex in 0.. Date: Fri, 5 Jun 2026 16:24:11 -0700 Subject: [PATCH 3/7] Add new experimental perf improvements flag --- MagazineLayout/Public/MagazineLayout.swift | 3 +++ .../Public/MagazineLayoutInvalidationContext.swift | 7 ++----- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/MagazineLayout/Public/MagazineLayout.swift b/MagazineLayout/Public/MagazineLayout.swift index 93fa6cd..151ef0b 100755 --- a/MagazineLayout/Public/MagazineLayout.swift +++ b/MagazineLayout/Public/MagazineLayout.swift @@ -43,6 +43,9 @@ public final class MagazineLayout: UICollectionViewLayout { // 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 diff --git a/MagazineLayout/Public/MagazineLayoutInvalidationContext.swift b/MagazineLayout/Public/MagazineLayoutInvalidationContext.swift index 4119ca6..36ddc55 100644 --- a/MagazineLayout/Public/MagazineLayoutInvalidationContext.swift +++ b/MagazineLayout/Public/MagazineLayoutInvalidationContext.swift @@ -20,15 +20,12 @@ import UIKit /// Used to indicate that collection view properties and/or delegate layout metrics changed. public final class MagazineLayoutInvalidationContext: UICollectionViewLayoutInvalidationContext { - /// A temporary flag to enable safely testing a change to how layout invalidation works. - public static var _invalidateLayoutMetricsDefaultValue = true - /// Indicates whether to recompute the positions and sizes of elements based on the current collection view and delegate layout /// metrics. /// - /// Defaults to `false`. Set to `true` when delegate-provided layout values (e.g. item size + /// Set to `true` when delegate-provided layout values (e.g. item size /// modes, header/footer visibility, section metrics) have changed and the layout needs to /// re-query the delegate. - public var invalidateLayoutMetrics = _invalidateLayoutMetricsDefaultValue + public var invalidateLayoutMetrics = !MagazineLayout._enableExperimentalOptimizations } From 8f2bbb9a3820b6d07bfd8d3833c82e43ee1084d6 Mon Sep 17 00:00:00 2001 From: Bryan Keller Date: Fri, 5 Jun 2026 16:33:55 -0700 Subject: [PATCH 4/7] Optimize delegate, collection view, and modelState access --- MagazineLayout/Public/MagazineLayout.swift | 62 +++++++++++++++------- 1 file changed, 44 insertions(+), 18 deletions(-) diff --git a/MagazineLayout/Public/MagazineLayout.swift b/MagazineLayout/Public/MagazineLayout.swift index 151ef0b..84715fd 100755 --- a/MagazineLayout/Public/MagazineLayout.swift +++ b/MagazineLayout/Public/MagazineLayout.swift @@ -41,6 +41,11 @@ 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. @@ -70,7 +75,7 @@ public final class MagazineLayout: UICollectionViewLayout { } guard collectionView != nil else { return .zero } - return layoutState.contentSize + return updatedLayoutState().contentSize } override public func prepare() { @@ -82,6 +87,9 @@ public final class MagazineLayout: UICollectionViewLayout { 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 @@ -137,7 +145,7 @@ public final class MagazineLayout: UICollectionViewLayout { os_signpost(.begin, log: signpostLog, name: SignpostName.prepareRecreateSectionModels, signpostID: signpostID) layoutStateBeforeRecreateSectionModels = LayoutState( - modelState: layoutState.modelState.copy(), + modelState: modelState.copy(), bounds: currentCollectionView.bounds, contentInset: contentInset, scale: scale, @@ -165,7 +173,7 @@ public final class MagazineLayout: UICollectionViewLayout { } let layoutStateBeforeCollectionViewUpdates = LayoutState( - modelState: layoutState.modelState.copy(), + modelState: modelState.copy(), bounds: currentCollectionView.bounds, contentInset: contentInset, scale: scale, @@ -259,6 +267,7 @@ public final class MagazineLayout: UICollectionViewLayout { if let layoutStateBeforeCollectionViewUpdates{ let targetContentOffsetAnchor = layoutStateBeforeCollectionViewUpdates.targetContentOffsetAnchor + let layoutState = updatedLayoutState() let targetYOffset = layoutState.yOffset( for: targetContentOffsetAnchor, isPerformingBatchUpdates: true) @@ -280,7 +289,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, @@ -708,13 +717,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, @@ -867,7 +878,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) @@ -929,12 +940,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 @@ -961,10 +986,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 } @@ -973,7 +994,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 @@ -981,10 +1011,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( From 14333957fee79fd945be97b01c31bd3df2f72cae Mon Sep 17 00:00:00 2001 From: Bryan Keller Date: Fri, 5 Jun 2026 16:36:37 -0700 Subject: [PATCH 5/7] Optimize IndexPath allocations Fix index paths --- MagazineLayout/Public/MagazineLayout.swift | 58 +++++++++++++++++++--- 1 file changed, 50 insertions(+), 8 deletions(-) diff --git a/MagazineLayout/Public/MagazineLayout.swift b/MagazineLayout/Public/MagazineLayout.swift index 84715fd..fd21179 100755 --- a/MagazineLayout/Public/MagazineLayout.swift +++ b/MagazineLayout/Public/MagazineLayout.swift @@ -102,12 +102,18 @@ public final class MagazineLayout: UICollectionViewLayout { hasPinnedHeaderOrFooter = false } + var reusableIndexPath = IndexPath(item: 0, section: 0) + // Update layout metrics if necessary if prepareActions.contains(.updateLayoutMetrics) { let signpostID = OSSignpostID(log: signpostLog) os_signpost(.begin, log: signpostLog, name: SignpostName.prepareUpdateLayoutMetrics, signpostID: signpostID) for sectionIndex in 0..]() + var reusableIndexPath = IndexPath(item: 0, section: 0) for updateItem in updateItems { let updateAction = updateItem.updateAction @@ -194,7 +212,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) @@ -222,7 +245,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) @@ -1115,9 +1143,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.. Date: Sat, 6 Jun 2026 17:47:58 -0700 Subject: [PATCH 6/7] Optimize prepare when just updating widths --- MagazineLayout/Public/MagazineLayout.swift | 26 +++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/MagazineLayout/Public/MagazineLayout.swift b/MagazineLayout/Public/MagazineLayout.swift index fd21179..da81127 100755 --- a/MagazineLayout/Public/MagazineLayout.swift +++ b/MagazineLayout/Public/MagazineLayout.swift @@ -104,6 +104,19 @@ public final class MagazineLayout: UICollectionViewLayout { 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.. Date: Fri, 5 Jun 2026 20:15:22 -0700 Subject: [PATCH 7/7] Defer SectionModel calculateElementFramesIfNecessary --- MagazineLayout/LayoutCore/SectionModel.swift | 25 ++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) 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 }