From a52e6e1c56ca5847039d9722b0788812849b6670 Mon Sep 17 00:00:00 2001 From: Bryan Keller Date: Mon, 8 Jun 2026 19:19:53 -0700 Subject: [PATCH] Optimize data structures and defer layout work --- MagazineLayout.xcodeproj/project.pbxproj | 4 + MagazineLayout/LayoutCore/ModelState.swift | 213 ++++++++++++---- MagazineLayout/LayoutCore/SectionModel.swift | 234 ++++++++++++++---- .../Types/Array+SafeSubscript.swift | 27 ++ MagazineLayout/Public/MagazineLayout.swift | 4 +- 5 files changed, 392 insertions(+), 90 deletions(-) create mode 100644 MagazineLayout/LayoutCore/Types/Array+SafeSubscript.swift diff --git a/MagazineLayout.xcodeproj/project.pbxproj b/MagazineLayout.xcodeproj/project.pbxproj index e5ce755..bb26cf8 100644 --- a/MagazineLayout.xcodeproj/project.pbxproj +++ b/MagazineLayout.xcodeproj/project.pbxproj @@ -39,6 +39,7 @@ 93A1C04E21ACED1100DED67D /* ElementLocationFramePairsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93A1C00F21ACED0100DED67D /* ElementLocationFramePairsTests.swift */; }; 93A1C04F21ACED1100DED67D /* ModelStateUpdateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93A1C01021ACED0100DED67D /* ModelStateUpdateTests.swift */; }; 93A868552EE0D7870027691E /* IDGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93A868542EE0D7870027691E /* IDGenerator.swift */; }; + 93F8D8B22FD38BBF005325C0 /* Array+SafeSubscript.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93F8D8B12FD38BBF005325C0 /* Array+SafeSubscript.swift */; }; 93F8DAE62FD76026005325C0 /* Signposting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93F8DAE52FD76026005325C0 /* Signposting.swift */; }; FCAC642622085AF100973F4C /* MagazineLayoutHeaderVisibilityMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCAC642522085AF100973F4C /* MagazineLayoutHeaderVisibilityMode.swift */; }; FCAC642822085B0E00973F4C /* FooterModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCAC642722085B0E00973F4C /* FooterModel.swift */; }; @@ -97,6 +98,7 @@ 93A1C05321ACEDFC00DED67D /* LICENSE */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = LICENSE; sourceTree = SOURCE_ROOT; }; 93A1C05421ACEDFC00DED67D /* README.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = SOURCE_ROOT; }; 93A868542EE0D7870027691E /* IDGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IDGenerator.swift; sourceTree = ""; }; + 93F8D8B12FD38BBF005325C0 /* Array+SafeSubscript.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+SafeSubscript.swift"; sourceTree = ""; }; 93F8DAE52FD76026005325C0 /* Signposting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Signposting.swift; sourceTree = ""; }; FCAC642522085AF100973F4C /* MagazineLayoutHeaderVisibilityMode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MagazineLayoutHeaderVisibilityMode.swift; sourceTree = ""; }; FCAC642722085B0E00973F4C /* FooterModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FooterModel.swift; sourceTree = ""; }; @@ -234,6 +236,7 @@ FDABC2572B23D0A000C9B8EF /* TargetContentOffsetAnchor.swift */, FD244FEE28B41F9900046C0D /* UITraitCollection+DisplayScale.swift */, 93A868542EE0D7870027691E /* IDGenerator.swift */, + 93F8D8B12FD38BBF005325C0 /* Array+SafeSubscript.swift */, ); path = Types; sourceTree = ""; @@ -362,6 +365,7 @@ FCAC642822085B0E00973F4C /* FooterModel.swift in Sources */, 93A1C03C21ACED0100DED67D /* UICollectionViewDelegateMagazineLayout.swift in Sources */, 93A1C03721ACED0100DED67D /* MagazineLayoutItemSizeMode.swift in Sources */, + 93F8D8B22FD38BBF005325C0 /* Array+SafeSubscript.swift in Sources */, FD4DFF0A21B0D182001F46CE /* MagazineLayoutCollectionReusableView.swift in Sources */, 93A1C04321ACED0100DED67D /* MagazineLayoutItemWidthMode+WidthDivisor.swift in Sources */, 93A1C03D21ACED0100DED67D /* MagazineLayout+Default.swift in Sources */, diff --git a/MagazineLayout/LayoutCore/ModelState.swift b/MagazineLayout/LayoutCore/ModelState.swift index cc44a27..4971488 100755 --- a/MagazineLayout/LayoutCore/ModelState.swift +++ b/MagazineLayout/LayoutCore/ModelState.swift @@ -134,18 +134,24 @@ final class ModelState { } func itemLocationFramePairs(forItemsIn rect: CGRect) -> ElementLocationFramePairs { + prepareElementLocationsForFlattenedIndicesIfNeeded() + return elementLocationFramePairsForElements( in: rect, withElementLocationsForFlattenedIndices: itemLocationsForFlattenedIndices, + newElementLocationsForFlattenedIndices: newItemLocationsForFlattenedIndices, andFramesProvidedBy: { itemLocation -> CGRect in return frameForItem(at: itemLocation) }) } func headerLocationFramePairs(forHeadersIn rect: CGRect) -> ElementLocationFramePairs { + prepareElementLocationsForFlattenedIndicesIfNeeded() + return elementLocationFramePairsForElements( in: rect, withElementLocationsForFlattenedIndices: headerLocationsForFlattenedIndices, + newElementLocationsForFlattenedIndices: newHeaderLocationsForFlattenedIndices, andFramesProvidedBy: { headerLocation -> CGRect in guard let headerFrame = frameForHeader( @@ -160,9 +166,12 @@ final class ModelState { } func footerLocationFramePairs(forFootersIn rect: CGRect) -> ElementLocationFramePairs { + prepareElementLocationsForFlattenedIndicesIfNeeded() + return elementLocationFramePairsForElements( in: rect, withElementLocationsForFlattenedIndices: footerLocationsForFlattenedIndices, + newElementLocationsForFlattenedIndices: newFooterLocationsForFlattenedIndices, andFramesProvidedBy: { footerLocation -> CGRect in guard let footerFrame = frameForFooter( @@ -177,9 +186,12 @@ final class ModelState { } func backgroundLocationFramePairs(forBackgroundsIn rect: CGRect) -> ElementLocationFramePairs { + prepareElementLocationsForFlattenedIndicesIfNeeded() + return elementLocationFramePairsForElements( in: rect, withElementLocationsForFlattenedIndices: backgroundLocationsForFlattenedIndices, + newElementLocationsForFlattenedIndices: newBackgroundLocationsForFlattenedIndices, andFramesProvidedBy: { backgroundLocation -> CGRect in guard let backgroundFrame = frameForBackground( @@ -194,6 +206,8 @@ final class ModelState { } func sectionMaxY(forSectionAtIndex targetSectionIndex: Int) -> CGFloat { + flushSectionMaxYsCacheInvalidationIfNeeded() + var sectionMaxY: CGFloat { guard targetSectionIndex >= 0 && targetSectionIndex < numberOfSections else { assertionFailure("`targetSectionIndex` is not within the bounds of the section models array") @@ -201,8 +215,16 @@ final class ModelState { } var totalHeight: CGFloat = 0 - for sectionIndex in 0...targetSectionIndex { - totalHeight += sectionModels[sectionIndex].calculateHeight() + if MagazineLayout._enableExperimentalOptimizations { + sectionModels.withUnsafeMutableBufferPointer { sectionModels in + for sectionIndex in 0...targetSectionIndex { + totalHeight += sectionModels[sectionIndex].calculateHeight() + } + } + } else { + for sectionIndex in 0...targetSectionIndex { + totalHeight += sectionModels[sectionIndex].calculateHeight() + } } return totalHeight @@ -299,10 +321,19 @@ final class ModelState { let currentVisibleBounds = currentVisibleBoundsProvider() let newModelState = ModelState(currentVisibleBoundsProvider: { currentVisibleBounds }) newModelState.sectionModels = sectionModels + + prepareElementLocationsForFlattenedIndicesIfNeeded() + newModelState.headerLocationsForFlattenedIndices = headerLocationsForFlattenedIndices newModelState.footerLocationsForFlattenedIndices = footerLocationsForFlattenedIndices newModelState.backgroundLocationsForFlattenedIndices = backgroundLocationsForFlattenedIndices newModelState.itemLocationsForFlattenedIndices = itemLocationsForFlattenedIndices + + newModelState.newHeaderLocationsForFlattenedIndices = newHeaderLocationsForFlattenedIndices + newModelState.newFooterLocationsForFlattenedIndices = newFooterLocationsForFlattenedIndices + newModelState.newBackgroundLocationsForFlattenedIndices = newBackgroundLocationsForFlattenedIndices + newModelState.newItemLocationsForFlattenedIndices = newItemLocationsForFlattenedIndices + return newModelState } @@ -596,44 +627,97 @@ final class ModelState { private var backgroundLocationsForFlattenedIndices = [Int: ElementLocation]() private var itemLocationsForFlattenedIndices = [Int: ElementLocation]() + private var newHeaderLocationsForFlattenedIndices = [ElementLocation]() + private var newFooterLocationsForFlattenedIndices = [ElementLocation]() + private var newBackgroundLocationsForFlattenedIndices = [ElementLocation]() + private var newItemLocationsForFlattenedIndices = [ElementLocation]() + + // When experimental optimizations are enabled, these defer the corresponding bookkeeping work + // until the next read that depends on it, coalescing repeated invalidations during a single + // round of section model mutations. + private var needsPrepareElementLocationsForFlattenedIndices = false + private var firstInvalidatedSectionMaxYIndex: Int? private func prepareElementLocationsForFlattenedIndices() { - headerLocationsForFlattenedIndices.removeAll() - footerLocationsForFlattenedIndices.removeAll() - backgroundLocationsForFlattenedIndices.removeAll() - itemLocationsForFlattenedIndices.removeAll() - - var flattenedHeaderIndex = 0 - var flattenedFooterIndex = 0 - var flattenedBackgroundIndex = 0 - var flattenedItemIndex = 0 - for sectionIndex in 0.. CGRect)) -> ElementLocationFramePairs { @@ -650,6 +735,7 @@ final class ModelState { let indexOfFirstFoundElement = indexOfFirstFoundElement( in: rect, withElementLocationsForFlattenedIndices: elementLocationsForFlattenedIndices, + newElementLocationsForFlattenedIndices: newElementLocationsForFlattenedIndices, andFramesProvidedBy: frameProvider) else { return elementLocationFramePairs @@ -663,7 +749,8 @@ final class ModelState { for elementLocationIndex in (0.. rect.minY else { @@ -684,10 +771,17 @@ final class ModelState { } // Look forward to find visible elements - for elementLocationIndex in indexOfFirstFoundElement.. CGRect)) -> Int? { + let numberOfElementLocations = + if MagazineLayout._enableExperimentalOptimizations { + newElementLocationsForFlattenedIndices.count + } else { + elementLocationsForFlattenedIndices.count + } + var lowerBound = 0 - var upperBound = elementLocationsForFlattenedIndices.count - 1 + var upperBound = numberOfElementLocations - 1 while lowerBound <= upperBound { let index = (lowerBound + upperBound) / 2 let elementLocation = self.elementLocation( forFlattenedIndex: index, - in: elementLocationsForFlattenedIndices) + in: elementLocationsForFlattenedIndices, + newElementLocationsForFlattenedIndices: newElementLocationsForFlattenedIndices) let elementFrame = frameProvider(elementLocation) if elementFrame.maxY <= rect.minY { lowerBound = index + 1 @@ -727,14 +830,19 @@ final class ModelState { private func elementLocation( forFlattenedIndex index: Int, - in elementLocationsForFlattenedIndices: [Int: ElementLocation]) + in elementLocationsForFlattenedIndices: [Int: ElementLocation], + newElementLocationsForFlattenedIndices: [ElementLocation]) -> ElementLocation { - guard let elementLocation = elementLocationsForFlattenedIndices[index] else { - preconditionFailure("`elementLocationsForFlattenedIndices` must have a complete mapping of indices in 0..<\(elementLocationsForFlattenedIndices.count) to element locations") - } + if MagazineLayout._enableExperimentalOptimizations { + return newElementLocationsForFlattenedIndices[index] + } else { + guard let elementLocation = elementLocationsForFlattenedIndices[index] else { + preconditionFailure("`elementLocationsForFlattenedIndices` must have a complete mapping of indices in 0..<\(elementLocationsForFlattenedIndices.count) to element locations") + } - return elementLocation + return elementLocation + } } private func allocateMemoryForSectionMaxYsCache() { @@ -770,6 +878,23 @@ final class ModelState { } func invalidateSectionMaxYsCacheForSectionIndices(startingAt sectionIndex: Int) { + if MagazineLayout._enableExperimentalOptimizations { + firstInvalidatedSectionMaxYIndex = min( + firstInvalidatedSectionMaxYIndex ?? sectionIndex, + sectionIndex) + } else { + clearSectionMaxYsCache(startingAt: sectionIndex) + } + } + + private func flushSectionMaxYsCacheInvalidationIfNeeded() { + guard let firstInvalidatedSectionMaxYIndex else { return } + clearSectionMaxYsCache(startingAt: firstInvalidatedSectionMaxYIndex) + self.firstInvalidatedSectionMaxYIndex = nil + } + + /// Clears cached section max-Y values from `sectionIndex` through the end of the cache. + private func clearSectionMaxYsCache(startingAt sectionIndex: Int) { guard sectionIndex >= 0, sectionIndex < sectionMaxYsCache.count else { assertionFailure("Cannot invalidate `sectionMaxYsCache` starting at an invalid (negative or out-of-bounds) `sectionIndex` (\(sectionIndex)).") return diff --git a/MagazineLayout/LayoutCore/SectionModel.swift b/MagazineLayout/LayoutCore/SectionModel.swift index f9ac3ee..e3a9e70 100755 --- a/MagazineLayout/LayoutCore/SectionModel.swift +++ b/MagazineLayout/LayoutCore/SectionModel.swift @@ -83,8 +83,15 @@ struct SectionModel { mutating func calculateFrameForItem(atIndex index: Int) -> CGRect { calculateElementFramesIfNecessary() + let rowIndex: Int? = + if MagazineLayout._enableExperimentalOptimizations { + newRowIndicesForItemIndices[safe: index] ?? nil + } else { + rowIndicesForItemIndices[index] + } + var origin = itemModels[index].originInSection - if let rowIndex = rowIndicesForItemIndices[index] { + if let rowIndex { origin.y += rowOffsetTracker?.offsetForRow(at: rowIndex) ?? 0 } else { assertionFailure("Expected a row and a row height for item at \(index).") @@ -323,10 +330,25 @@ struct SectionModel { directlyMutableItemModels[index].preferredHeight = preferredHeight } - if - let rowIndex = rowIndicesForItemIndices[index], - let rowHeight = itemRowHeightsForRowIndices[rowIndex] - { + let rowIndex: Int? = + if MagazineLayout._enableExperimentalOptimizations { + newRowIndicesForItemIndices[safe: index] ?? nil + } else { + rowIndicesForItemIndices[index] + } + + let rowHeight: CGFloat? = + if let rowIndex { + if MagazineLayout._enableExperimentalOptimizations { + newItemRowHeightsForRowIndices[safe: rowIndex] + } else { + itemRowHeightsForRowIndices[rowIndex] + } + } else { + nil + } + + if let rowIndex, let rowHeight { let newRowHeight = updateHeightsForItemsInRow(at: rowIndex) let heightDelta = newRowHeight - rowHeight @@ -409,8 +431,8 @@ struct SectionModel { private var indexOfFirstInvalidatedRow: Int? { didSet { - guard indexOfFirstInvalidatedRow != nil else { return } - applyRowOffsetsIfNecessary() + guard let indexOfFirstInvalidatedRow else { return } + applyRowOffsets(upToInvalidatedRow: indexOfFirstInvalidatedRow) } } @@ -418,18 +440,38 @@ struct SectionModel { private var rowIndicesForItemIndices = [Int: Int]() private var itemRowHeightsForRowIndices = [Int: CGFloat]() + private var newItemIndicesForRowIndices = [ClosedRange?]() + private var newRowIndicesForItemIndices = [Int?]() + private var newItemRowHeightsForRowIndices = [CGFloat]() + private var rowOffsetTracker: RowOffsetTracker? private func maxYForItemsRow(atIndex rowIndex: Int) -> CGFloat? { - guard - let itemIndices = itemIndicesForRowIndices[rowIndex], - let itemY = itemIndices.first.flatMap({ itemModels[$0].originInSection.y }), - let itemHeight = itemIndices.map({ itemModels[$0].size.height }).max() else - { - return nil - } + if MagazineLayout._enableExperimentalOptimizations { + guard + let itemIndices = newItemIndicesForRowIndices[safe: rowIndex] ?? nil, + let itemY = itemModels[safe: itemIndices.lowerBound]?.originInSection.y + else { + return nil + } + + var maxItemHeight: CGFloat = 0 + for itemIndex in itemIndices { + maxItemHeight = max(maxItemHeight, itemModels[safe: itemIndex]?.size.height ?? maxItemHeight) + } + + return itemY + maxItemHeight + } else { + guard + let itemIndices = itemIndicesForRowIndices[rowIndex], + let itemY = itemIndices.first.flatMap({ itemModels[$0].originInSection.y }), + let maxItemHeight = itemIndices.map({ itemModels[$0].size.height }).max() + else { + return nil + } - return itemY + itemHeight + return itemY + maxItemHeight + } } private func indexOfHeaderRow() -> Int? { @@ -444,7 +486,11 @@ struct SectionModel { private func indexOfLastItemsRow() -> Int? { guard numberOfItems > 0 else { return nil } - return rowIndicesForItemIndices[numberOfItems - 1] + if MagazineLayout._enableExperimentalOptimizations { + return newRowIndicesForItemIndices[numberOfItems - 1] + } else { + return rowIndicesForItemIndices[numberOfItems - 1] + } } private func indexOfFooterRow() -> Int? { @@ -456,15 +502,27 @@ struct SectionModel { } private mutating func updateIndexOfFirstInvalidatedRow(forChangeToItemAtIndex changedIndex: Int) { - guard - let indexOfCurrentRow = rowIndicesForItemIndices[changedIndex], - indexOfCurrentRow > 0 else - { - indexOfFirstInvalidatedRow = rowIndicesForItemIndices[0] ?? 0 - return + if MagazineLayout._enableExperimentalOptimizations { + guard + let indexOfCurrentRow = newRowIndicesForItemIndices[safe: changedIndex] ?? nil, + indexOfCurrentRow > 0 else + { + indexOfFirstInvalidatedRow = newRowIndicesForItemIndices[safe: 0] ?? 0 + return + } + + updateIndexOfFirstInvalidatedRowIfNecessary(toProposedIndex: indexOfCurrentRow - 1) + } else { + guard + let indexOfCurrentRow = rowIndicesForItemIndices[changedIndex], + indexOfCurrentRow > 0 else + { + indexOfFirstInvalidatedRow = rowIndicesForItemIndices[0] ?? 0 + return + } + + updateIndexOfFirstInvalidatedRowIfNecessary(toProposedIndex: indexOfCurrentRow - 1) } - - updateIndexOfFirstInvalidatedRowIfNecessary(toProposedIndex: indexOfCurrentRow - 1) } private mutating func updateIndexOfFirstInvalidatedRowIfNecessary( @@ -473,17 +531,39 @@ struct SectionModel { indexOfFirstInvalidatedRow = min(proposedIndex, indexOfFirstInvalidatedRow ?? proposedIndex) } - private mutating func applyRowOffsetsIfNecessary() { + /// Bakes the row offset tracker's accumulated offsets into the stored element origins, then clears + /// the tracker. + private mutating func applyRowOffsets(upToInvalidatedRow invalidatedRow: Int) { guard let rowOffsetTracker = rowOffsetTracker else { return } - for rowIndex in 0.. 0 else { + // Every row is about to be recomputed, so the tracker's offsets are irrelevant. Drop it. + self.rowOffsetTracker = nil + return + } + } else { + upperBound = numberOfRows + } + + for rowIndex in 0..= rowIndex else { continue } + if MagazineLayout._enableExperimentalOptimizations { + var lowestItemIndex: Int? + var lowestRowIndexKey: Int? + var lowestRowIndex: Int? + for rowIndexKey in newItemIndicesForRowIndices.indices { + guard rowIndexKey >= rowIndex else { continue } + + if let itemIndices = newItemIndicesForRowIndices[safe: rowIndexKey] ?? nil { + lowestItemIndex = min(lowestItemIndex ?? itemIndices.lowerBound, itemIndices.lowerBound) + } - if let itemIndex = itemIndicesForRowIndices[rowIndexKey]?.first { - rowIndicesForItemIndices[itemIndex] = nil + lowestRowIndexKey = min(lowestRowIndexKey ?? rowIndexKey, rowIndexKey) + lowestRowIndex = min(lowestRowIndex ?? rowIndex, rowIndex) } - itemIndicesForRowIndices[rowIndexKey] = nil - itemRowHeightsForRowIndices[rowIndex] = nil + if let lowestItemIndex { + newRowIndicesForItemIndices.removeSubrange(lowestItemIndex...) + } + if let lowestRowIndexKey { + newItemIndicesForRowIndices.removeSubrange(lowestRowIndexKey...) + } + if let lowestRowIndex { + newItemRowHeightsForRowIndices.removeSubrange(lowestRowIndex...) + } + } else { + for rowIndexKey in itemIndicesForRowIndices.keys { + guard rowIndexKey >= rowIndex else { continue } + + if let itemIndex = itemIndicesForRowIndices[rowIndexKey]?.first { + rowIndicesForItemIndices[itemIndex] = nil + } + + itemIndicesForRowIndices[rowIndexKey] = nil + itemRowHeightsForRowIndices[rowIndex] = nil + } } // Header frame calculation @@ -528,11 +634,19 @@ struct SectionModel { // Item frame calculations + let previousRowIndex = rowIndex - 1 + let indexOfLastItemInPreviousRow: Int? = + if MagazineLayout._enableExperimentalOptimizations { + newItemIndicesForRowIndices[safe: previousRowIndex]??.upperBound + } else { + itemIndicesForRowIndices[previousRowIndex]?.last + } + let startingItemIndex: Int if - let indexOfLastItemInPreviousRow = itemIndicesForRowIndices[rowIndex - 1]?.last, + let indexOfLastItemInPreviousRow, indexOfLastItemInPreviousRow + 1 < numberOfItems, - let maxYForPreviousRow = maxYForItemsRow(atIndex: rowIndex - 1) + let maxYForPreviousRow = maxYForItemsRow(atIndex: previousRowIndex) { // There's a previous row of items, so we'll use the max Y of that row as the starting place // for the current row of items. @@ -561,10 +675,21 @@ struct SectionModel { var indexInCurrentRow = 0 for itemIndex in startingItemIndex.. CGFloat { - guard let indicesForItemsInRow = itemIndicesForRowIndices[rowIndex] else { - assertionFailure("Expected item indices for row \(rowIndex).") - return 0 + if MagazineLayout._enableExperimentalOptimizations { + guard let indicesForItemsInRow = newItemIndicesForRowIndices[safe: rowIndex] ?? nil else { + assertionFailure("Expected item indices for row \(rowIndex).") + return 0 + } + return updateHeightsForItems(in: indicesForItemsInRow, at: rowIndex) + } else { + guard let indicesForItemsInRow = itemIndicesForRowIndices[rowIndex] else { + assertionFailure("Expected item indices for row \(rowIndex).") + return 0 + } + return updateHeightsForItems(in: indicesForItemsInRow, at: rowIndex) } + } + private mutating func updateHeightsForItems>( + in indicesForItemsInRow: Indices, + at rowIndex: Int) + -> CGFloat + { var heightOfTallestItem = CGFloat(0) var stretchToTallestItemInRowItemIndices = Set() @@ -684,7 +824,13 @@ struct SectionModel { } } - itemRowHeightsForRowIndices[rowIndex] = heightOfTallestItem + if MagazineLayout._enableExperimentalOptimizations { + newItemRowHeightsForRowIndices.grow(toInclude: rowIndex, fillingWith: 0) + newItemRowHeightsForRowIndices[rowIndex] = heightOfTallestItem + } else { + itemRowHeightsForRowIndices[rowIndex] = heightOfTallestItem + } + return heightOfTallestItem } diff --git a/MagazineLayout/LayoutCore/Types/Array+SafeSubscript.swift b/MagazineLayout/LayoutCore/Types/Array+SafeSubscript.swift new file mode 100644 index 0000000..2d84bb0 --- /dev/null +++ b/MagazineLayout/LayoutCore/Types/Array+SafeSubscript.swift @@ -0,0 +1,27 @@ +// Created by Bryan Keller on 6/5/26. +// Copyright © 2026 Airbnb Inc. All rights reserved. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +extension Array { + + subscript(safe index: Int) -> Element? { + index >= 0 && index < count ? self[index] : nil + } + + /// Grows the array (if needed) so `index` is in bounds, back-filling any gap between the current + /// `count` and `index` with `filler` so the array stays dense and positionally addressable. + mutating func grow(toInclude index: Int, fillingWith filler: @autoclosure () -> Element) { + while index >= count { append(filler()) } + } +} diff --git a/MagazineLayout/Public/MagazineLayout.swift b/MagazineLayout/Public/MagazineLayout.swift index 1435866..a9e7d64 100755 --- a/MagazineLayout/Public/MagazineLayout.swift +++ b/MagazineLayout/Public/MagazineLayout.swift @@ -917,7 +917,7 @@ public final class MagazineLayout: UICollectionViewLayout { !context.invalidateDataSourceCounts if context.invalidateEverything { - prepareActions.formUnion([.recreateSectionModels]) + prepareActions.formUnion(.recreateSectionModels) } // Checking `cachedCollectionViewWidth != collectionView?.bounds.size.width` is necessary @@ -936,7 +936,7 @@ public final class MagazineLayout: UICollectionViewLayout { } if context.invalidateLayoutMetrics && shouldInvalidateLayoutMetrics { - prepareActions.formUnion([.updateLayoutMetrics]) + prepareActions.formUnion(.updateLayoutMetrics) } hasDataSourceCountInvalidationBeforeReceivingUpdateItems = context.invalidateDataSourceCounts &&