Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions MagazineLayout.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -94,6 +95,7 @@
93A1C02B21ACED0100DED67D /* ModelState.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ModelState.swift; sourceTree = "<group>"; };
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 = "<group>"; };
FCAC642522085AF100973F4C /* MagazineLayoutHeaderVisibilityMode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MagazineLayoutHeaderVisibilityMode.swift; sourceTree = "<group>"; };
FCAC642722085B0E00973F4C /* FooterModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FooterModel.swift; sourceTree = "<group>"; };
FD23F5F021AF4A1B00AA78D4 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
Expand Down Expand Up @@ -228,6 +230,7 @@
93540AAF282E25D90008BD6F /* ScreenPixelAlignment.swift */,
FDABC2572B23D0A000C9B8EF /* TargetContentOffsetAnchor.swift */,
FD244FEE28B41F9900046C0D /* UITraitCollection+DisplayScale.swift */,
93A868542EE0D7870027691E /* IDGenerator.swift */,
);
path = Types;
sourceTree = "<group>";
Expand Down Expand Up @@ -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 */,
Expand Down
4 changes: 0 additions & 4 deletions MagazineLayout/LayoutCore/BackgroundModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,23 +14,19 @@
// limitations under the License.

import CoreGraphics
import Foundation

/// Represents the layout information for a background in a section.
struct BackgroundModel {

// MARK: Lifecycle

init() {
id = NSUUID().uuidString
originInSection = .zero
size = .zero
}

// MARK: Internal

let id: String

var originInSection: CGPoint
var size: CGSize

Expand Down
7 changes: 3 additions & 4 deletions MagazineLayout/LayoutCore/ItemModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,23 +14,22 @@
// 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)
}

// MARK: Internal

let id: UUID
let id: UInt64

var sizeMode: MagazineLayoutItemSizeMode
var originInSection: CGPoint
Expand Down
8 changes: 4 additions & 4 deletions MagazineLayout/LayoutCore/ModelState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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..<sectionModels.count {
guard let index = sectionModels[sectionIndex].indexForItemModel(withID: id) else {
continue
Expand All @@ -63,7 +63,7 @@ final class ModelState {
return nil
}

func idForSectionModel(atIndex index: Int) -> UUID? {
func idForSectionModel(atIndex index: Int) -> UInt64? {
guard index < sectionModels.count else {
// This occurs when getting layout attributes for initial / final animations
return nil
Expand All @@ -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..<sectionModels.count {
guard sectionModels[sectionIndex].id == id else { continue }
return sectionIndex
Expand Down
10 changes: 5 additions & 5 deletions MagazineLayout/LayoutCore/SectionModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,21 +14,21 @@
// limitations under the License.

import CoreGraphics
import Foundation

/// Represents the layout information for a section.
struct SectionModel {

// MARK: Lifecycle

init(
idGenerator: IDGenerator,
itemModels: [ItemModel],
headerModel: HeaderModel?,
footerModel: FooterModel?,
backgroundModel: BackgroundModel?,
metrics: MagazineLayoutSectionMetrics)
{
id = UUID()
id = idGenerator.next()
self.itemModels = itemModels
self.headerModel = headerModel
self.footerModel = footerModel
Expand All @@ -43,7 +43,7 @@ struct SectionModel {

// MARK: Internal

let id: UUID
let id: UInt64

private(set) var headerModel: HeaderModel?
private(set) var footerModel: FooterModel?
Expand All @@ -55,11 +55,11 @@ struct SectionModel {
return itemModels.count
}

func idForItemModel(atIndex index: Int) -> 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 }
}

Expand Down
29 changes: 29 additions & 0 deletions MagazineLayout/LayoutCore/Types/IDGenerator.swift
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
3 changes: 3 additions & 0 deletions MagazineLayout/Public/MagazineLayout.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1042,6 +1043,7 @@ public final class MagazineLayout: UICollectionViewLayout {
}

return SectionModel(
idGenerator: idGenerator,
itemModels: itemModels,
headerModel: headerModelForHeader(inSectionAtIndex: sectionIndex),
footerModel: footerModelForFooter(inSectionAtIndex: sectionIndex),
Expand All @@ -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))
}
Expand Down
49 changes: 30 additions & 19 deletions Tests/LayoutStateTargetContentOffsetTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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
}
}
6 changes: 6 additions & 0 deletions Tests/ModelStateEmptySectionLayoutTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -82,6 +84,7 @@ final class ModelStateEmptySectionLayoutTests: XCTestCase {

let initialSections = [
SectionModel(
idGenerator: idGenerator,
itemModels: [],
headerModel: HeaderModel(
heightMode: .static(height: 45),
Expand All @@ -94,6 +97,7 @@ final class ModelStateEmptySectionLayoutTests: XCTestCase {
backgroundModel: BackgroundModel(),
metrics: metrics0),
SectionModel(
idGenerator: idGenerator,
itemModels: [],
headerModel: HeaderModel(
heightMode: .static(height: 65),
Expand Down Expand Up @@ -145,6 +149,8 @@ final class ModelStateEmptySectionLayoutTests: XCTestCase {

// MARK: Private

private let idGenerator = IDGenerator()

private var modelState: ModelState!

}
Loading