From dd93dddf24c84de07caf7d5294a03d07c2d8be58 Mon Sep 17 00:00:00 2001 From: Bryan Keller Date: Mon, 8 Jun 2026 17:19:59 -0700 Subject: [PATCH] Optimize sectionModels and itemModels mutations --- MagazineLayout/LayoutCore/ModelState.swift | 243 ++++++++++++++----- MagazineLayout/LayoutCore/SectionModel.swift | 45 +++- MagazineLayout/Public/MagazineLayout.swift | 102 +++++--- 3 files changed, 305 insertions(+), 85 deletions(-) diff --git a/MagazineLayout/LayoutCore/ModelState.swift b/MagazineLayout/LayoutCore/ModelState.swift index a3166fc..cc44a27 100755 --- a/MagazineLayout/LayoutCore/ModelState.swift +++ b/MagazineLayout/LayoutCore/ModelState.swift @@ -223,11 +223,10 @@ final class ModelState { } var itemFrame: CGRect! - mutateSectionModels( - withUnsafeMutableBufferPointer: { directlyMutableSectionModels in - itemFrame = directlyMutableSectionModels[itemLocation.sectionIndex].calculateFrameForItem( - atIndex: itemLocation.elementIndex) - }) + sectionModels.withUnsafeMutableBufferPointer { sectionModels in + itemFrame = sectionModels[itemLocation.sectionIndex].calculateFrameForItem( + atIndex: itemLocation.elementIndex) + } itemFrame.origin.y += sectionMinY return itemFrame @@ -243,15 +242,14 @@ final class ModelState { let currentVisibleBounds = currentVisibleBoundsProvider() var headerFrame: CGRect? - mutateSectionModels( - withUnsafeMutableBufferPointer: { directlyMutableSectionModels in - headerFrame = directlyMutableSectionModels[sectionIndex].calculateFrameForHeader( - inSectionVisibleBounds: CGRect( - x: currentVisibleBounds.minX, - y: currentVisibleBounds.minY - sectionMinY, - width: currentVisibleBounds.width, - height: currentVisibleBounds.height)) - }) + sectionModels.withUnsafeMutableBufferPointer { sectionModels in + headerFrame = sectionModels[sectionIndex].calculateFrameForHeader( + inSectionVisibleBounds: CGRect( + x: currentVisibleBounds.minX, + y: currentVisibleBounds.minY - sectionMinY, + width: currentVisibleBounds.width, + height: currentVisibleBounds.height)) + } headerFrame?.origin.y += sectionMinY return headerFrame @@ -267,15 +265,14 @@ final class ModelState { let currentVisibleBounds = currentVisibleBoundsProvider() var footerFrame: CGRect? - mutateSectionModels( - withUnsafeMutableBufferPointer: { directlyMutableSectionModels in - footerFrame = directlyMutableSectionModels[sectionIndex].calculateFrameForFooter( + sectionModels.withUnsafeMutableBufferPointer { sectionModels in + footerFrame = sectionModels[sectionIndex].calculateFrameForFooter( inSectionVisibleBounds: CGRect( x: currentVisibleBounds.minX, y: currentVisibleBounds.minY - sectionMinY, width: currentVisibleBounds.width, height: currentVisibleBounds.height)) - }) + } footerFrame?.origin.y += sectionMinY return footerFrame @@ -290,10 +287,9 @@ final class ModelState { } var backgroundFrame: CGRect? - mutateSectionModels( - withUnsafeMutableBufferPointer: { directlyMutableSectionModels in - backgroundFrame = directlyMutableSectionModels[sectionIndex].calculateFrameForBackground() - }) + sectionModels.withUnsafeMutableBufferPointer { sectionModels in + backgroundFrame = sectionModels[sectionIndex].calculateFrameForBackground() + } backgroundFrame?.origin.y += sectionMinY return backgroundFrame @@ -357,11 +353,34 @@ final class ModelState { invalidateSectionMaxYsCacheForSectionIndices(startingAt: sectionIndex) } + func updateMetrics( + to sectionMetrics: MagazineLayoutSectionMetrics, + forSectionAtIndex sectionIndex: Int, + sectionModel: inout SectionModel) + { + let didUpdate = sectionModel.updateMetrics(to: sectionMetrics) + if didUpdate { + invalidateSectionMaxYsCacheForSectionIndices(startingAt: sectionIndex) + } + } + func updateMetrics( to sectionMetrics: MagazineLayoutSectionMetrics, forSectionAtIndex sectionIndex: Int) { sectionModels[sectionIndex].updateMetrics(to: sectionMetrics) + + invalidateSectionMaxYsCacheForSectionIndices(startingAt: sectionIndex) + } + + func updateItemSizeModes( + forSectionAtIndex sectionIndex: Int, + sectionModel: inout SectionModel, + sizeModeProvider: (_ itemIndex: Int) -> MagazineLayoutItemSizeMode) + { + sectionModel.updateItemSizeModes(sizeModeProvider) + + // TODO: Only invalidate if something changes invalidateSectionMaxYsCacheForSectionIndices(startingAt: sectionIndex) } @@ -371,6 +390,19 @@ final class ModelState { invalidateSectionMaxYsCacheForSectionIndices(startingAt: indexPath.section) } + func setHeader( + _ headerModel: HeaderModel, + forSectionAtIndex sectionIndex: Int, + sectionModel: inout SectionModel) + { + sectionModel.setHeader(headerModel) + + // TODO: Only invalidate if something changes + invalidateSectionMaxYsCacheForSectionIndices(startingAt: sectionIndex) + + prepareElementLocationsForFlattenedIndices() + } + func setHeader(_ headerModel: HeaderModel, forSectionAtIndex sectionIndex: Int) { sectionModels[sectionIndex].setHeader(headerModel) @@ -379,6 +411,13 @@ final class ModelState { prepareElementLocationsForFlattenedIndices() } + func removeHeader(forSectionAtIndex sectionIndex: Int, sectionModel: inout SectionModel) { + if sectionModel.removeHeader() { + invalidateSectionMaxYsCacheForSectionIndices(startingAt: sectionIndex) + prepareElementLocationsForFlattenedIndices() + } + } + func removeHeader(forSectionAtIndex sectionIndex: Int) { if sectionModels[sectionIndex].removeHeader() { invalidateSectionMaxYsCacheForSectionIndices(startingAt: sectionIndex) @@ -386,6 +425,19 @@ final class ModelState { } } + func setFooter( + _ footerModel: FooterModel, + forSectionAtIndex sectionIndex: Int, + sectionModel: inout SectionModel) + { + sectionModel.setFooter(footerModel) + + // TODO: Only invalidate if something changes + invalidateSectionMaxYsCacheForSectionIndices(startingAt: sectionIndex) + + prepareElementLocationsForFlattenedIndices() + } + func setFooter(_ footerModel: FooterModel, forSectionAtIndex sectionIndex: Int) { sectionModels[sectionIndex].setFooter(footerModel) @@ -394,6 +446,13 @@ final class ModelState { prepareElementLocationsForFlattenedIndices() } + func removeFooter(forSectionAtIndex sectionIndex: Int, sectionModel: inout SectionModel) { + if sectionModel.removeFooter() { + invalidateSectionMaxYsCacheForSectionIndices(startingAt: sectionIndex) + prepareElementLocationsForFlattenedIndices() + } + } + func removeFooter(forSectionAtIndex sectionIndex: Int) { if sectionModels[sectionIndex].removeFooter() { invalidateSectionMaxYsCacheForSectionIndices(startingAt: sectionIndex) @@ -401,12 +460,29 @@ final class ModelState { } } + func setBackground( + _ backgroundModel: BackgroundModel, + forSectionAtIndex sectionIndex: Int, + sectionModel: inout SectionModel) + { + sectionModel.setBackground(backgroundModel) + + // TODO: Only invalidate if something changes + prepareElementLocationsForFlattenedIndices() + } + func setBackground(_ backgroundModel: BackgroundModel, forSectionAtIndex sectionIndex: Int) { sectionModels[sectionIndex].setBackground(backgroundModel) prepareElementLocationsForFlattenedIndices() } + func removeBackground(forSectionAtIndex sectionIndex: Int, sectionModel: inout SectionModel) { + if sectionModel.removeBackground() { + prepareElementLocationsForFlattenedIndices() + } + } + func removeBackground(forSectionAtIndex sectionIndex: Int) { if sectionModels[sectionIndex].removeBackground() { prepareElementLocationsForFlattenedIndices() @@ -497,6 +573,16 @@ final class ModelState { itemIndexPathsToDelete.removeAll() } + func forEachSectionModel( + _ mutator: (_ sectionIndex: Int, _ sectionModel: inout SectionModel) -> Void) + { + sectionModels.withUnsafeMutableBufferPointer { sectionModels in + for sectionIndex in 0.. CGRect @@ -510,13 +596,6 @@ final class ModelState { private var backgroundLocationsForFlattenedIndices = [Int: ElementLocation]() private var itemLocationsForFlattenedIndices = [Int: ElementLocation]() - private func mutateSectionModels( - withUnsafeMutableBufferPointer body: (inout UnsafeMutableBufferPointer) -> Void) - { - // Accessing these arrays using unsafe, untyped (raw) pointers - // avoids expensive copy-on-writes and Swift retain / release calls. - sectionModels.withUnsafeMutableBufferPointer(body) - } private func prepareElementLocationsForFlattenedIndices() { headerLocationsForFlattenedIndices.removeAll() @@ -696,29 +775,55 @@ final class ModelState { return } - for sectionIndex in sectionIndex.. $1 }) { - sectionModels[indexPathOfItemModelToDelete.section].deleteItemModel( - atIndex: indexPathOfItemModelToDelete.item) + if MagazineLayout._enableExperimentalOptimizations { + sectionModels.withUnsafeMutableBufferPointer { sectionModels in + for indexPathOfItemModelToDelete in (indexPathsOfItemModelsToDelete.sorted { $0 > $1 }) { + sectionModels[indexPathOfItemModelToDelete.section].deleteItemModel( + atIndex: indexPathOfItemModelToDelete.item) + } + } + } else { + for indexPathOfItemModelToDelete in (indexPathsOfItemModelsToDelete.sorted { $0 > $1 }) { + sectionModels[indexPathOfItemModelToDelete.section].deleteItemModel( + atIndex: indexPathOfItemModelToDelete.item) + } } } @@ -750,18 +864,37 @@ final class ModelState { itemModelInsertIndexPathPairs: [(itemModel: ItemModel, insertIndexPath: IndexPath)]) { // Always insert in ascending order - for (itemModel, insertIndexPath) in (itemModelInsertIndexPathPairs.sorted { $0.insertIndexPath < $1.insertIndexPath }) { - let sectionIndex = insertIndexPath.section - let itemIndex = insertIndexPath.item - let section = sectionModels[sectionIndex] - if itemIndex < section.numberOfItems, itemModel.id == section.idForItemModel(atIndex: itemIndex) { - // If the `itemModel` to insert already exists at the destination index, then there's no need to insert it again. This - // happens if item move updates are generated in addition to section move updates, which appears to be the case when using - // `UICollectionViewDiffableDataSource`. Other diffing approaches, like Paul Heckel's, do not produce item moves when - // their containing sections move. - continue - } else { - sectionModels[insertIndexPath.section].insert(itemModel, atIndex: itemIndex) + if MagazineLayout._enableExperimentalOptimizations { + sectionModels.withUnsafeMutableBufferPointer { sectionModels in + for (itemModel, insertIndexPath) in (itemModelInsertIndexPathPairs.sorted { $0.insertIndexPath < $1.insertIndexPath }) { + let sectionIndex = insertIndexPath.section + let itemIndex = insertIndexPath.item + let section = sectionModels[sectionIndex] + if itemIndex < section.numberOfItems, itemModel.id == section.idForItemModel(atIndex: itemIndex) { + // If the `itemModel` to insert already exists at the destination index, then there's no need to insert it again. This + // happens if item move updates are generated in addition to section move updates, which appears to be the case when using + // `UICollectionViewDiffableDataSource`. Other diffing approaches, like Paul Heckel's, do not produce item moves when + // their containing sections move. + continue + } else { + sectionModels[insertIndexPath.section].insert(itemModel, atIndex: itemIndex) + } + } + } + } else { + for (itemModel, insertIndexPath) in (itemModelInsertIndexPathPairs.sorted { $0.insertIndexPath < $1.insertIndexPath }) { + let sectionIndex = insertIndexPath.section + let itemIndex = insertIndexPath.item + let section = sectionModels[sectionIndex] + if itemIndex < section.numberOfItems, itemModel.id == section.idForItemModel(atIndex: itemIndex) { + // If the `itemModel` to insert already exists at the destination index, then there's no need to insert it again. This + // happens if item move updates are generated in addition to section move updates, which appears to be the case when using + // `UICollectionViewDiffableDataSource`. Other diffing approaches, like Paul Heckel's, do not produce item moves when + // their containing sections move. + continue + } else { + sectionModels[insertIndexPath.section].insert(itemModel, atIndex: itemIndex) + } } } } diff --git a/MagazineLayout/LayoutCore/SectionModel.swift b/MagazineLayout/LayoutCore/SectionModel.swift index 8b780ed..f9ac3ee 100755 --- a/MagazineLayout/LayoutCore/SectionModel.swift +++ b/MagazineLayout/LayoutCore/SectionModel.swift @@ -185,6 +185,14 @@ struct SectionModel { } } + @discardableResult + mutating func updateItemModel(atIndex indexOfUpdate: Int, to itemModel: ItemModel) -> ItemModel { + updateIndexOfFirstInvalidatedRow(forChangeToItemAtIndex: indexOfUpdate) + let oldItemModel = itemModels[indexOfUpdate] + itemModels[indexOfUpdate] = itemModel + return oldItemModel + } + @discardableResult mutating func deleteItemModel(atIndex indexOfDeletion: Int) -> ItemModel { updateIndexOfFirstInvalidatedRow(forChangeToItemAtIndex: indexOfDeletion) @@ -198,12 +206,45 @@ struct SectionModel { itemModels.insert(itemModel, at: indexOfInsertion) } - mutating func updateMetrics(to metrics: MagazineLayoutSectionMetrics) { - guard self.metrics != metrics else { return } + @discardableResult + mutating func updateMetrics(to metrics: MagazineLayoutSectionMetrics) -> Bool { + guard self.metrics != metrics else { return false } self.metrics = metrics updateIndexOfFirstInvalidatedRowIfNecessary(toProposedIndex: 0) + + return true + } + + mutating func updateItemSizeModes( + _ sizeModeProvider: (_ itemIndex: Int) -> MagazineLayoutItemSizeMode) + { + guard numberOfItems > 0 else { return } + + var indexOfFirstInvalidatedItem: Int? + itemModels.withUnsafeMutableBufferPointer { itemModels in + for index in 0..