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
12 changes: 8 additions & 4 deletions MagazineLayout.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
60432D952E05DB41001728F0 /* ContentInsetAdjustingContentOffsetTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60432D942E05DB41001728F0 /* ContentInsetAdjustingContentOffsetTests.swift */; };
9332FB0822969B5600483D99 /* RowOffsetTrackerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9332FB0622969AB200483D99 /* RowOffsetTrackerTests.swift */; };
93424B012256878B003D00C0 /* MagazineLayoutFooterVisibilityMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93424B002256878B003D00C0 /* MagazineLayoutFooterVisibilityMode.swift */; };
93443FD22EB582AE00D60F56 /* LayoutState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93443FD12EB582AE00D60F56 /* LayoutState.swift */; };
93540AB0282E25D90008BD6F /* ScreenPixelAlignment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93540AAF282E25D90008BD6F /* ScreenPixelAlignment.swift */; };
93540AB2282E26340008BD6F /* ScreenPixelAlignmentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93540AB1282E26340008BD6F /* ScreenPixelAlignmentTests.swift */; };
9398462A2296864200E442DA /* RowOffsetTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 939846292296864200E442DA /* RowOffsetTracker.swift */; };
Expand Down Expand Up @@ -44,7 +45,7 @@
FD4DFF0A21B0D182001F46CE /* MagazineLayoutCollectionReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD4DFF0921B0D181001F46CE /* MagazineLayoutCollectionReusableView.swift */; };
FDABC2562B23CCF700C9B8EF /* MagazineLayoutVerticalLayoutDirection.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDABC2552B23CCF700C9B8EF /* MagazineLayoutVerticalLayoutDirection.swift */; };
FDABC2582B23D0A000C9B8EF /* TargetContentOffsetAnchor.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDABC2572B23D0A000C9B8EF /* TargetContentOffsetAnchor.swift */; };
FDE08E162B2CC47800C9D24D /* TargetContentOffsetAnchorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE08E152B2CC47800C9D24D /* TargetContentOffsetAnchorTests.swift */; };
FDE08E162B2CC47800C9D24D /* LayoutStateTargetContentOffsetTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE08E152B2CC47800C9D24D /* LayoutStateTargetContentOffsetTests.swift */; };
FDF6E15B21B0B7870092775D /* MagazineLayoutCollectionViewLayoutAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF6E15A21B0B7870092775D /* MagazineLayoutCollectionViewLayoutAttributes.swift */; };
/* End PBXBuildFile section */

Expand All @@ -62,6 +63,7 @@
60432D942E05DB41001728F0 /* ContentInsetAdjustingContentOffsetTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentInsetAdjustingContentOffsetTests.swift; sourceTree = "<group>"; };
9332FB0622969AB200483D99 /* RowOffsetTrackerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RowOffsetTrackerTests.swift; sourceTree = "<group>"; };
93424B002256878B003D00C0 /* MagazineLayoutFooterVisibilityMode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MagazineLayoutFooterVisibilityMode.swift; sourceTree = "<group>"; };
93443FD12EB582AE00D60F56 /* LayoutState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LayoutState.swift; sourceTree = "<group>"; };
93540AAF282E25D90008BD6F /* ScreenPixelAlignment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenPixelAlignment.swift; sourceTree = "<group>"; };
93540AB1282E26340008BD6F /* ScreenPixelAlignmentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenPixelAlignmentTests.swift; sourceTree = "<group>"; };
939846292296864200E442DA /* RowOffsetTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RowOffsetTracker.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -101,7 +103,7 @@
FD69EA0F21BA01E6001E0650 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
FDABC2552B23CCF700C9B8EF /* MagazineLayoutVerticalLayoutDirection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MagazineLayoutVerticalLayoutDirection.swift; sourceTree = "<group>"; };
FDABC2572B23D0A000C9B8EF /* TargetContentOffsetAnchor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TargetContentOffsetAnchor.swift; sourceTree = "<group>"; };
FDE08E152B2CC47800C9D24D /* TargetContentOffsetAnchorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TargetContentOffsetAnchorTests.swift; sourceTree = "<group>"; };
FDE08E152B2CC47800C9D24D /* LayoutStateTargetContentOffsetTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LayoutStateTargetContentOffsetTests.swift; sourceTree = "<group>"; };
FDF6E15A21B0B7870092775D /* MagazineLayoutCollectionViewLayoutAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MagazineLayoutCollectionViewLayoutAttributes.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */

Expand Down Expand Up @@ -150,7 +152,7 @@
children = (
FD23F5F021AF4A1B00AA78D4 /* Info.plist */,
93A1C00C21ACED0100DED67D /* ModelStateInitiallSetUpTests.swift */,
FDE08E152B2CC47800C9D24D /* TargetContentOffsetAnchorTests.swift */,
FDE08E152B2CC47800C9D24D /* LayoutStateTargetContentOffsetTests.swift */,
93A1C00A21ACED0100DED67D /* ModelStateEmptySectionLayoutTests.swift */,
93A1C00D21ACED0100DED67D /* ModelStateLayoutTests.swift */,
93A1C01021ACED0100DED67D /* ModelStateUpdateTests.swift */,
Expand Down Expand Up @@ -202,6 +204,7 @@
93A1C01E21ACED0100DED67D /* LayoutCore */ = {
isa = PBXGroup;
children = (
93443FD12EB582AE00D60F56 /* LayoutState.swift */,
93A1C02B21ACED0100DED67D /* ModelState.swift */,
93A1C02721ACED0100DED67D /* SectionModel.swift */,
93A1C02821ACED0100DED67D /* ItemModel.swift */,
Expand Down Expand Up @@ -362,6 +365,7 @@
93A1C04821ACED0100DED67D /* ModelState.swift in Sources */,
9398462A2296864200E442DA /* RowOffsetTracker.swift in Sources */,
FD4DFF0821B0C737001F46CE /* MagazineLayoutCollectionViewCell.swift in Sources */,
93443FD22EB582AE00D60F56 /* LayoutState.swift in Sources */,
93424B012256878B003D00C0 /* MagazineLayoutFooterVisibilityMode.swift in Sources */,
FDF6E15B21B0B7870092775D /* MagazineLayoutCollectionViewLayoutAttributes.swift in Sources */,
93A1C03621ACED0100DED67D /* MagazineLayout+SupplementaryViewKinds.swift in Sources */,
Expand All @@ -383,7 +387,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
FDE08E162B2CC47800C9D24D /* TargetContentOffsetAnchorTests.swift in Sources */,
FDE08E162B2CC47800C9D24D /* LayoutStateTargetContentOffsetTests.swift in Sources */,
93A1C04B21ACED1100DED67D /* ModelStateInitiallSetUpTests.swift in Sources */,
60432D952E05DB41001728F0 /* ContentInsetAdjustingContentOffsetTests.swift in Sources */,
9332FB0822969B5600483D99 /* RowOffsetTrackerTests.swift in Sources */,
Expand Down
174 changes: 174 additions & 0 deletions MagazineLayout/LayoutCore/LayoutState.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
// Created by Bryan Keller on 10/31/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.

import UIKit

// MARK: - LayoutState

/// Represents the state of the layout, including metrics and the current `ModelState`.
struct LayoutState {

// MARK: Internal

let modelState: ModelState

var bounds: CGRect
var contentInset: UIEdgeInsets
var scale: CGFloat
var verticalLayoutDirection: MagazineLayoutVerticalLayoutDirection

var minContentOffset: CGPoint {
CGPoint(x: -contentInset.left, y: -contentInset.top)
}

var maxContentOffset: CGPoint {
let x = contentSize.width - bounds.width + contentInset.right
let y = contentSize.height - bounds.height + contentInset.bottom
return CGPoint(x: max(x, minContentOffset.x), y: max(y, minContentOffset.y))
}

var contentSize: CGSize {
// This is a workaround for `layoutAttributesForElementsInRect:` not getting invoked enough
// times if `collectionViewContentSize.width` is not smaller than the width of the collection
// view, minus horizontal insets. This results in visual defects when performing batch
// updates. To work around this, we subtract 0.0001 from our content size width calculation;
// this small decrease in `collectionViewContentSize.width` is enough to work around the
// incorrect, internal collection view `CGRect` checks, without introducing any visual
// differences for elements in the collection view.
// See https://openradar.appspot.com/radar?id=5025850143539200 for more details.
let width = bounds.width - contentInset.left - contentInset.right - 0.0001

let numberOfSections = modelState.numberOfSections
let height: CGFloat =
if numberOfSections <= 0 {
0
} else {
modelState.sectionMaxY(forSectionAtIndex: numberOfSections - 1)
}

return CGSize(width: width, height: height)
Comment on lines +42 to +61
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

all of this used to live in MagazineLayout.swift - I moved it here without changing any math or comments

}

var targetContentOffsetAnchor: TargetContentOffsetAnchor {
var visibleItemLocationFramePairs = [ElementLocationFramePair]()
for itemLocationFramePair in modelState.itemLocationFramePairs(forItemsIn: bounds) {
visibleItemLocationFramePairs.append(itemLocationFramePair)
}
visibleItemLocationFramePairs.sort { $0.elementLocation < $1.elementLocation }

let firstVisibleItemLocationFramePair = visibleItemLocationFramePairs.first {
// When scrolling up, only calculate a target content offset based on visible, already-sized
// cells. Otherwise, scrolling will be jumpy.
modelState.isItemHeightSettled(indexPath: $0.elementLocation.indexPath)
} ?? visibleItemLocationFramePairs.first // fallback to the first item if we can't find one with a settled height

let lastVisibleItemLocationFramePair = visibleItemLocationFramePairs.reversed().first {
// When scrolling down, only calculate a target content offset based on visible, already-sized
// cells. Otherwise, scrolling will be jumpy.
modelState.isItemHeightSettled(indexPath: $0.elementLocation.indexPath)
} ?? visibleItemLocationFramePairs.last // fallback to the last item if we can't find one with a settled height

guard
let firstVisibleItemLocationFramePair,
let lastVisibleItemLocationFramePair,
let firstVisibleItemID = modelState.idForItemModel(
at: firstVisibleItemLocationFramePair.elementLocation.indexPath),
let lastVisibleItemID = modelState.idForItemModel(
at: lastVisibleItemLocationFramePair.elementLocation.indexPath)
else {
switch verticalLayoutDirection {
case .topToBottom: return .top
case .bottomToTop: return .bottom
}
}

let top = minContentOffset.y.alignedToPixel(forScreenWithScale: scale)
let bottom = maxContentOffset.y.alignedToPixel(forScreenWithScale: scale)
let isAtTop = bounds.minY <= top
let isAtBottom = bounds.minY >= bottom
let position: Position
if isAtTop, isAtBottom {
switch verticalLayoutDirection {
case .topToBottom:
position = .atTop
case .bottomToTop:
position = .atBottom
}
} else if isAtTop {
position = .atTop
} else if isAtBottom {
position = .atBottom
} else {
position = .inMiddle
}

switch verticalLayoutDirection {
case .topToBottom:
switch position {
case .atTop:
return .top
case .inMiddle, .atBottom:
let top = bounds.minY + contentInset.top
let distanceFromTop = firstVisibleItemLocationFramePair.frame.minY - top
return .topItem(
id: firstVisibleItemID,
distanceFromTop: distanceFromTop.alignedToPixel(forScreenWithScale: scale))
}
case .bottomToTop:
switch position {
case .atTop, .inMiddle:
let bottom = bounds.maxY - contentInset.bottom
let distanceFromBottom = lastVisibleItemLocationFramePair.frame.maxY - bottom
return .bottomItem(
id: lastVisibleItemID,
distanceFromBottom: distanceFromBottom.alignedToPixel(forScreenWithScale: scale))
case .atBottom:
return .bottom
}
}
}
Comment on lines +64 to +141
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic pulled out of a function that used to live in MagazineLayout.swift


func yOffset(for targetContentOffsetAnchor: TargetContentOffsetAnchor) -> CGFloat {
switch targetContentOffsetAnchor {
case .top:
return minContentOffset.y

case .bottom:
return maxContentOffset.y

case .topItem(let id, let distanceFromTop):
guard let indexPath = modelState.indexPathForItemModel(withID: id) else { return bounds.minY }
let itemFrame = modelState.frameForItem(at: ElementLocation(indexPath: indexPath))
let proposedYOffset = itemFrame.minY - contentInset.top - distanceFromTop
// Clamp between minYOffset...maxYOffset
return min(max(proposedYOffset, minContentOffset.y), maxContentOffset.y)

case .bottomItem(let id, let distanceFromBottom):
guard let indexPath = modelState.indexPathForItemModel(withID: id) else { return bounds.minY }
let itemFrame = modelState.frameForItem(at: ElementLocation(indexPath: indexPath))
let proposedYOffset = itemFrame.maxY - bounds.height + contentInset.bottom - distanceFromBottom
// Clamp between minYOffset...maxYOffset
return min(max(proposedYOffset, minContentOffset.y), maxContentOffset.y)
}
}
}

// MARK: - Position

private enum Position {
case atTop
case inMiddle
case atBottom
}
8 changes: 2 additions & 6 deletions MagazineLayout/LayoutCore/ModelState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -209,9 +209,7 @@ final class ModelState {
}

let maxY = cachedMaxYForSection(atIndex: targetSectionIndex) ?? sectionMaxY
if !disableSectionMaxYsCache {
cacheMaxY(maxY, forSectionAtIndex: targetSectionIndex)
}
Comment on lines -212 to -214
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I meant to delete this in my last PR - this extra property / check isn't necessary, and was never actually used

cacheMaxY(maxY, forSectionAtIndex: targetSectionIndex)
return maxY
}

Expand Down Expand Up @@ -301,11 +299,10 @@ final class ModelState {
return backgroundFrame
}

func copyForBatchUpdates() -> ModelState {
func copy() -> ModelState {
let currentVisibleBounds = currentVisibleBoundsProvider()
let newModelState = ModelState(currentVisibleBoundsProvider: { currentVisibleBounds })
newModelState.sectionModels = sectionModels
newModelState.disableSectionMaxYsCache = true
newModelState.headerLocationsForFlattenedIndices = headerLocationsForFlattenedIndices
newModelState.footerLocationsForFlattenedIndices = footerLocationsForFlattenedIndices
newModelState.backgroundLocationsForFlattenedIndices = backgroundLocationsForFlattenedIndices
Expand Down Expand Up @@ -507,7 +504,6 @@ final class ModelState {
private var sectionModels = [SectionModel]()

private var sectionMaxYsCache = [CGFloat?]()
private var disableSectionMaxYsCache = false

private var headerLocationsForFlattenedIndices = [Int: ElementLocation]()
private var footerLocationsForFlattenedIndices = [Int: ElementLocation]()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ struct MagazineLayoutSectionMetrics: Equatable {
scale = collectionView.traitCollection.nonZeroDisplayScale
}

private init(
init(
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

made internal for unit test support

collectionViewWidth: CGFloat,
collectionViewContentInset: UIEdgeInsets,
verticalSpacing: CGFloat,
Expand Down
104 changes: 1 addition & 103 deletions MagazineLayout/LayoutCore/Types/TargetContentOffsetAnchor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,112 +17,10 @@ import UIKit

// MARK: - TargetContentOffsetAnchor

/// An internal type for calculating the target content offset for various state of the collection view. Various anchors are possible, each
/// changing how the collection view prioritizes keeping certain items visible in target content offset calculations.
Comment on lines -20 to -21
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All of the static functions below for calculating the targetContentOffsetAnchor and yOffset moved to the LayoutState, making it easier to get this information for pre-update and post-update layout states.

/// Anchors representing how the collection view prioritizes keeping certain items visible in target content offset calculations.
enum TargetContentOffsetAnchor: Equatable {
case top
case bottom
case topItem(id: UUID, distanceFromTop: CGFloat)
case bottomItem(id: UUID, distanceFromBottom: CGFloat)

static func targetContentOffsetAnchor(
verticalLayoutDirection: MagazineLayoutVerticalLayoutDirection,
topInset: CGFloat,
bottomInset: CGFloat,
bounds: CGRect,
contentHeight: CGFloat,
scale: CGFloat,
firstVisibleItemID: UUID,
lastVisibleItemID: UUID,
firstVisibleItemFrame: CGRect,
lastVisibleItemFrame: CGRect)
-> Self
{
let top = (-topInset).alignedToPixel(forScreenWithScale: scale)
let bottom = (contentHeight + bottomInset - bounds.height).alignedToPixel(forScreenWithScale: scale)
let isAtTop = bounds.minY <= top
let isAtBottom = bounds.minY >= bottom
let position: Position
if isAtTop, isAtBottom {
switch verticalLayoutDirection {
case .topToBottom:
position = .atTop
case .bottomToTop:
position = .atBottom
}
} else if isAtTop {
position = .atTop
} else if isAtBottom {
position = .atBottom
} else {
position = .inMiddle
}

switch verticalLayoutDirection {
case .topToBottom:
switch position {
case .atTop:
return .top
case .inMiddle, .atBottom:
let top = bounds.minY + topInset
let distanceFromTop = firstVisibleItemFrame.minY - top
return .topItem(
id: firstVisibleItemID,
distanceFromTop: distanceFromTop.alignedToPixel(forScreenWithScale: scale))
}
case .bottomToTop:
switch position {
case .atTop, .inMiddle:
let bottom = bounds.maxY - bottomInset
let distanceFromBottom = lastVisibleItemFrame.maxY - bottom
return .bottomItem(
id: lastVisibleItemID,
distanceFromBottom: distanceFromBottom.alignedToPixel(forScreenWithScale: scale))
case .atBottom:
return .bottom
}
}
}

func yOffset(
topInset: CGFloat,
bottomInset: CGFloat,
bounds: CGRect,
contentHeight: CGFloat,
indexPathForItemID: (_ id: UUID) -> IndexPath?,
frameForItemAtIndexPath: (_ indexPath: IndexPath) -> CGRect)
-> CGFloat
{
let minYOffset = -topInset
let maxYOffset = max(contentHeight - bounds.height + bottomInset, minYOffset)

switch self {
case .top:
return minYOffset

case .bottom:
return maxYOffset

case .topItem(let id, let distanceFromTop):
guard let indexPath = indexPathForItemID(id) else { return bounds.minY }
let itemFrame = frameForItemAtIndexPath(indexPath)
let proposedYOffset = itemFrame.minY - topInset - distanceFromTop
return min(max(proposedYOffset, minYOffset), maxYOffset) // Clamp between minYOffset...maxYOffset

case .bottomItem(let id, let distanceFromBottom):
guard let indexPath = indexPathForItemID(id) else { return bounds.minY }
let itemFrame = frameForItemAtIndexPath(indexPath)
let proposedYOffset = itemFrame.maxY - bounds.height + bottomInset - distanceFromBottom
return min(max(proposedYOffset, minYOffset), maxYOffset) // Clamp between minYOffset...maxYOffset
}
}

}

// MARK: - Position

private enum Position {
case atTop
case inMiddle
case atBottom
}
Loading