-
Notifications
You must be signed in to change notification settings - Fork 222
Bk/add layout state #148
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Bk/add layout state #148
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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) | ||
| } | ||
|
|
||
| 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
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
| } | ||
|
|
||
|
|
@@ -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]() | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -53,7 +53,7 @@ struct MagazineLayoutSectionMetrics: Equatable { | |
| scale = collectionView.traitCollection.nonZeroDisplayScale | ||
| } | ||
|
|
||
| private init( | ||
| init( | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. made internal for unit test support |
||
| collectionViewWidth: CGFloat, | ||
| collectionViewContentInset: UIEdgeInsets, | ||
| verticalSpacing: CGFloat, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. All of the static functions below for calculating the |
||
| /// 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 | ||
| } | ||
There was a problem hiding this comment.
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