From b6ea277aba59c82a9d1b0901d46775151953d231 Mon Sep 17 00:00:00 2001 From: Bryan Keller Date: Thu, 12 Feb 2026 14:10:55 -0800 Subject: [PATCH 1/2] Fix the contentOffsetAdjustment when overscrolling --- MagazineLayout/LayoutCore/LayoutState.swift | 16 ++++---- .../Types/TargetContentOffsetAnchor.swift | 4 +- MagazineLayout/Public/MagazineLayout.swift | 8 ++-- .../LayoutStateTargetContentOffsetTests.swift | 41 ++++++++++++++++++- 4 files changed, 53 insertions(+), 16 deletions(-) diff --git a/MagazineLayout/LayoutCore/LayoutState.swift b/MagazineLayout/LayoutCore/LayoutState.swift index 11ba80d..d9cd854 100644 --- a/MagazineLayout/LayoutCore/LayoutState.swift +++ b/MagazineLayout/LayoutCore/LayoutState.swift @@ -89,8 +89,8 @@ struct LayoutState { at: lastVisibleItemLocationFramePair.elementLocation.indexPath) else { switch verticalLayoutDirection { - case .topToBottom: return .top - case .bottomToTop: return .bottom + case .topToBottom: return .top(overScrollDistance: 0) + case .bottomToTop: return .bottom(overScrollDistance: 0) } } @@ -118,7 +118,7 @@ struct LayoutState { case .topToBottom: switch position { case .atTop: - return .top + return .top(overScrollDistance: top - bounds.minY) case .inMiddle, .atBottom: let top = bounds.minY + contentInset.top let distanceFromTop = firstVisibleItemLocationFramePair.frame.minY - top @@ -137,7 +137,7 @@ struct LayoutState { elementLocation: lastVisibleItemLocationFramePair.elementLocation, distanceFromBottom: distanceFromBottom.alignedToPixel(forScreenWithScale: scale)) case .atBottom: - return .bottom + return .bottom(overScrollDistance: bounds.minY - bottom) } } } @@ -148,11 +148,11 @@ struct LayoutState { -> CGFloat { switch targetContentOffsetAnchor { - case .top: - return minContentOffset.y + case .top(let overScrollDistance): + return minContentOffset.y - overScrollDistance - case .bottom: - return maxContentOffset.y + case .bottom(let overScrollDistance): + return maxContentOffset.y + overScrollDistance case .topItem(let id, let _elementLocation, let distanceFromTop): let elementLocation = diff --git a/MagazineLayout/LayoutCore/Types/TargetContentOffsetAnchor.swift b/MagazineLayout/LayoutCore/Types/TargetContentOffsetAnchor.swift index a868bab..da2bc3c 100644 --- a/MagazineLayout/LayoutCore/Types/TargetContentOffsetAnchor.swift +++ b/MagazineLayout/LayoutCore/Types/TargetContentOffsetAnchor.swift @@ -19,8 +19,8 @@ import UIKit /// Anchors representing how the collection view prioritizes keeping certain items visible in target content offset calculations. enum TargetContentOffsetAnchor: Equatable { - case top - case bottom + case top(overScrollDistance: CGFloat) + case bottom(overScrollDistance: CGFloat) case topItem(id: UInt64, elementLocation: ElementLocation, distanceFromTop: CGFloat) case bottomItem(id: UInt64, elementLocation: ElementLocation, distanceFromBottom: CGFloat) } diff --git a/MagazineLayout/Public/MagazineLayout.swift b/MagazineLayout/Public/MagazineLayout.swift index 9713781..60175dd 100755 --- a/MagazineLayout/Public/MagazineLayout.swift +++ b/MagazineLayout/Public/MagazineLayout.swift @@ -687,11 +687,11 @@ public final class MagazineLayout: UICollectionViewLayout { forItemAt: preferredAttributes.indexPath) switch targetContentOffsetAnchor { - case .top: - context.contentOffsetAdjustment.y = layoutState.minContentOffset.y - layoutState.bounds.minY + case .top(let overScrollDistance): + context.contentOffsetAdjustment.y = layoutState.minContentOffset.y - overScrollDistance - layoutState.bounds.minY - case .bottom: - context.contentOffsetAdjustment.y = layoutState.maxContentOffset.y - layoutState.bounds.minY + case .bottom(let overScrollDistance): + context.contentOffsetAdjustment.y = layoutState.maxContentOffset.y + overScrollDistance - layoutState.bounds.minY case .topItem, .bottomItem: let targetYOffsetAfter = layoutState.yOffset( diff --git a/Tests/LayoutStateTargetContentOffsetTests.swift b/Tests/LayoutStateTargetContentOffsetTests.swift index 58e4686..e82ae20 100644 --- a/Tests/LayoutStateTargetContentOffsetTests.swift +++ b/Tests/LayoutStateTargetContentOffsetTests.swift @@ -29,7 +29,7 @@ final class LayoutStateTargetContentOffsetTests: XCTestCase { contentInset: UIEdgeInsets(top: 50, left: 0, bottom: 30, right: 0), scale: 1, verticalLayoutDirection: .topToBottom) - XCTAssert(layoutState.targetContentOffsetAnchor == .top) + XCTAssert(layoutState.targetContentOffsetAnchor == .top(overScrollDistance: 0)) } func testAnchor_TopToBottom_ScrolledToMiddle() throws { @@ -130,7 +130,7 @@ final class LayoutStateTargetContentOffsetTests: XCTestCase { contentInset: measurementLayoutState.contentInset, scale: measurementLayoutState.scale, verticalLayoutDirection: measurementLayoutState.verticalLayoutDirection) - XCTAssert(layoutState.targetContentOffsetAnchor == .bottom) + XCTAssert(layoutState.targetContentOffsetAnchor == .bottom(overScrollDistance: 0)) } // MARK: Top-to-Bottom Target Content Offset Tests @@ -180,6 +180,20 @@ final class LayoutStateTargetContentOffsetTests: XCTestCase { XCTAssert(layoutState.yOffset(for: targetContentOffsetAnchor, isPerformingBatchUpdates: false) == 690) } + func testOffset_TopToBottom_OverscrolledPastTop() { + // bounds.minY = -80 is 30px past minContentOffset.y (-50), simulating rubber-banding + let bounds = CGRect(x: 0, y: -80, 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(targetContentOffsetAnchor == .top(overScrollDistance: 30)) + XCTAssert(layoutState.yOffset(for: targetContentOffsetAnchor, isPerformingBatchUpdates: false) == -80) + } + // MARK: Bottom-to-Top Target Content Offset Tests func testOffset_BottomToTop_ScrolledToTop() { @@ -227,6 +241,29 @@ final class LayoutStateTargetContentOffsetTests: XCTestCase { XCTAssert(layoutState.yOffset(for: targetContentOffsetAnchor, isPerformingBatchUpdates: false) == 690) } + func testOffset_BottomToTop_OverscrolledPastBottom() { + 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 + + // 25px past maxContentOffset, simulating rubber-banding + let bounds = CGRect(x: 0, y: maxContentOffset.y + 25, width: 300, height: 400) + let layoutState = LayoutState( + modelState: modelState(bounds: bounds), + bounds: bounds, + contentInset: measurementLayoutState.contentInset, + scale: measurementLayoutState.scale, + verticalLayoutDirection: measurementLayoutState.verticalLayoutDirection) + let targetContentOffsetAnchor = layoutState.targetContentOffsetAnchor + XCTAssert(targetContentOffsetAnchor == .bottom(overScrollDistance: 25)) + XCTAssert(layoutState.yOffset(for: targetContentOffsetAnchor, isPerformingBatchUpdates: false) == maxContentOffset.y + 25) + } + // MARK: Private private let idGenerator = IDGenerator() From 41968f864253f4d19a34d1735f94f05e0ae89133 Mon Sep 17 00:00:00 2001 From: Bryan Keller Date: Thu, 12 Feb 2026 15:55:31 -0800 Subject: [PATCH 2/2] Fix CI --- .github/workflows/swift.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index 58c9e70..d69fa3a 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -18,4 +18,4 @@ jobs: - name: Build run: xcodebuild clean build -scheme MagazineLayout -destination "generic/platform=iOS Simulator" - name: Run tests - run: xcodebuild clean test -project MagazineLayout.xcodeproj -scheme MagazineLayout -destination "name=iPhone 16,OS=18.4" + run: xcodebuild clean test -project MagazineLayout.xcodeproj -scheme MagazineLayout -destination "name=iPhone 17,OS=26.2"