From c215f6ad87540828cad0d31a78522c3c56c51a33 Mon Sep 17 00:00:00 2001 From: Bryan Keller Date: Sat, 1 Nov 2025 10:46:37 -0700 Subject: [PATCH 1/3] Refactor to use LayoutState --- MagazineLayout.xcodeproj/project.pbxproj | 4 + MagazineLayout/LayoutCore/LayoutState.swift | 174 ++++++++++ MagazineLayout/LayoutCore/ModelState.swift | 8 +- .../Types/TargetContentOffsetAnchor.swift | 104 +----- MagazineLayout/Public/MagazineLayout.swift | 296 +++++++----------- 5 files changed, 301 insertions(+), 285 deletions(-) create mode 100644 MagazineLayout/LayoutCore/LayoutState.swift diff --git a/MagazineLayout.xcodeproj/project.pbxproj b/MagazineLayout.xcodeproj/project.pbxproj index 69f9b51..46eb145 100644 --- a/MagazineLayout.xcodeproj/project.pbxproj +++ b/MagazineLayout.xcodeproj/project.pbxproj @@ -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 */; }; @@ -62,6 +63,7 @@ 60432D942E05DB41001728F0 /* ContentInsetAdjustingContentOffsetTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentInsetAdjustingContentOffsetTests.swift; sourceTree = ""; }; 9332FB0622969AB200483D99 /* RowOffsetTrackerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RowOffsetTrackerTests.swift; sourceTree = ""; }; 93424B002256878B003D00C0 /* MagazineLayoutFooterVisibilityMode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MagazineLayoutFooterVisibilityMode.swift; sourceTree = ""; }; + 93443FD12EB582AE00D60F56 /* LayoutState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LayoutState.swift; sourceTree = ""; }; 93540AAF282E25D90008BD6F /* ScreenPixelAlignment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenPixelAlignment.swift; sourceTree = ""; }; 93540AB1282E26340008BD6F /* ScreenPixelAlignmentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenPixelAlignmentTests.swift; sourceTree = ""; }; 939846292296864200E442DA /* RowOffsetTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RowOffsetTracker.swift; sourceTree = ""; }; @@ -202,6 +204,7 @@ 93A1C01E21ACED0100DED67D /* LayoutCore */ = { isa = PBXGroup; children = ( + 93443FD12EB582AE00D60F56 /* LayoutState.swift */, 93A1C02B21ACED0100DED67D /* ModelState.swift */, 93A1C02721ACED0100DED67D /* SectionModel.swift */, 93A1C02821ACED0100DED67D /* ItemModel.swift */, @@ -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 */, diff --git a/MagazineLayout/LayoutCore/LayoutState.swift b/MagazineLayout/LayoutCore/LayoutState.swift new file mode 100644 index 0000000..36d18c7 --- /dev/null +++ b/MagazineLayout/LayoutCore/LayoutState.swift @@ -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) + } + + 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 + } + } + } + + 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.maxY - 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 +} diff --git a/MagazineLayout/LayoutCore/ModelState.swift b/MagazineLayout/LayoutCore/ModelState.swift index d7f1692..a2e5acf 100755 --- a/MagazineLayout/LayoutCore/ModelState.swift +++ b/MagazineLayout/LayoutCore/ModelState.swift @@ -209,9 +209,7 @@ final class ModelState { } let maxY = cachedMaxYForSection(atIndex: targetSectionIndex) ?? sectionMaxY - if !disableSectionMaxYsCache { - cacheMaxY(maxY, forSectionAtIndex: targetSectionIndex) - } + cacheMaxY(maxY, forSectionAtIndex: targetSectionIndex) return maxY } @@ -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 @@ -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]() diff --git a/MagazineLayout/LayoutCore/Types/TargetContentOffsetAnchor.swift b/MagazineLayout/LayoutCore/Types/TargetContentOffsetAnchor.swift index 951aa75..d2ca62a 100644 --- a/MagazineLayout/LayoutCore/Types/TargetContentOffsetAnchor.swift +++ b/MagazineLayout/LayoutCore/Types/TargetContentOffsetAnchor.swift @@ -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. +/// 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 } diff --git a/MagazineLayout/Public/MagazineLayout.swift b/MagazineLayout/Public/MagazineLayout.swift index efb68bc..6fdbc42 100755 --- a/MagazineLayout/Public/MagazineLayout.swift +++ b/MagazineLayout/Public/MagazineLayout.swift @@ -59,31 +59,8 @@ public final class MagazineLayout: UICollectionViewLayout { } override public var collectionViewContentSize: CGSize { - let numberOfSections = modelState.numberOfSections - - let width: CGFloat - if let collectionView = collectionView { - // 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. - width = collectionView.bounds.width - contentInset.left - contentInset.right - 0.0001 - } else { - width = 0 - } - - let height: CGFloat - if numberOfSections <= 0 { - height = 0 - } else { - height = modelState.sectionMaxY(forSectionAtIndex: numberOfSections - 1) - } - - return CGSize(width: width, height: height) + guard collectionView != nil else { return .zero } + return layoutState.contentSize } override public func prepare() { @@ -135,6 +112,13 @@ public final class MagazineLayout: UICollectionViewLayout { // Recreate section models from scratch if necessary if prepareActions.contains(.recreateSectionModels) { + layoutStateBeforeRecreateSectionModels = LayoutState( + modelState: layoutState.modelState.copy(), + bounds: currentCollectionView.bounds, + contentInset: contentInset, + scale: scale, + verticalLayoutDirection: verticalLayoutDirection) + var sections = [SectionModel]() for sectionIndex in 0..]() @@ -230,12 +219,9 @@ public final class MagazineLayout: UICollectionViewLayout { } } - // Calculate the target offset before applying updates, since the target offset should be based - // on the pre-update state. - targetContentOffsetAnchor = currentTargetContentOffsetAnchor - contentHeightBeforeUpdates = collectionViewContentSize.height - - modelState.applyUpdates(updates, modelStateBeforeBatchUpdates: modelStateBeforeBatchUpdates) + modelState.applyUpdates( + updates, + modelStateBeforeBatchUpdates: layoutStateBeforeCollectionViewUpdates.modelState) hasDataSourceCountInvalidationBeforeReceivingUpdateItems = false lastSizedElementMinY = nil @@ -250,20 +236,18 @@ public final class MagazineLayout: UICollectionViewLayout { itemLayoutAttributesForPendingAnimations.removeAll() supplementaryViewLayoutAttributesForPendingAnimations.removeAll() - if let targetContentOffsetAnchor { - let targetYOffset = yOffset(for: targetContentOffsetAnchor) + if let layoutStateBeforeCollectionViewUpdates{ + let targetContentOffsetAnchor = layoutStateBeforeCollectionViewUpdates.targetContentOffsetAnchor + let targetYOffset = layoutState.yOffset(for: targetContentOffsetAnchor) let context = MagazineLayoutInvalidationContext() context.invalidateLayoutMetrics = false - context.contentOffsetAdjustment.y = targetYOffset - currentCollectionView.contentOffset.y + context.contentOffsetAdjustment.y = targetYOffset - layoutState.bounds.minY invalidateLayout(with: context) } - targetContentOffsetAnchor = nil - contentHeightBeforeUpdates = nil - targetContentOffsetCompensatingYOffsetForAppearingItem = nil - modelStateBeforeBatchUpdates = nil + layoutStateBeforeCollectionViewUpdates = nil super.finalizeCollectionViewUpdates() } @@ -272,21 +256,17 @@ public final class MagazineLayout: UICollectionViewLayout { super.prepare(forAnimatedBoundsChange: oldBounds) if currentCollectionView.bounds.size != oldBounds.size { - targetContentOffsetAnchor = targetContentOffsetAnchor( + layoutStateBeforeAnimatedBoundsChange = LayoutState( + modelState: layoutState.modelState.copy(), bounds: oldBounds, - contentHeight: currentCollectionView.contentSize.height, - // There doesn't seem to be a reliable way to get the correct content insets here. We can try - // track them in invalidateLayout or prepare, but then there are edge cases where we need to - // track multiple past inset values. It's a huge mess, and I don't think it's worth solving. - // The downside is that if your insets change on rotation, you won't always land in the exact - // correct spot if you're in the middle of the content. Being at the top or bottom works fine. - topInset: 0, - bottomInset: 0) + contentInset: contentInset, + scale: scale, + verticalLayoutDirection: verticalLayoutDirection) } } override public func finalizeAnimatedBoundsChange() { - targetContentOffsetAnchor = nil + layoutStateBeforeAnimatedBoundsChange = nil super.finalizeAnimatedBoundsChange() } @@ -433,11 +413,13 @@ public final class MagazineLayout: UICollectionViewLayout { at itemIndexPath: IndexPath) -> UICollectionViewLayoutAttributes? { + let attributes = super.initialLayoutAttributesForAppearingItem(at: itemIndexPath) + attributes?.frame = modelState.frameForItem(at: ElementLocation(indexPath: itemIndexPath)) + if modelState.itemIndexPathsToInsert.contains(itemIndexPath) || modelState.sectionIndicesToInsert.contains(itemIndexPath.section) { - let attributes = layoutAttributesForItem(at: itemIndexPath)?.copy() as? UICollectionViewLayoutAttributes attributes.map { delegateMagazineLayout?.collectionView( currentCollectionView, @@ -446,19 +428,22 @@ public final class MagazineLayout: UICollectionViewLayout { byModifying: $0) } - attributes?.frame.origin.y += targetContentOffsetCompensatingYOffsetForAppearingItem ?? 0 + attributes?.transform = CGAffineTransform( + translationX: 0, + y: targetContentOffsetCompensatingYOffsetForAppearingItem ?? 0, + ) itemLayoutAttributesForPendingAnimations[itemIndexPath] = attributes - return attributes } else if let movedItemID = modelState.idForItemModel(at: itemIndexPath), - let initialIndexPath = modelStateBeforeBatchUpdates?.indexPathForItemModel( - withID: movedItemID) + let initialIndexPath = layoutStateBeforeCollectionViewUpdates?.modelState.indexPathForItemModel( + withID: movedItemID), + let frame = layoutStateBeforeCollectionViewUpdates?.modelState.frameForItem(at: ElementLocation(indexPath: initialIndexPath)) { - return previousLayoutAttributesForItem(at: initialIndexPath) - } else { - return super.initialLayoutAttributesForAppearingItem(at: itemIndexPath) + attributes?.frame = frame } + + return attributes } override public func finalLayoutAttributesForDisappearingItem( @@ -479,7 +464,8 @@ public final class MagazineLayout: UICollectionViewLayout { } return attributes } else if - let movedItemID = modelStateBeforeBatchUpdates?.idForItemModel(at: itemIndexPath), + let movedItemID = layoutStateBeforeCollectionViewUpdates?.modelState.idForItemModel( + at: itemIndexPath), let finalIndexPath = modelState.indexPathForItemModel( withID: movedItemID) { @@ -520,7 +506,7 @@ public final class MagazineLayout: UICollectionViewLayout { } else if let movedSectionID = modelState.idForSectionModel( atIndex: elementIndexPath.section), - let initialSectionIndex = modelStateBeforeBatchUpdates?.indexForSectionModel( + let initialSectionIndex = layoutStateBeforeCollectionViewUpdates?.modelState.indexForSectionModel( withID: movedSectionID) { let initialIndexPath = IndexPath(item: 0, section: initialSectionIndex) @@ -560,7 +546,7 @@ public final class MagazineLayout: UICollectionViewLayout { } return attributes } else if - let movedSectionID = modelStateBeforeBatchUpdates?.idForSectionModel( + let movedSectionID = layoutStateBeforeCollectionViewUpdates?.modelState.idForSectionModel( atIndex: elementIndexPath.section), let finalSectionIndex = modelState.indexForSectionModel( withID: movedSectionID) @@ -688,36 +674,59 @@ public final class MagazineLayout: UICollectionViewLayout { withOriginalAttributes originalAttributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutInvalidationContext { - let contentOffsetAdjustment: CGPoint? + let context = super.invalidationContext( + forPreferredLayoutAttributes: preferredAttributes, + withOriginalAttributes: originalAttributes) as! MagazineLayoutInvalidationContext + context.invalidateLayoutMetrics = false + switch preferredAttributes.representedElementCategory { case .cell: - let targetContentOffsetAnchor = targetContentOffsetAnchor ?? currentTargetContentOffsetAnchor - - let targetYOffsetBeforeUpdate: CGFloat? - if let targetContentOffsetAnchor { - targetYOffsetBeforeUpdate = yOffset(for: targetContentOffsetAnchor) - } else { - targetYOffsetBeforeUpdate = nil - } + let targetContentOffsetAnchor = ( + layoutStateBeforeRecreateSectionModels ?? + layoutStateBeforeCollectionViewUpdates ?? + layoutStateBeforeAnimatedBoundsChange ?? + self.layoutState + ).targetContentOffsetAnchor + let targetYOffsetBefore = layoutState.yOffset(for: targetContentOffsetAnchor) modelState.updateItemHeight( toPreferredHeight: preferredAttributes.size.height, forItemAt: preferredAttributes.indexPath) - if let targetYOffsetBeforeUpdate, let targetContentOffsetAnchor { - let targetYOffsetAfterUpdate = yOffset(for: targetContentOffsetAnchor) - contentOffsetAdjustment = CGPoint(x: 0, y: targetYOffsetAfterUpdate - targetYOffsetBeforeUpdate) - } else { - contentOffsetAdjustment = nil + switch targetContentOffsetAnchor { + case .top: + context.contentOffsetAdjustment.y = layoutState.minContentOffset.y - layoutState.bounds.minY + + case .bottom: + context.contentOffsetAdjustment.y = layoutState.maxContentOffset.y - layoutState.bounds.minY + + case .topItem, .bottomItem: + let targetYOffsetAfter = layoutState.yOffset(for: targetContentOffsetAnchor) + context.contentOffsetAdjustment.y = targetYOffsetAfter - targetYOffsetBefore } if let attributes = itemLayoutAttributesForPendingAnimations[preferredAttributes.indexPath] { - attributes.frame = modelState.frameForItem( - at: ElementLocation(indexPath: preferredAttributes.indexPath)) + switch verticalLayoutDirection { + case .topToBottom: + attributes.frame = modelState.frameForItem(at: ElementLocation(indexPath: preferredAttributes.indexPath)) + + case .bottomToTop: + if case .bottom = targetContentOffsetAnchor { + attributes.transform = .identity + attributes.frame = modelState.frameForItem(at: ElementLocation(indexPath: preferredAttributes.indexPath)) + } else { + let previousHeight = attributes.frame.height + attributes.frame = modelState.frameForItem(at: ElementLocation(indexPath: preferredAttributes.indexPath)) + + var targetContentOffsetCompensatingYOffsetForAppearingItem = targetContentOffsetCompensatingYOffsetForAppearingItem ?? 0 + targetContentOffsetCompensatingYOffsetForAppearingItem -= (attributes.frame.height - previousHeight) + self.targetContentOffsetCompensatingYOffsetForAppearingItem = targetContentOffsetCompensatingYOffsetForAppearingItem + attributes.transform = CGAffineTransform(translationX: 0, y: targetContentOffsetCompensatingYOffsetForAppearingItem) + } + } } case .supplementaryView: - contentOffsetAdjustment = nil let layoutAttributesForPendingAnimation = supplementaryViewLayoutAttributesForPendingAnimations[preferredAttributes.indexPath] switch preferredAttributes.representedElementKind { @@ -742,27 +751,12 @@ public final class MagazineLayout: UICollectionViewLayout { } case .decorationView: - contentOffsetAdjustment = nil assertionFailure("`MagazineLayout` does not support decoration views") @unknown default: - contentOffsetAdjustment = nil assertionFailure("`MagazineLayout` does not support this kind of element category") } - let context = super.invalidationContext( - forPreferredLayoutAttributes: preferredAttributes, - withOriginalAttributes: originalAttributes) as! MagazineLayoutInvalidationContext - - if let contentOffsetAdjustment, !isPerformingBatchUpdates { - // If we're in the middle of a batch update, we need to adjust our content offset. Doing it - // here in the middle of a batch update gets ignored for some reason. Instead, we delay - // slightly and do it in `finalizeCollectionViewUpdates`. - context.contentOffsetAdjustment = contentOffsetAdjustment - } - - context.invalidateLayoutMetrics = false - return context } @@ -820,6 +814,8 @@ public final class MagazineLayout: UICollectionViewLayout { backgroundLayoutAttributes.removeAll() } + layoutStateBeforeRecreateSectionModels = nil + super.invalidateLayout(with: context) } @@ -827,11 +823,12 @@ public final class MagazineLayout: UICollectionViewLayout { forProposedContentOffset proposedContentOffset: CGPoint) -> CGPoint { - guard let targetContentOffsetAnchor else { + let layoutStateBefore = layoutStateBeforeCollectionViewUpdates ?? layoutStateBeforeAnimatedBoundsChange + guard let layoutStateBefore else { return super.targetContentOffset(forProposedContentOffset: proposedContentOffset) } - let yOffset = yOffset(for: targetContentOffsetAnchor) + let yOffset = layoutState.yOffset(for: layoutStateBefore.targetContentOffsetAnchor) targetContentOffsetCompensatingYOffsetForAppearingItem = proposedContentOffset.y - yOffset @@ -842,12 +839,17 @@ public final class MagazineLayout: UICollectionViewLayout { private let _flipsHorizontallyInOppositeLayoutDirection: Bool - private lazy var modelState: ModelState = { - return ModelState(currentVisibleBoundsProvider: { [weak self] in - return self?.currentVisibleBounds ?? .zero - }) - }() - private var modelStateBeforeBatchUpdates: ModelState? + private lazy var _layoutState = LayoutState( + modelState: ModelState(currentVisibleBoundsProvider: { [weak self] in + self?.currentVisibleBounds ?? .zero + }), + bounds: currentCollectionView.bounds, + contentInset: contentInset, + scale: scale, + verticalLayoutDirection: verticalLayoutDirection) + private var layoutStateBeforeRecreateSectionModels: LayoutState? + private var layoutStateBeforeCollectionViewUpdates: LayoutState? + private var layoutStateBeforeAnimatedBoundsChange: LayoutState? private var cachedCollectionViewWidth: CGFloat? @@ -890,7 +892,6 @@ public final class MagazineLayout: UICollectionViewLayout { // `layoutAttributesForElementsInRect:` for more details. private var hasDataSourceCountInvalidationBeforeReceivingUpdateItems = false - private var isPerformingAnimatedBoundsChange = false private var targetContentOffsetAnchor: TargetContentOffsetAnchor? private var contentHeightBeforeUpdates: CGFloat? private var previousContentInset: UIEdgeInsets? @@ -939,16 +940,16 @@ public final class MagazineLayout: UICollectionViewLayout { currentCollectionView.adjustedContentInset } - private var currentTargetContentOffsetAnchor: TargetContentOffsetAnchor? { - targetContentOffsetAnchor( - bounds: currentCollectionView.bounds, - contentHeight: currentCollectionView.contentSize.height, - topInset: contentInset.top, - bottomInset: contentInset.bottom) + private var layoutState: LayoutState { + _layoutState.bounds = currentCollectionView.bounds + _layoutState.contentInset = contentInset + _layoutState.scale = scale + _layoutState.verticalLayoutDirection = verticalLayoutDirection + return _layoutState } - private var isPerformingBatchUpdates: Bool { - modelStateBeforeBatchUpdates != nil + private var modelState: ModelState { + layoutState.modelState } private func metricsForSection(atIndex sectionIndex: Int) -> MagazineLayoutSectionMetrics { @@ -1124,7 +1125,7 @@ public final class MagazineLayout: UICollectionViewLayout { { let layoutAttributes = MagazineLayoutCollectionViewLayoutAttributes(forCellWith: indexPath) - guard let modelStateBeforeBatchUpdates else { + guard let layoutStateBeforeCollectionViewUpdates else { // TODO(bryankeller): Look into whether this happens on iOS 10. It definitely does on iOS 9. // Returning `nil` rather than default/frameless layout attributes causes internal exceptions @@ -1133,8 +1134,8 @@ public final class MagazineLayout: UICollectionViewLayout { } guard - indexPath.section < modelStateBeforeBatchUpdates.numberOfSections, - indexPath.item < modelStateBeforeBatchUpdates.numberOfItems( + indexPath.section < layoutStateBeforeCollectionViewUpdates.modelState.numberOfSections, + indexPath.item < layoutStateBeforeCollectionViewUpdates.modelState.numberOfItems( inSectionAtIndex: indexPath.section) else { // On iOS 9, `layoutAttributesForItem(at:)` can be invoked for an index path of a new item @@ -1147,7 +1148,7 @@ public final class MagazineLayout: UICollectionViewLayout { return layoutAttributes } - layoutAttributes.frame = modelStateBeforeBatchUpdates.frameForItem( + layoutAttributes.frame = layoutStateBeforeCollectionViewUpdates.modelState.frameForItem( at: ElementLocation(indexPath: indexPath)) return layoutAttributes @@ -1162,7 +1163,7 @@ public final class MagazineLayout: UICollectionViewLayout { forSupplementaryViewOfKind: elementKind, with: indexPath) - guard let modelStateBeforeBatchUpdates else { + guard let layoutStateBeforeCollectionViewUpdates else { // TODO(bryankeller): Look into whether this happens on iOS 10. It definitely does on iOS 9. // Returning `nil` rather than default/frameless layout attributes causes internal exceptions @@ -1170,7 +1171,7 @@ public final class MagazineLayout: UICollectionViewLayout { return layoutAttributes } - guard indexPath.section < modelStateBeforeBatchUpdates.numberOfSections else { + guard indexPath.section < layoutStateBeforeCollectionViewUpdates.modelState.numberOfSections else { // On iOS 9, `layoutAttributesForItem(at:)` can be invoked for an index path of a new // supplementary view before the layout is notified of this new item (through either `prepare` // or `prepare(forCollectionViewUpdates:)`). This seems to be fixed in iOS 10 and higher. @@ -1183,19 +1184,19 @@ public final class MagazineLayout: UICollectionViewLayout { if elementKind == MagazineLayout.SupplementaryViewKind.sectionHeader, - let headerFrame = modelStateBeforeBatchUpdates.frameForHeader( + let headerFrame = layoutStateBeforeCollectionViewUpdates.modelState.frameForHeader( inSectionAtIndex: indexPath.section) { layoutAttributes.frame = headerFrame } else if elementKind == MagazineLayout.SupplementaryViewKind.sectionFooter, - let footerFrame = modelStateBeforeBatchUpdates.frameForFooter( + let footerFrame = layoutStateBeforeCollectionViewUpdates.modelState.frameForFooter( inSectionAtIndex: indexPath.section) { layoutAttributes.frame = footerFrame } else if elementKind == MagazineLayout.SupplementaryViewKind.sectionBackground, - let backgroundFrame = modelStateBeforeBatchUpdates.frameForBackground( + let backgroundFrame = layoutStateBeforeCollectionViewUpdates.modelState.frameForBackground( inSectionAtIndex: indexPath.section) { layoutAttributes.frame = backgroundFrame @@ -1264,63 +1265,6 @@ public final class MagazineLayout: UICollectionViewLayout { } } - private func targetContentOffsetAnchor( - bounds: CGRect, - contentHeight: CGFloat, - topInset: CGFloat, - bottomInset: CGFloat) - -> 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.last - - guard - let firstVisibleItemLocationFramePair, - let lastVisibleItemLocationFramePair, - let firstVisibleItemID = modelState.idForItemModel( - at: firstVisibleItemLocationFramePair.elementLocation.indexPath), - let lastVisibleItemID = modelState.idForItemModel( - at: lastVisibleItemLocationFramePair.elementLocation.indexPath) - else { - return nil - } - - return TargetContentOffsetAnchor.targetContentOffsetAnchor( - verticalLayoutDirection: verticalLayoutDirection, - topInset: topInset, - bottomInset: bottomInset, - bounds: bounds, - contentHeight: contentHeight, - scale: scale, - firstVisibleItemID: firstVisibleItemID, - lastVisibleItemID: lastVisibleItemID, - firstVisibleItemFrame: firstVisibleItemLocationFramePair.frame, - lastVisibleItemFrame: lastVisibleItemLocationFramePair.frame) - } - - private func yOffset(for targetContentOffsetAnchor: TargetContentOffsetAnchor) -> CGFloat { - targetContentOffsetAnchor.yOffset( - topInset: contentInset.top, - bottomInset: contentInset.bottom, - bounds: currentCollectionView.bounds, - contentHeight: collectionViewContentSize.height, - indexPathForItemID: { modelState.indexPathForItemModel(withID: $0) }, - frameForItemAtIndexPath: { - modelState.frameForItem(at: ElementLocation(indexPath: $0)) - }) - } - } // MARK: Layout Attributes Creation and Caching From b53c73cf91b5d4db5606e18c99c2477cb1a8fea8 Mon Sep 17 00:00:00 2001 From: Bryan Keller Date: Wed, 5 Nov 2025 22:09:22 -0800 Subject: [PATCH 2/3] Update tests --- MagazineLayout.xcodeproj/project.pbxproj | 8 +- MagazineLayout/LayoutCore/LayoutState.swift | 2 +- .../Types/MagazineLayoutSectionMetrics.swift | 2 +- .../LayoutStateTargetContentOffsetTests.swift | 255 ++++++++++++++++++ Tests/ModelStateLayoutTests.swift | 12 +- Tests/ModelStateUpdateTests.swift | 20 +- Tests/TargetContentOffsetAnchorTests.swift | 226 ---------------- 7 files changed, 277 insertions(+), 248 deletions(-) create mode 100644 Tests/LayoutStateTargetContentOffsetTests.swift delete mode 100644 Tests/TargetContentOffsetAnchorTests.swift diff --git a/MagazineLayout.xcodeproj/project.pbxproj b/MagazineLayout.xcodeproj/project.pbxproj index 46eb145..9c606a0 100644 --- a/MagazineLayout.xcodeproj/project.pbxproj +++ b/MagazineLayout.xcodeproj/project.pbxproj @@ -45,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 */ @@ -103,7 +103,7 @@ FD69EA0F21BA01E6001E0650 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; FDABC2552B23CCF700C9B8EF /* MagazineLayoutVerticalLayoutDirection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MagazineLayoutVerticalLayoutDirection.swift; sourceTree = ""; }; FDABC2572B23D0A000C9B8EF /* TargetContentOffsetAnchor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TargetContentOffsetAnchor.swift; sourceTree = ""; }; - FDE08E152B2CC47800C9D24D /* TargetContentOffsetAnchorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TargetContentOffsetAnchorTests.swift; sourceTree = ""; }; + FDE08E152B2CC47800C9D24D /* LayoutStateTargetContentOffsetTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LayoutStateTargetContentOffsetTests.swift; sourceTree = ""; }; FDF6E15A21B0B7870092775D /* MagazineLayoutCollectionViewLayoutAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MagazineLayoutCollectionViewLayoutAttributes.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -152,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 */, @@ -387,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 */, diff --git a/MagazineLayout/LayoutCore/LayoutState.swift b/MagazineLayout/LayoutCore/LayoutState.swift index 36d18c7..69b88f5 100644 --- a/MagazineLayout/LayoutCore/LayoutState.swift +++ b/MagazineLayout/LayoutCore/LayoutState.swift @@ -151,7 +151,7 @@ struct LayoutState { 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.maxY - contentInset.top - distanceFromTop + let proposedYOffset = itemFrame.minY - contentInset.top - distanceFromTop // Clamp between minYOffset...maxYOffset return min(max(proposedYOffset, minContentOffset.y), maxContentOffset.y) diff --git a/MagazineLayout/LayoutCore/Types/MagazineLayoutSectionMetrics.swift b/MagazineLayout/LayoutCore/Types/MagazineLayoutSectionMetrics.swift index aebdf81..92498d3 100644 --- a/MagazineLayout/LayoutCore/Types/MagazineLayoutSectionMetrics.swift +++ b/MagazineLayout/LayoutCore/Types/MagazineLayoutSectionMetrics.swift @@ -53,7 +53,7 @@ struct MagazineLayoutSectionMetrics: Equatable { scale = collectionView.traitCollection.nonZeroDisplayScale } - private init( + init( collectionViewWidth: CGFloat, collectionViewContentInset: UIEdgeInsets, verticalSpacing: CGFloat, diff --git a/Tests/LayoutStateTargetContentOffsetTests.swift b/Tests/LayoutStateTargetContentOffsetTests.swift new file mode 100644 index 0000000..e2cc1bf --- /dev/null +++ b/Tests/LayoutStateTargetContentOffsetTests.swift @@ -0,0 +1,255 @@ +// Created by bryankeller on 12/15/23. +// Copyright © 2023 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 XCTest + +@testable import MagazineLayout + +final class LayoutStateTargetContentOffsetTests: XCTestCase { + + // MARK: Top-to-Bottom Anchor Tests + + func testAnchor_TopToBottom_ScrolledToTop() throws { + let bounds = CGRect(x: 0, y: -50, width: 300, height: 400) + let layoutState = LayoutState( + modelState: modelState(bounds: bounds), + bounds: bounds, + contentInset: UIEdgeInsets(top: 50, left: 0, bottom: 30, right: 0), + scale: 1, + verticalLayoutDirection: .topToBottom) + XCTAssert(layoutState.targetContentOffsetAnchor == .top) + } + + func testAnchor_TopToBottom_ScrolledToMiddle() throws { + let bounds = CGRect(x: 0, y: 500, width: 300, height: 400) + let layoutState = LayoutState( + modelState: modelState(bounds: bounds), + bounds: bounds, + contentInset: UIEdgeInsets(top: 50, left: 0, bottom: 30, right: 0), + scale: 1, + verticalLayoutDirection: .topToBottom) + let id = layoutState.modelState.idForItemModel(at: IndexPath(item: 5, section: 0))! + XCTAssert(layoutState.targetContentOffsetAnchor == .topItem(id: id, distanceFromTop: -160)) + } + + func testAnchor_TopToBottom_ScrolledToBottom() throws { + let measurementBounds = CGRect(x: 0, y: 0, width: 300, height: 400) + let measurementLayoutState = LayoutState( + modelState: modelState(bounds: measurementBounds), + bounds: measurementBounds, + contentInset: UIEdgeInsets(top: 50, left: 0, bottom: 30, right: 0), + scale: 1, + verticalLayoutDirection: .topToBottom) + let maxContentOffset = measurementLayoutState.maxContentOffset + + let bounds = CGRect(origin: maxContentOffset, size: measurementBounds.size) + let layoutState = LayoutState( + modelState: modelState(bounds: bounds), + bounds: bounds, + contentInset: measurementLayoutState.contentInset, + scale: measurementLayoutState.scale, + verticalLayoutDirection: measurementLayoutState.verticalLayoutDirection) + let id = layoutState.modelState.idForItemModel(at: IndexPath(item: 7, section: 0))! + XCTAssert(layoutState.targetContentOffsetAnchor == .topItem(id: id, distanceFromTop: -80)) + } + + // MARK: Bottom-to-Top Anchor Tests + + func testAnchor_BottomToTop_ScrolledToTop() throws { + let bounds = CGRect(x: 0, y: -50, width: 300, height: 400) + let layoutState = LayoutState( + modelState: modelState(bounds: bounds), + bounds: bounds, + contentInset: UIEdgeInsets(top: 50, left: 0, bottom: 30, right: 0), + scale: 1, + verticalLayoutDirection: .bottomToTop) + let id = layoutState.modelState.idForItemModel(at: IndexPath(item: 3, section: 0))! + XCTAssert(layoutState.targetContentOffsetAnchor == .bottomItem(id: id, distanceFromBottom: -90)) + } + + func testAnchor_BottomToTop_ScrolledToMiddle() throws { + let bounds = CGRect(x: 0, y: 500, width: 300, height: 400) + let layoutState = LayoutState( + modelState: modelState(bounds: bounds), + bounds: bounds, + contentInset: UIEdgeInsets(top: 50, left: 0, bottom: 30, right: 0), + scale: 1, + verticalLayoutDirection: .bottomToTop) + let id = layoutState.modelState.idForItemModel(at: IndexPath(item: 12, section: 0))! + XCTAssert(layoutState.targetContentOffsetAnchor == .bottomItem(id: id, distanceFromBottom: 190)) + } + + func testAnchor_BottomToTop_ScrolledToBottom() throws { + let measurementBounds = CGRect(x: 0, y: 0, width: 300, height: 400) + let measurementLayoutState = LayoutState( + modelState: modelState(bounds: measurementBounds), + bounds: measurementBounds, + contentInset: UIEdgeInsets(top: 50, left: 0, bottom: 30, right: 0), + scale: 1, + verticalLayoutDirection: .bottomToTop) + let maxContentOffset = measurementLayoutState.maxContentOffset + + let bounds = CGRect(origin: maxContentOffset, size: measurementBounds.size) + let layoutState = LayoutState( + modelState: modelState(bounds: bounds), + bounds: bounds, + contentInset: measurementLayoutState.contentInset, + scale: measurementLayoutState.scale, + verticalLayoutDirection: measurementLayoutState.verticalLayoutDirection) + XCTAssert(layoutState.targetContentOffsetAnchor == .bottom) + } + + // MARK: Top-to-Bottom Target Content Offset Tests + + func testOffset_TopToBottom_ScrolledToTop() { + let bounds = CGRect(x: 0, y: -50, width: 300, height: 400) + let layoutState = LayoutState( + modelState: modelState(bounds: bounds), + bounds: bounds, + contentInset: UIEdgeInsets(top: 50, left: 0, bottom: 30, right: 0), + scale: 1, + verticalLayoutDirection: .topToBottom) + let targetContentOffsetAnchor = layoutState.targetContentOffsetAnchor + XCTAssert(layoutState.yOffset(for: targetContentOffsetAnchor) == -50) + } + + func testOffset_TopToBottom_ScrolledToMiddle() { + let bounds = CGRect(x: 0, y: 500, width: 300, height: 400) + let layoutState = LayoutState( + modelState: modelState(bounds: bounds), + bounds: bounds, + contentInset: UIEdgeInsets(top: 50, left: 0, bottom: 30, right: 0), + scale: 1, + verticalLayoutDirection: .topToBottom) + let targetContentOffsetAnchor = layoutState.targetContentOffsetAnchor + XCTAssert(layoutState.yOffset(for: targetContentOffsetAnchor) == 500) + } + + func testOffset_TopToBottom_ScrolledToBottom() { + let measurementBounds = CGRect(x: 0, y: 0, width: 300, height: 400) + let measurementLayoutState = LayoutState( + modelState: modelState(bounds: measurementBounds), + bounds: measurementBounds, + contentInset: UIEdgeInsets(top: 50, left: 0, bottom: 30, right: 0), + scale: 1, + verticalLayoutDirection: .topToBottom) + let maxContentOffset = measurementLayoutState.maxContentOffset + + let bounds = CGRect(origin: maxContentOffset, size: measurementBounds.size) + let layoutState = LayoutState( + modelState: modelState(bounds: bounds), + bounds: bounds, + contentInset: measurementLayoutState.contentInset, + scale: measurementLayoutState.scale, + verticalLayoutDirection: measurementLayoutState.verticalLayoutDirection) + let targetContentOffsetAnchor = layoutState.targetContentOffsetAnchor + XCTAssert(layoutState.yOffset(for: targetContentOffsetAnchor) == 690) + } + + // MARK: Bottom-to-Top Target Content Offset Tests + + func testOffset_BottomToTop_ScrolledToTop() { + let bounds = CGRect(x: 0, y: -50, width: 300, height: 400) + let layoutState = LayoutState( + modelState: modelState(bounds: bounds), + bounds: bounds, + contentInset: UIEdgeInsets(top: 50, left: 0, bottom: 30, right: 0), + scale: 1, + verticalLayoutDirection: .bottomToTop) + let targetContentOffsetAnchor = layoutState.targetContentOffsetAnchor + XCTAssert(layoutState.yOffset(for: targetContentOffsetAnchor) == -50) + } + + func testOffset_BottomToTop_ScrolledToMiddle() { + let bounds = CGRect(x: 0, y: 500, width: 300, height: 400) + let layoutState = LayoutState( + modelState: modelState(bounds: bounds), + bounds: bounds, + contentInset: UIEdgeInsets(top: 50, left: 0, bottom: 30, right: 0), + scale: 1, + verticalLayoutDirection: .bottomToTop) + let targetContentOffsetAnchor = layoutState.targetContentOffsetAnchor + XCTAssert(layoutState.yOffset(for: targetContentOffsetAnchor) == 500) + } + + func testOffset_BottomToTop_ScrolledToBottom() { + let measurementBounds = CGRect(x: 0, y: 0, width: 300, height: 400) + let measurementLayoutState = LayoutState( + modelState: modelState(bounds: measurementBounds), + bounds: measurementBounds, + contentInset: UIEdgeInsets(top: 50, left: 0, bottom: 30, right: 0), + scale: 1, + verticalLayoutDirection: .bottomToTop) + let maxContentOffset = measurementLayoutState.maxContentOffset + + let bounds = CGRect(origin: maxContentOffset, size: measurementBounds.size) + let layoutState = LayoutState( + modelState: modelState(bounds: bounds), + bounds: bounds, + contentInset: measurementLayoutState.contentInset, + scale: measurementLayoutState.scale, + verticalLayoutDirection: measurementLayoutState.verticalLayoutDirection) + let targetContentOffsetAnchor = layoutState.targetContentOffsetAnchor + XCTAssert(layoutState.yOffset(for: targetContentOffsetAnchor) == 690) + } + + // MARK: Private + + private func modelState(bounds: CGRect) -> ModelState { + let modelState = ModelState(currentVisibleBoundsProvider: { bounds }) + let sections = [ + SectionModel( + 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), + ], + headerModel: nil, + footerModel: nil, + backgroundModel: nil, + metrics: MagazineLayoutSectionMetrics( + collectionViewWidth: bounds.width, + collectionViewContentInset: .zero, + verticalSpacing: 0, + horizontalSpacing: 0, + sectionInsets: .zero, + itemInsets: .zero, + scale: 1)) + ] + modelState.setSections(sections) + return modelState + } + +} + +// MARK: - ItemModel + +private extension ItemModel { + init(widthMode: MagazineLayoutItemWidthMode, preferredHeight: CGFloat?) { + self.init(sizeMode: .init(widthMode: widthMode, heightMode: .dynamic), height: 150) + self.preferredHeight = preferredHeight + } +} diff --git a/Tests/ModelStateLayoutTests.swift b/Tests/ModelStateLayoutTests.swift index 965f202..ecdcd53 100644 --- a/Tests/ModelStateLayoutTests.swift +++ b/Tests/ModelStateLayoutTests.swift @@ -353,7 +353,7 @@ final class ModelStateLayoutTests: XCTestCase { heightMode: .static(height: 10)), height: 10)), ], - modelStateBeforeBatchUpdates: modelState.copyForBatchUpdates()) + modelStateBeforeBatchUpdates: modelState.copy()) let expectedItemFrames0: [CGRect] = [ CGRect(x: 25.0, y: 90.0, width: 280.0, height: 20.0), @@ -438,7 +438,7 @@ final class ModelStateLayoutTests: XCTestCase { heightMode: .static(height: 20)), height: 20)), ], - modelStateBeforeBatchUpdates: modelState.copyForBatchUpdates()) + modelStateBeforeBatchUpdates: modelState.copy()) let expectedItemFrames2: [CGRect] = [ CGRect(x: 125.0, y: 380.0, width: 80.0, height: 50.0), @@ -507,7 +507,7 @@ final class ModelStateLayoutTests: XCTestCase { modelState.applyUpdates([ .itemDelete(itemIndexPath: IndexPath(item: 5, section: 0)), ], - modelStateBeforeBatchUpdates: modelState.copyForBatchUpdates()) + modelStateBeforeBatchUpdates: modelState.copy()) let expectedItemFrames0: [CGRect] = [ CGRect(x: 15.0, y: 140.0, width: 300.0, height: 150.0), @@ -573,7 +573,7 @@ final class ModelStateLayoutTests: XCTestCase { .itemDelete(itemIndexPath: IndexPath(item: 0, section: 1)), .itemDelete(itemIndexPath: IndexPath(item: 5, section: 0)), ], - modelStateBeforeBatchUpdates: modelState.copyForBatchUpdates()) + modelStateBeforeBatchUpdates: modelState.copy()) let expectedItemFrames2: [CGRect] = [ CGRect(x: 25.0, y: 200.0, width: 130.0, height: 150.0), @@ -638,7 +638,7 @@ final class ModelStateLayoutTests: XCTestCase { initialItemIndexPath: IndexPath(item: 0, section: 1), finalItemIndexPath: IndexPath(item: 5, section: 0)), ], - modelStateBeforeBatchUpdates: modelState.copyForBatchUpdates()) + modelStateBeforeBatchUpdates: modelState.copy()) let expectedItemFrames0: [CGRect] = [ CGRect(x: 25.0, y: 380.0, width: 130.0, height: 150.0), @@ -713,7 +713,7 @@ final class ModelStateLayoutTests: XCTestCase { initialItemIndexPath: IndexPath(item: 2, section: 1), finalItemIndexPath: IndexPath(item: 0, section: 1)), ], - modelStateBeforeBatchUpdates: modelState.copyForBatchUpdates()) + modelStateBeforeBatchUpdates: modelState.copy()) let expectedItemFrames2: [CGRect] = [ CGRect(x: 25.0, y: 490.0, width: 80.0, height: 150.0), diff --git a/Tests/ModelStateUpdateTests.swift b/Tests/ModelStateUpdateTests.swift index 426c8f7..26e0d65 100644 --- a/Tests/ModelStateUpdateTests.swift +++ b/Tests/ModelStateUpdateTests.swift @@ -37,7 +37,7 @@ final class ModelStateUpdateTests: XCTestCase { modelState.applyUpdates([ .sectionInsert(sectionIndex: 0, newSection: sectionToInsert) ], - modelStateBeforeBatchUpdates: modelState.copyForBatchUpdates()) + modelStateBeforeBatchUpdates: modelState.copy()) XCTAssert( !modelState.sectionIndicesToInsert.isEmpty, @@ -59,7 +59,7 @@ final class ModelStateUpdateTests: XCTestCase { numberOfSections: 3, numberOfItemsPerSection: 1).first! - let modelStateBeforeBatchUpdates = modelState.copyForBatchUpdates() + let modelStateBeforeBatchUpdates = modelState.copy() modelState.applyUpdates([ .sectionReload(sectionIndex: 0, newSection: replacementSection) ], @@ -82,7 +82,7 @@ final class ModelStateUpdateTests: XCTestCase { let replacementItem = ModelHelpers.basicItemModel() let indexPath = IndexPath(item: 0, section: 0) - let modelStateBeforeBatchUpdates = modelState.copyForBatchUpdates() + let modelStateBeforeBatchUpdates = modelState.copy() modelState.applyUpdates([ .itemReload(itemIndexPath: indexPath, newItem: replacementItem) ], @@ -101,7 +101,7 @@ final class ModelStateUpdateTests: XCTestCase { numberOfSections: 3, numberOfItemsPerSection: 0) - let modelStateBeforeBatchUpdates = modelState.copyForBatchUpdates() + let modelStateBeforeBatchUpdates = modelState.copy() modelState.applyUpdates([ .sectionInsert(sectionIndex: 2, newSection: sectionsToInsert[2]), .sectionInsert(sectionIndex: 1, newSection: sectionsToInsert[1]), @@ -132,7 +132,7 @@ final class ModelStateUpdateTests: XCTestCase { ModelHelpers.basicItemModel(), ] - let modelStateBeforeBatchUpdates = modelState.copyForBatchUpdates() + let modelStateBeforeBatchUpdates = modelState.copy() modelState.applyUpdates([ .itemInsert(itemIndexPath: IndexPath(item: 2, section: 0), newItem: itemsToInsert[2]), .itemInsert(itemIndexPath: IndexPath(item: 0, section: 0), newItem: itemsToInsert[0]), @@ -157,7 +157,7 @@ final class ModelStateUpdateTests: XCTestCase { numberOfItemsPerSection: 0) modelState.setSections(initialSections) - let modelStateBeforeBatchUpdates = modelState.copyForBatchUpdates() + let modelStateBeforeBatchUpdates = modelState.copy() modelState.applyUpdates([ .sectionDelete(sectionIndex: 2), .sectionDelete(sectionIndex: 0), @@ -182,7 +182,7 @@ final class ModelStateUpdateTests: XCTestCase { numberOfItemsPerSection: 3) modelState.setSections(initialSections) - let modelStateBeforeBatchUpdates = modelState.copyForBatchUpdates() + let modelStateBeforeBatchUpdates = modelState.copy() modelState.applyUpdates([ .itemDelete(itemIndexPath: IndexPath(item: 2, section: 0)), .itemDelete(itemIndexPath: IndexPath(item: 0, section: 0)), @@ -207,7 +207,7 @@ final class ModelStateUpdateTests: XCTestCase { numberOfItemsPerSection: 2) modelState.setSections(initialSections) - let modelStateBeforeBatchUpdates = modelState.copyForBatchUpdates() + let modelStateBeforeBatchUpdates = modelState.copy() modelState.applyUpdates([ .sectionMove(initialSectionIndex: 0, finalSectionIndex: 1), .itemMove(initialItemIndexPath: .init(item: 0, section: 0), finalItemIndexPath: .init(item: 0, section: 1)), @@ -259,7 +259,7 @@ final class ModelStateUpdateTests: XCTestCase { numberOfItemsPerSection: 2) modelState.setSections(initialSections) - let modelStateBeforeBatchUpdates = modelState.copyForBatchUpdates() + let modelStateBeforeBatchUpdates = modelState.copy() modelState.applyUpdates([ .itemMove( initialItemIndexPath: IndexPath(item: 0, section: 0), @@ -338,7 +338,7 @@ final class ModelStateUpdateTests: XCTestCase { initialItemIndexPath: IndexPath(item: 0, section: 4), finalItemIndexPath: IndexPath(item: 0, section: 1)), ], - modelStateBeforeBatchUpdates: modelState.copyForBatchUpdates()) + modelStateBeforeBatchUpdates: modelState.copy()) XCTAssert(true) } diff --git a/Tests/TargetContentOffsetAnchorTests.swift b/Tests/TargetContentOffsetAnchorTests.swift deleted file mode 100644 index 37da952..0000000 --- a/Tests/TargetContentOffsetAnchorTests.swift +++ /dev/null @@ -1,226 +0,0 @@ -// Created by bryankeller on 12/15/23. -// Copyright © 2023 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 XCTest - -@testable import MagazineLayout - -final class TargetContentOffsetAnchorTests: XCTestCase { - - // MARK: To-to-Bottom Anchor Tests - - func testAnchor_TopToBottom_ScrolledToTop() throws { - let anchor = TargetContentOffsetAnchor.targetContentOffsetAnchor( - verticalLayoutDirection: .topToBottom, - topInset: 50, - bottomInset: 30, - bounds: CGRect(x: 0, y: -50, width: 300, height: 400), - contentHeight: 2000, - scale: 1, - firstVisibleItemID: UUID(), - lastVisibleItemID: UUID(), - firstVisibleItemFrame: CGRect(x: 0, y: 0, width: 300, height: 20), - lastVisibleItemFrame: CGRect(x: 0, y: 290, width: 300, height: 20)) - XCTAssert(anchor == .top) - } - - func testAnchor_TopToBottom_ScrolledToMiddle() throws { - let topID = UUID() - let anchor = TargetContentOffsetAnchor.targetContentOffsetAnchor( - verticalLayoutDirection: .topToBottom, - topInset: 50, - bottomInset: 30, - bounds: CGRect(x: 0, y: 500, width: 300, height: 400), - contentHeight: 2000, - scale: 1, - firstVisibleItemID: topID, - lastVisibleItemID: UUID(), - firstVisibleItemFrame: CGRect(x: 0, y: 560, width: 300, height: 20), - lastVisibleItemFrame: CGRect(x: 0, y: 800, width: 300, height: 20)) - XCTAssert(anchor == .topItem(id: topID, distanceFromTop: 10)) - } - - func testAnchor_TopToBottom_ScrolledToBottom() throws { - let topID = UUID() - let anchor = TargetContentOffsetAnchor.targetContentOffsetAnchor( - verticalLayoutDirection: .topToBottom, - topInset: 50, - bottomInset: 30, - bounds: CGRect(x: 0, y: 1630, width: 300, height: 400), - contentHeight: 2000, - scale: 1, - firstVisibleItemID: topID, - lastVisibleItemID: UUID(), - firstVisibleItemFrame: CGRect(x: 0, y: 1700, width: 300, height: 20), - lastVisibleItemFrame: CGRect(x: 0, y: 1950, width: 300, height: 20)) - XCTAssert(anchor == .topItem(id: topID, distanceFromTop: 20)) - } - - func testAnchor_TopToBottom_SmallContentHeight() throws { - let anchor = TargetContentOffsetAnchor.targetContentOffsetAnchor( - verticalLayoutDirection: .topToBottom, - topInset: 50, - bottomInset: 30, - bounds: CGRect(x: 0, y: -50, width: 300, height: 400), - contentHeight: 50, - scale: 1, - firstVisibleItemID: UUID(), - lastVisibleItemID: UUID(), - firstVisibleItemFrame: CGRect(x: 0, y: 0, width: 300, height: 20), - lastVisibleItemFrame: CGRect(x: 0, y: 30, width: 300, height: 20)) - XCTAssert(anchor == .top) - } - - // MARK: Bottom-to-Top Anchor Tests - - func testAnchor_BottomToTop_ScrolledToTop() throws { - let bottomID = UUID() - let anchor = TargetContentOffsetAnchor.targetContentOffsetAnchor( - verticalLayoutDirection: .bottomToTop, - topInset: 50, - bottomInset: 30, - bounds: CGRect(x: 0, y: -50, width: 300, height: 400), - contentHeight: 2000, - scale: 1, - firstVisibleItemID: UUID(), - lastVisibleItemID: bottomID, - firstVisibleItemFrame: CGRect(x: 0, y: 0, width: 300, height: 20), - lastVisibleItemFrame: CGRect(x: 0, y: 290, width: 300, height: 20)) - XCTAssert(anchor == .bottomItem(id: bottomID, distanceFromBottom: -10)) - } - - func testAnchor_BottomToTop_ScrolledToMiddle() throws { - let bottomID = UUID() - let anchor = TargetContentOffsetAnchor.targetContentOffsetAnchor( - verticalLayoutDirection: .bottomToTop, - topInset: 50, - bottomInset: 30, - bounds: CGRect(x: 0, y: 500, width: 300, height: 400), - contentHeight: 2000, - scale: 1, - firstVisibleItemID: UUID(), - lastVisibleItemID: bottomID, - firstVisibleItemFrame: CGRect(x: 0, y: 560, width: 300, height: 20), - lastVisibleItemFrame: CGRect(x: 0, y: 800, width: 300, height: 20)) - XCTAssert(anchor == .bottomItem(id: bottomID, distanceFromBottom: -50)) - } - - func testAnchor_BottomToTop_ScrolledToBottom() throws { - let anchor = TargetContentOffsetAnchor.targetContentOffsetAnchor( - verticalLayoutDirection: .bottomToTop, - topInset: 50, - bottomInset: 30, - bounds: CGRect(x: 0, y: 1630, width: 300, height: 400), - contentHeight: 2000, - scale: 1, - firstVisibleItemID: UUID(), - lastVisibleItemID: UUID(), - firstVisibleItemFrame: CGRect(x: 0, y: 1700, width: 300, height: 20), - lastVisibleItemFrame: CGRect(x: 0, y: 1950, width: 300, height: 20)) - XCTAssert(anchor == .bottom) - } - - func testAnchor_BottomToTop_SmallContentHeight() throws { - let anchor = TargetContentOffsetAnchor.targetContentOffsetAnchor( - verticalLayoutDirection: .bottomToTop, - topInset: 50, - bottomInset: 30, - bounds: CGRect(x: 0, y: -50, width: 300, height: 400), - contentHeight: 50, - scale: 1, - firstVisibleItemID: UUID(), - lastVisibleItemID: UUID(), - firstVisibleItemFrame: CGRect(x: 0, y: 0, width: 300, height: 20), - lastVisibleItemFrame: CGRect(x: 0, y: 30, width: 300, height: 20)) - XCTAssert(anchor == .bottom) - } - - // MARK: Top-to-Bottom Target Content Offset Tests - - func testOffset_TopToBottom_ScrolledToTop() { - let anchor = TargetContentOffsetAnchor.top - let offset = anchor.yOffset( - topInset: 50, - bottomInset: 30, - bounds: CGRect(x: 0, y: -50, width: 300, height: 400), - contentHeight: 2000, - indexPathForItemID: { _ in IndexPath(item: 0, section: 0) }, - frameForItemAtIndexPath: { _ in CGRect(x: 0, y: 0, width: 300, height: 20) }) - XCTAssert(offset == -50) - } - - func testOffset_TopToBottom_ScrolledToMiddle() { - let anchor = TargetContentOffsetAnchor.topItem(id: UUID(), distanceFromTop: 10) - let offset = anchor.yOffset( - topInset: 50, - bottomInset: 30, - bounds: CGRect(x: 0, y: 500, width: 300, height: 400), - contentHeight: 2000, - indexPathForItemID: { _ in IndexPath(item: 2, section: 0) }, - frameForItemAtIndexPath: { _ in CGRect(x: 0, y: 560, width: 300, height: 20) }) - XCTAssert(offset == 500) - } - - func testOffset_TopToBottom_ScrolledToBottom() { - let anchor = TargetContentOffsetAnchor.topItem(id: UUID(), distanceFromTop: 10) - let offset = anchor.yOffset( - topInset: 50, - bottomInset: 30, - bounds: CGRect(x: 0, y: 1630, width: 300, height: 400), - contentHeight: 2000, - indexPathForItemID: { _ in IndexPath(item: 6, section: 0) }, - frameForItemAtIndexPath: { _ in CGRect(x: 0, y: 1700, width: 300, height: 20) }) - XCTAssert(offset == 1630) - } - - // MARK: Bottom-to-Top Target Content Offset Tests - - func testOffset_BottomToTop_ScrolledToTop() { - let anchor = TargetContentOffsetAnchor.bottomItem(id: UUID(), distanceFromBottom: -10) - let offset = anchor.yOffset( - topInset: 50, - bottomInset: 30, - bounds: CGRect(x: 0, y: -50, width: 300, height: 400), - contentHeight: 2000, - indexPathForItemID: { _ in IndexPath(item: 4, section: 0) }, - frameForItemAtIndexPath: { _ in CGRect(x: 0, y: 290, width: 300, height: 20) }) - XCTAssert(offset == -50) - } - - func testOffset_BottomToTop_ScrolledToMiddle() { - let anchor = TargetContentOffsetAnchor.bottomItem(id: UUID(), distanceFromBottom: -50) - let offset = anchor.yOffset( - topInset: 50, - bottomInset: 30, - bounds: CGRect(x: 0, y: 500, width: 300, height: 400), - contentHeight: 2000, - indexPathForItemID: { _ in IndexPath(item: 6, section: 0) }, - frameForItemAtIndexPath: { _ in CGRect(x: 0, y: 800, width: 300, height: 20) }) - XCTAssert(offset == 500) - } - - func testOffset_BottomToTop_ScrolledToBottom() { - let anchor = TargetContentOffsetAnchor.bottom - let offset = anchor.yOffset( - topInset: 50, - bottomInset: 30, - bounds: CGRect(x: 0, y: 1630, width: 300, height: 400), - contentHeight: 2000, - indexPathForItemID: { _ in IndexPath(item: 10, section: 0) }, - frameForItemAtIndexPath: { _ in CGRect(x: 0, y: 1950, width: 300, height: 20) }) - XCTAssert(offset == 1630) - } - -} From 83d20643c57c1226be2a00b37e8d9cdab0523b4d Mon Sep 17 00:00:00 2001 From: Bryan Keller Date: Thu, 6 Nov 2025 11:40:16 -0800 Subject: [PATCH 3/3] Cleanup unused proeprties --- MagazineLayout/Public/MagazineLayout.swift | 22 +--------------------- 1 file changed, 1 insertion(+), 21 deletions(-) diff --git a/MagazineLayout/Public/MagazineLayout.swift b/MagazineLayout/Public/MagazineLayout.swift index 6fdbc42..81a1f26 100755 --- a/MagazineLayout/Public/MagazineLayout.swift +++ b/MagazineLayout/Public/MagazineLayout.swift @@ -128,14 +128,6 @@ public final class MagazineLayout: UICollectionViewLayout { modelState.setSections(sections) } - if - prepareActions.contains(.recreateSectionModels) || - prepareActions.contains(.updateLayoutMetrics) - { - lastSizedElementMinY = nil - lastSizedElementPreferredHeight = nil - } - prepareActions = [] } @@ -224,9 +216,6 @@ public final class MagazineLayout: UICollectionViewLayout { modelStateBeforeBatchUpdates: layoutStateBeforeCollectionViewUpdates.modelState) hasDataSourceCountInvalidationBeforeReceivingUpdateItems = false - lastSizedElementMinY = nil - lastSizedElementPreferredHeight = nil - super.prepare(forCollectionViewUpdates: updateItems) } @@ -851,14 +840,6 @@ public final class MagazineLayout: UICollectionViewLayout { private var layoutStateBeforeCollectionViewUpdates: LayoutState? private var layoutStateBeforeAnimatedBoundsChange: LayoutState? - private var cachedCollectionViewWidth: CGFloat? - - // These properties are used to prevent scroll jumpiness due to self-sizing after rotation; see - // comment in `invalidationContext(forPreferredLayoutAttributes:withOriginalAttributes:)` for more - // details. - private var lastSizedElementMinY: CGFloat? - private var lastSizedElementPreferredHeight: CGFloat? - private var hasPinnedHeaderOrFooter: Bool = false // Cached layout attributes; lazily populated using information from the model state. @@ -892,8 +873,7 @@ public final class MagazineLayout: UICollectionViewLayout { // `layoutAttributesForElementsInRect:` for more details. private var hasDataSourceCountInvalidationBeforeReceivingUpdateItems = false - private var targetContentOffsetAnchor: TargetContentOffsetAnchor? - private var contentHeightBeforeUpdates: CGFloat? + private var cachedCollectionViewWidth: CGFloat? private var previousContentInset: UIEdgeInsets? private var currentCollectionView: UICollectionView {