From b2d18a5024e2a049fcb654ecad3e22eeb6128eb2 Mon Sep 17 00:00:00 2001 From: Bryan Keller Date: Wed, 3 Dec 2025 13:27:01 -0800 Subject: [PATCH] Improve performance of layout model IDs --- MagazineLayout.xcodeproj/project.pbxproj | 4 ++ .../LayoutCore/BackgroundModel.swift | 4 -- MagazineLayout/LayoutCore/ItemModel.swift | 7 ++- MagazineLayout/LayoutCore/ModelState.swift | 8 +-- MagazineLayout/LayoutCore/SectionModel.swift | 10 ++-- .../LayoutCore/Types/IDGenerator.swift | 29 +++++++++++ .../Types/TargetContentOffsetAnchor.swift | 4 +- MagazineLayout/Public/MagazineLayout.swift | 3 ++ .../LayoutStateTargetContentOffsetTests.swift | 49 ++++++++++++------- Tests/ModelStateEmptySectionLayoutTests.swift | 6 +++ Tests/ModelStateLayoutTests.swift | 11 ++++- Tests/TestingSupport.swift | 8 +++ 12 files changed, 103 insertions(+), 40 deletions(-) create mode 100644 MagazineLayout/LayoutCore/Types/IDGenerator.swift diff --git a/MagazineLayout.xcodeproj/project.pbxproj b/MagazineLayout.xcodeproj/project.pbxproj index 9c606a0..3730e5e 100644 --- a/MagazineLayout.xcodeproj/project.pbxproj +++ b/MagazineLayout.xcodeproj/project.pbxproj @@ -38,6 +38,7 @@ 93A1C04D21ACED1100DED67D /* TestingSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93A1C00E21ACED0100DED67D /* TestingSupport.swift */; }; 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 */; }; FCAC642622085AF100973F4C /* MagazineLayoutHeaderVisibilityMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCAC642522085AF100973F4C /* MagazineLayoutHeaderVisibilityMode.swift */; }; FCAC642822085B0E00973F4C /* FooterModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCAC642722085B0E00973F4C /* FooterModel.swift */; }; FD244FEF28B41F9900046C0D /* UITraitCollection+DisplayScale.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD244FEE28B41F9900046C0D /* UITraitCollection+DisplayScale.swift */; }; @@ -94,6 +95,7 @@ 93A1C02B21ACED0100DED67D /* ModelState.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ModelState.swift; sourceTree = ""; }; 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 = ""; }; 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 = ""; }; FD23F5F021AF4A1B00AA78D4 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -228,6 +230,7 @@ 93540AAF282E25D90008BD6F /* ScreenPixelAlignment.swift */, FDABC2572B23D0A000C9B8EF /* TargetContentOffsetAnchor.swift */, FD244FEE28B41F9900046C0D /* UITraitCollection+DisplayScale.swift */, + 93A868542EE0D7870027691E /* IDGenerator.swift */, ); path = Types; sourceTree = ""; @@ -363,6 +366,7 @@ 93A1C04421ACED0100DED67D /* SectionModel.swift in Sources */, 93A1C03E21ACED0100DED67D /* ElementLocation.swift in Sources */, 93A1C04821ACED0100DED67D /* ModelState.swift in Sources */, + 93A868552EE0D7870027691E /* IDGenerator.swift in Sources */, 9398462A2296864200E442DA /* RowOffsetTracker.swift in Sources */, FD4DFF0821B0C737001F46CE /* MagazineLayoutCollectionViewCell.swift in Sources */, 93443FD22EB582AE00D60F56 /* LayoutState.swift in Sources */, diff --git a/MagazineLayout/LayoutCore/BackgroundModel.swift b/MagazineLayout/LayoutCore/BackgroundModel.swift index 04e842f..672b12f 100644 --- a/MagazineLayout/LayoutCore/BackgroundModel.swift +++ b/MagazineLayout/LayoutCore/BackgroundModel.swift @@ -14,7 +14,6 @@ // limitations under the License. import CoreGraphics -import Foundation /// Represents the layout information for a background in a section. struct BackgroundModel { @@ -22,15 +21,12 @@ struct BackgroundModel { // MARK: Lifecycle init() { - id = NSUUID().uuidString originInSection = .zero size = .zero } // MARK: Internal - let id: String - var originInSection: CGPoint var size: CGSize diff --git a/MagazineLayout/LayoutCore/ItemModel.swift b/MagazineLayout/LayoutCore/ItemModel.swift index d21fafd..bedbbdf 100644 --- a/MagazineLayout/LayoutCore/ItemModel.swift +++ b/MagazineLayout/LayoutCore/ItemModel.swift @@ -14,15 +14,14 @@ // limitations under the License. import CoreGraphics -import Foundation /// Represents the layout information for an item in a section. struct ItemModel { // MARK: Lifecycle - init(sizeMode: MagazineLayoutItemSizeMode, height: CGFloat) { - id = UUID() + init(idGenerator: IDGenerator, sizeMode: MagazineLayoutItemSizeMode, height: CGFloat) { + id = idGenerator.next() self.sizeMode = sizeMode originInSection = .zero size = CGSize(width: 0, height: height) @@ -30,7 +29,7 @@ struct ItemModel { // MARK: Internal - let id: UUID + let id: UInt64 var sizeMode: MagazineLayoutItemSizeMode var originInSection: CGPoint diff --git a/MagazineLayout/LayoutCore/ModelState.swift b/MagazineLayout/LayoutCore/ModelState.swift index a2e5acf..a3166fc 100755 --- a/MagazineLayout/LayoutCore/ModelState.swift +++ b/MagazineLayout/LayoutCore/ModelState.swift @@ -40,7 +40,7 @@ final class ModelState { sectionModels[sectionIndex].numberOfItems } - func idForItemModel(at indexPath: IndexPath) -> UUID? { + func idForItemModel(at indexPath: IndexPath) -> UInt64? { guard indexPath.section < sectionModels.count, indexPath.item < sectionModels[indexPath.section].numberOfItems else @@ -52,7 +52,7 @@ final class ModelState { return sectionModels[indexPath.section].idForItemModel(atIndex: indexPath.item) } - func indexPathForItemModel(withID id: UUID) -> IndexPath? { + func indexPathForItemModel(withID id: UInt64) -> IndexPath? { for sectionIndex in 0.. UUID? { + func idForSectionModel(atIndex index: Int) -> UInt64? { guard index < sectionModels.count else { // This occurs when getting layout attributes for initial / final animations return nil @@ -72,7 +72,7 @@ final class ModelState { return sectionModels[index].id } - func indexForSectionModel(withID id: UUID) -> Int? { + func indexForSectionModel(withID id: UInt64) -> Int? { for sectionIndex in 0.. UUID { + func idForItemModel(atIndex index: Int) -> UInt64 { return itemModels[index].id } - func indexForItemModel(withID id: UUID) -> Int? { + func indexForItemModel(withID id: UInt64) -> Int? { return itemModels.firstIndex { $0.id == id } } diff --git a/MagazineLayout/LayoutCore/Types/IDGenerator.swift b/MagazineLayout/LayoutCore/Types/IDGenerator.swift new file mode 100644 index 0000000..cceae75 --- /dev/null +++ b/MagazineLayout/LayoutCore/Types/IDGenerator.swift @@ -0,0 +1,29 @@ +// Created by Bryan Keller on 12/3/25. +// Copyright © 2025 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. + +/// Generates sequential `UInt64` IDs for section and item models. 18,446,744,073,709,551,615 ought to be enough for any layout. +final class IDGenerator { + + // MARK: Internal + + func next() -> UInt64 { + defer { id &+= 1 } + return id + } + + // MARK: Private + + private var id: UInt64 = 0 +} diff --git a/MagazineLayout/LayoutCore/Types/TargetContentOffsetAnchor.swift b/MagazineLayout/LayoutCore/Types/TargetContentOffsetAnchor.swift index d2ca62a..34b16db 100644 --- a/MagazineLayout/LayoutCore/Types/TargetContentOffsetAnchor.swift +++ b/MagazineLayout/LayoutCore/Types/TargetContentOffsetAnchor.swift @@ -21,6 +21,6 @@ import UIKit enum TargetContentOffsetAnchor: Equatable { case top case bottom - case topItem(id: UUID, distanceFromTop: CGFloat) - case bottomItem(id: UUID, distanceFromBottom: CGFloat) + case topItem(id: UInt64, distanceFromTop: CGFloat) + case bottomItem(id: UInt64, distanceFromBottom: CGFloat) } diff --git a/MagazineLayout/Public/MagazineLayout.swift b/MagazineLayout/Public/MagazineLayout.swift index 81a1f26..7143881 100755 --- a/MagazineLayout/Public/MagazineLayout.swift +++ b/MagazineLayout/Public/MagazineLayout.swift @@ -827,6 +827,7 @@ public final class MagazineLayout: UICollectionViewLayout { // MARK: Private private let _flipsHorizontallyInOppositeLayoutDirection: Bool + private let idGenerator = IDGenerator() private lazy var _layoutState = LayoutState( modelState: ModelState(currentVisibleBoundsProvider: { [weak self] in @@ -1042,6 +1043,7 @@ public final class MagazineLayout: UICollectionViewLayout { } return SectionModel( + idGenerator: idGenerator, itemModels: itemModels, headerModel: headerModelForHeader(inSectionAtIndex: sectionIndex), footerModel: footerModelForFooter(inSectionAtIndex: sectionIndex), @@ -1052,6 +1054,7 @@ public final class MagazineLayout: UICollectionViewLayout { private func itemModelForItem(at indexPath: IndexPath) -> ItemModel { let itemSizeMode = sizeModeForItem(at: indexPath) return ItemModel( + idGenerator: idGenerator, sizeMode: itemSizeMode, height: initialItemHeight(from: itemSizeMode)) } diff --git a/Tests/LayoutStateTargetContentOffsetTests.swift b/Tests/LayoutStateTargetContentOffsetTests.swift index 2441158..80e4dd3 100644 --- a/Tests/LayoutStateTargetContentOffsetTests.swift +++ b/Tests/LayoutStateTargetContentOffsetTests.swift @@ -224,25 +224,28 @@ final class LayoutStateTargetContentOffsetTests: XCTestCase { // MARK: Private + private let idGenerator = IDGenerator() + private func modelState(bounds: CGRect) -> ModelState { let modelState = ModelState(currentVisibleBoundsProvider: { bounds }) let sections = [ SectionModel( + idGenerator: idGenerator, itemModels: [ - ItemModel(widthMode: .halfWidth, preferredHeight: nil), - ItemModel(widthMode: .halfWidth, preferredHeight: 70), - ItemModel(widthMode: .halfWidth, preferredHeight: 90), - ItemModel(widthMode: .halfWidth, preferredHeight: 80), - ItemModel(widthMode: .fullWidth(respectsHorizontalInsets: true), preferredHeight: nil), - ItemModel(widthMode: .fullWidth(respectsHorizontalInsets: true), preferredHeight: 135), - ItemModel(widthMode: .fullWidth(respectsHorizontalInsets: true), preferredHeight: 135), - ItemModel(widthMode: .halfWidth, preferredHeight: 55), - ItemModel(widthMode: .halfWidth, preferredHeight: 105), - ItemModel(widthMode: .halfWidth, preferredHeight: 80), - ItemModel(widthMode: .halfWidth, preferredHeight: 95), - ItemModel(widthMode: .thirdWidth, preferredHeight: 200), - ItemModel(widthMode: .thirdWidth, preferredHeight: 200), - ItemModel(widthMode: .thirdWidth, preferredHeight: nil), + ItemModel(idGenerator: idGenerator, widthMode: .halfWidth, preferredHeight: nil), + ItemModel(idGenerator: idGenerator, widthMode: .halfWidth, preferredHeight: 70), + ItemModel(idGenerator: idGenerator, widthMode: .halfWidth, preferredHeight: 90), + ItemModel(idGenerator: idGenerator, widthMode: .halfWidth, preferredHeight: 80), + ItemModel(idGenerator: idGenerator, widthMode: .fullWidth(respectsHorizontalInsets: true), preferredHeight: nil), + ItemModel(idGenerator: idGenerator, widthMode: .fullWidth(respectsHorizontalInsets: true), preferredHeight: 135), + ItemModel(idGenerator: idGenerator, widthMode: .fullWidth(respectsHorizontalInsets: true), preferredHeight: 135), + ItemModel(idGenerator: idGenerator, widthMode: .halfWidth, preferredHeight: 55), + ItemModel(idGenerator: idGenerator, widthMode: .halfWidth, preferredHeight: 105), + ItemModel(idGenerator: idGenerator, widthMode: .halfWidth, preferredHeight: 80), + ItemModel(idGenerator: idGenerator, widthMode: .halfWidth, preferredHeight: 95), + ItemModel(idGenerator: idGenerator, widthMode: .thirdWidth, preferredHeight: 200), + ItemModel(idGenerator: idGenerator, widthMode: .thirdWidth, preferredHeight: 200), + ItemModel(idGenerator: idGenerator, widthMode: .thirdWidth, preferredHeight: nil), ], headerModel: nil, footerModel: nil, @@ -264,11 +267,12 @@ final class LayoutStateTargetContentOffsetTests: XCTestCase { let modelState = ModelState(currentVisibleBoundsProvider: { bounds }) let sections = [ SectionModel( + idGenerator: idGenerator, itemModels: [ // Create items that are 500px tall, larger than the 400px bounds height - ItemModel(widthMode: .fullWidth(respectsHorizontalInsets: true), preferredHeight: 500), - ItemModel(widthMode: .fullWidth(respectsHorizontalInsets: true), preferredHeight: 500), - ItemModel(widthMode: .fullWidth(respectsHorizontalInsets: true), preferredHeight: 500), + ItemModel(idGenerator: idGenerator, widthMode: .fullWidth(respectsHorizontalInsets: true), preferredHeight: 500), + ItemModel(idGenerator: idGenerator, widthMode: .fullWidth(respectsHorizontalInsets: true), preferredHeight: 500), + ItemModel(idGenerator: idGenerator, widthMode: .fullWidth(respectsHorizontalInsets: true), preferredHeight: 500), ], headerModel: nil, footerModel: nil, @@ -291,8 +295,15 @@ final class LayoutStateTargetContentOffsetTests: XCTestCase { // MARK: - ItemModel private extension ItemModel { - init(widthMode: MagazineLayoutItemWidthMode, preferredHeight: CGFloat?) { - self.init(sizeMode: .init(widthMode: widthMode, heightMode: .dynamic), height: 150) + init( + idGenerator: IDGenerator, + widthMode: MagazineLayoutItemWidthMode, + preferredHeight: CGFloat?) + { + self.init( + idGenerator: idGenerator, + sizeMode: .init(widthMode: widthMode, heightMode: .dynamic), + height: 150) self.preferredHeight = preferredHeight } } diff --git a/Tests/ModelStateEmptySectionLayoutTests.swift b/Tests/ModelStateEmptySectionLayoutTests.swift index e16fc42..74f85f4 100644 --- a/Tests/ModelStateEmptySectionLayoutTests.swift +++ b/Tests/ModelStateEmptySectionLayoutTests.swift @@ -45,12 +45,14 @@ final class ModelStateEmptySectionLayoutTests: XCTestCase { let initialSections = [ SectionModel( + idGenerator: idGenerator, itemModels: [], headerModel: nil, footerModel: nil, backgroundModel: nil, metrics: metrics0), SectionModel( + idGenerator: idGenerator, itemModels: [], headerModel: nil, footerModel: nil, @@ -82,6 +84,7 @@ final class ModelStateEmptySectionLayoutTests: XCTestCase { let initialSections = [ SectionModel( + idGenerator: idGenerator, itemModels: [], headerModel: HeaderModel( heightMode: .static(height: 45), @@ -94,6 +97,7 @@ final class ModelStateEmptySectionLayoutTests: XCTestCase { backgroundModel: BackgroundModel(), metrics: metrics0), SectionModel( + idGenerator: idGenerator, itemModels: [], headerModel: HeaderModel( heightMode: .static(height: 65), @@ -145,6 +149,8 @@ final class ModelStateEmptySectionLayoutTests: XCTestCase { // MARK: Private + private let idGenerator = IDGenerator() + private var modelState: ModelState! } diff --git a/Tests/ModelStateLayoutTests.swift b/Tests/ModelStateLayoutTests.swift index ecdcd53..dc74e3d 100644 --- a/Tests/ModelStateLayoutTests.swift +++ b/Tests/ModelStateLayoutTests.swift @@ -36,12 +36,13 @@ final class ModelStateLayoutTests: XCTestCase { (headerModel1, sizeModesAndHeights1, footerModel1) ].map { headerModel, sizeModesAndHeights, footerModel in SectionModel( + idGenerator: idGenerator, itemModels: sizeModesAndHeights.map { sizeMode, height in switch sizeMode.heightMode { case .static: - return ItemModel(sizeMode: sizeMode, height: height) + return ItemModel(idGenerator: idGenerator, sizeMode: sizeMode, height: height) case .dynamic, .dynamicAndStretchToTallestItemInRow: - return ItemModel(sizeMode: sizeMode, height: 150) + return ItemModel(idGenerator: idGenerator, sizeMode: sizeMode, height: 150) } }, headerModel: headerModel, @@ -348,6 +349,7 @@ final class ModelStateLayoutTests: XCTestCase { .itemInsert( itemIndexPath: IndexPath(item: 3, section: 1), newItem: ItemModel( + idGenerator: idGenerator, sizeMode: MagazineLayoutItemSizeMode( widthMode: .halfWidth, heightMode: .static(height: 10)), @@ -419,6 +421,7 @@ final class ModelStateLayoutTests: XCTestCase { .itemInsert( itemIndexPath: IndexPath(item: 4, section: 0), newItem: ItemModel( + idGenerator: idGenerator, sizeMode: MagazineLayoutItemSizeMode( widthMode: .thirdWidth, heightMode: .static(height: 100)), @@ -426,6 +429,7 @@ final class ModelStateLayoutTests: XCTestCase { .itemInsert( itemIndexPath: IndexPath(item: 5, section: 0), newItem: ItemModel( + idGenerator: idGenerator, sizeMode: MagazineLayoutItemSizeMode( widthMode: .thirdWidth, heightMode: .static(height: 50)), @@ -433,6 +437,7 @@ final class ModelStateLayoutTests: XCTestCase { .itemInsert( itemIndexPath: IndexPath(item: 6, section: 0), newItem: ItemModel( + idGenerator: idGenerator, sizeMode: MagazineLayoutItemSizeMode( widthMode: .thirdWidth, heightMode: .static(height: 20)), @@ -1246,6 +1251,8 @@ final class ModelStateLayoutTests: XCTestCase { // MARK: Private + private let idGenerator = IDGenerator() + private var modelState: ModelState! private let visibleRect0 = CGRect(x: 0, y: 0, width: 320, height: 500) diff --git a/Tests/TestingSupport.swift b/Tests/TestingSupport.swift index b592b16..3db236b 100644 --- a/Tests/TestingSupport.swift +++ b/Tests/TestingSupport.swift @@ -21,6 +21,8 @@ import UIKit final class ModelHelpers { + // MARK: Internal + static func basicSectionModels( numberOfSections: UInt, numberOfItemsPerSection: UInt) @@ -35,6 +37,7 @@ final class ModelHelpers { } let sectionModel = SectionModel( + idGenerator: idGenerator, itemModels: itemModels, headerModel: nil, footerModel: nil, @@ -50,12 +53,17 @@ final class ModelHelpers { static func basicItemModel() -> ItemModel { return ItemModel( + idGenerator: idGenerator, sizeMode: MagazineLayoutItemSizeMode( widthMode: .fullWidth(respectsHorizontalInsets: true), heightMode: .static(height: 20)), height: 20) } + // MARK: Private + + private static let idGenerator = IDGenerator() + } // MARK: - FrameHelpers