Skip to content
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/target content offset improvements #116

Merged
merged 3 commits into from
Feb 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions MagazineLayout/LayoutCore/Types/ElementLocation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,14 @@ struct ElementLocation: Hashable {
}

}

// MARK: Comparable

extension ElementLocation: Comparable {

static func < (lhs: ElementLocation, rhs: ElementLocation) -> Bool {
lhs.sectionIndex < rhs.sectionIndex
|| (lhs.sectionIndex == rhs.sectionIndex && lhs.elementIndex < rhs.elementIndex)
}

}
73 changes: 61 additions & 12 deletions MagazineLayout/LayoutCore/Types/TargetContentOffsetAnchor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,25 +22,28 @@ import UIKit
enum TargetContentOffsetAnchor: Equatable {
case top
case bottom
case topItem(id: String, distanceFromTop: CGFloat)
case bottomItem(id: String, distanceFromBottom: CGFloat)
case topItem(id: String, itemEdge: ItemEdge, distanceFromTop: CGFloat)
case bottomItem(id: String, itemEdge: ItemEdge, distanceFromBottom: CGFloat)
Comment on lines +25 to +26
Copy link
Contributor Author

Choose a reason for hiding this comment

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

this change allows us to preserve the scroll position more accurately when cells change size on rotation (their height might grow / shrink depending on the width after rotation)


static func targetContentOffsetAnchor(
verticalLayoutDirection: MagazineLayoutVerticalLayoutDirection,
topInset: CGFloat,
bottomInset: CGFloat,
bounds: CGRect,
contentHeight: CGFloat,
scale: CGFloat,
firstVisibleItemID: String,
lastVisibleItemID: String,
firstVisibleItemFrame: CGRect,
lastVisibleItemFrame: CGRect)
-> Self
{
let top = (-topInset).alignedToPixel(forScreenWithScale: scale)
let bottom = (contentHeight + bottomInset - bounds.height).alignedToPixel(forScreenWithScale: scale)
let position: Position
if bounds.minY <= -topInset {
if bounds.minY <= top {
position = .atTop
} else if bounds.minY >= contentHeight + bottomInset - bounds.height {
} else if bounds.minY >= bottom {
position = .atBottom
} else {
position = .inMiddle
Expand All @@ -52,14 +55,38 @@ enum TargetContentOffsetAnchor: Equatable {
case .atTop:
return .top
case .inMiddle, .atBottom:
let distanceFromTop = firstVisibleItemFrame.minY - (bounds.minY + topInset)
return .topItem(id: firstVisibleItemID, distanceFromTop: distanceFromTop)
let top = bounds.minY + topInset
let topDistanceFromTop = firstVisibleItemFrame.value(for: .top) - top
let bottomDistanceFromTop = firstVisibleItemFrame.value(for: .bottom) - top
if abs(topDistanceFromTop) < abs(bottomDistanceFromTop) {
return .topItem(
id: firstVisibleItemID,
itemEdge: .top,
distanceFromTop: topDistanceFromTop.alignedToPixel(forScreenWithScale: scale))
} else {
return .topItem(
id: firstVisibleItemID,
itemEdge: .bottom,
distanceFromTop: bottomDistanceFromTop.alignedToPixel(forScreenWithScale: scale))
}
}
case .bottomToTop:
switch position {
case .atTop, .inMiddle:
let distanceFromBottom = lastVisibleItemFrame.maxY - (bounds.maxY - bottomInset)
return .bottomItem(id: lastVisibleItemID, distanceFromBottom: distanceFromBottom)
let bottom = bounds.maxY - bottomInset
let topDistanceFromBottom = lastVisibleItemFrame.value(for: .top) - bottom
let bottomDistanceFromBottom = lastVisibleItemFrame.value(for: .bottom) - bottom
if abs(topDistanceFromBottom) < abs(bottomDistanceFromBottom) {
return .bottomItem(
id: lastVisibleItemID,
itemEdge: .top,
distanceFromBottom: topDistanceFromBottom.alignedToPixel(forScreenWithScale: scale))
} else {
return .bottomItem(
id: lastVisibleItemID,
itemEdge: .bottom,
distanceFromBottom: bottomDistanceFromBottom.alignedToPixel(forScreenWithScale: scale))
}
case .atBottom:
return .bottom
}
Expand All @@ -82,15 +109,37 @@ enum TargetContentOffsetAnchor: Equatable {
case .bottom:
return contentHeight - bounds.height + bottomInset

case .topItem(let id, let distanceFromTop):
case .topItem(let id, let itemEdge, let distanceFromTop):
guard let indexPath = indexPathForItemID(id) else { return bounds.minY }
let itemFrame = frameForItemAtIndexPath(indexPath)
return itemFrame.minY - topInset - distanceFromTop
return itemFrame.value(for: itemEdge) - topInset - distanceFromTop

case .bottomItem(let id, let distanceFromBottom):
case .bottomItem(let id, let itemEdge, let distanceFromBottom):
guard let indexPath = indexPathForItemID(id) else { return bounds.minY }
let itemFrame = frameForItemAtIndexPath(indexPath)
return itemFrame.maxY - bounds.height + bottomInset - distanceFromBottom
return itemFrame.value(for: itemEdge) - bounds.height + bottomInset - distanceFromBottom
}
}

}

// MARK: ItemEdge

enum ItemEdge {
case top
case bottom
}

// MARK: CGRect + Item Edge Value

private extension CGRect {

func value(for itemEdge: ItemEdge) -> CGFloat {
switch itemEdge {
case .top:
return minY
case .bottom:
return maxY
}
}

Expand Down
145 changes: 74 additions & 71 deletions MagazineLayout/Public/MagazineLayout.swift
Original file line number Diff line number Diff line change
Expand Up @@ -99,13 +99,6 @@ public final class MagazineLayout: UICollectionViewLayout {
override public func prepare() {
super.prepare()

// Save the most recent content inset. We read this later in `invalidateLayout:`. Unlike the
// bounds and content size, the content insets are updated _before_ `invalidateLayout` is
// called, leaving us no choice other than tracking it separately here.
lastContentInset = contentInset

guard !prepareActions.isEmpty else { return }

// Save the previous collection view width if necessary
if prepareActions.contains(.cachePreviousWidth) {
cachedCollectionViewWidth = currentCollectionView.bounds.width
Expand Down Expand Up @@ -255,6 +248,9 @@ public final class MagazineLayout: UICollectionViewLayout {
modelState.applyUpdates(updates)
hasDataSourceCountInvalidationBeforeReceivingUpdateItems = false

lastSizedElementMinY = nil
lastSizedElementPreferredHeight = nil

super.prepare(forCollectionViewUpdates: updateItems)
}

Expand All @@ -265,32 +261,37 @@ public final class MagazineLayout: UICollectionViewLayout {
supplementaryViewLayoutAttributesForPendingAnimations.removeAll()

targetContentOffsetAnchor = nil
preInvalidationContentSize = nil

if let stagedContentOffsetAdjustment {
let context = MagazineLayoutInvalidationContext()
context.invalidateLayoutMetrics = false
context.contentOffsetAdjustment = stagedContentOffsetAdjustment
invalidateLayout(with: context)
}
stagedContentOffsetAdjustment = nil
Comment on lines +266 to +272
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Copy link

@brynbodayle brynbodayle Feb 10, 2024

Choose a reason for hiding this comment

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

I'm not sure I'm seeing it, maybe we should add this in a comment? Curious why we invalidate the layout here.


super.finalizeCollectionViewUpdates()
}

override public func prepare(forAnimatedBoundsChange oldBounds: CGRect) {
super.prepare(forAnimatedBoundsChange: oldBounds)

let contentSize: CGSize
let contentInset: UIEdgeInsets
if let metricsSnapshot = metricsSnapshots.first(where: { $0.bounds == oldBounds }) {
contentSize = metricsSnapshot.contentSize
contentInset = metricsSnapshot.contentInset
} else {
contentSize = collectionViewContentSize
contentInset = self.contentInset
}

targetContentOffsetAnchor = targetContentOffsetAnchor(
bounds: oldBounds,
contentHeight: contentSize.height,
topInset: contentInset.top,
bottomInset: contentInset.bottom)
contentHeight: preInvalidationContentSize?.height ?? 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.

Choose a reason for hiding this comment

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

agreed

// 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)
}

override public func finalizeAnimatedBoundsChange() {
targetContentOffsetAnchor = nil
preInvalidationContentSize = nil

super.finalizeAnimatedBoundsChange()
}
Expand Down Expand Up @@ -671,6 +672,8 @@ public final class MagazineLayout: UICollectionViewLayout {
withOriginalAttributes originalAttributes: UICollectionViewLayoutAttributes)
-> UICollectionViewLayoutInvalidationContext
{
let originalAttributes = originalAttributes.copy() as! UICollectionViewLayoutAttributes

switch preferredAttributes.representedElementCategory {
case .cell:
modelState.updateItemHeight(
Expand Down Expand Up @@ -721,29 +724,60 @@ public final class MagazineLayout: UICollectionViewLayout {
forPreferredLayoutAttributes: preferredAttributes,
withOriginalAttributes: originalAttributes) as! MagazineLayoutInvalidationContext

// If layout information is discarded above our current scroll position (on rotation, for
// example), we need to compensate for preferred size changes to items as we're scrolling up,
// otherwise, the collection view will appear to jump each time an element is sized.
// Since size adjustments can occur for multiple items in the same soon-to-be-visible row, we
// need to account for this by considering the preferred height for previously sized elements in
// the same row so that we only adjust the content offset by the exact amount needed to create
// smooth scrolling.
let isScrolling = currentCollectionView.isDragging || currentCollectionView.isDecelerating
let isSizingElementAboveTopEdge = originalAttributes.frame.minY < currentCollectionView.contentOffset.y

if isScrolling && isSizingElementAboveTopEdge {
let top = currentCollectionView.bounds.minY + contentInset.top
let bottom = currentCollectionView.bounds.maxY
let isSizingElementAboveTopEdge = originalAttributes.frame.minY < top
let isSizingElementBelowBottomEdge = originalAttributes.frame.minY > bottom

let contentOffsetAdjustment: CGPoint
if verticalLayoutDirection == .topToBottom, isSizingElementAboveTopEdge {
// If layout information is discarded above our current scroll position (on rotation, for
// example), we need to compensate for preferred size changes to items as we're scrolling up,
// otherwise, the collection view will appear to jump each time an element is sized.
// Since size adjustments can occur for multiple items in the same soon-to-be-visible row, we
// need to account for this by considering the preferred height for previously sized elements
// in the same row so that we only adjust the content offset by the exact amount needed to
// create smooth scrolling.
let isSameRowAsLastSizedElement = lastSizedElementMinY?.isEqual(
to: currentElementY,
threshold: 1 / scale)
?? false

if isSameRowAsLastSizedElement {
let lastSizedElementPreferredHeight = self.lastSizedElementPreferredHeight ?? 0
if preferredAttributes.size.height > lastSizedElementPreferredHeight {
context.contentOffsetAdjustment.y = preferredAttributes.size.height - lastSizedElementPreferredHeight
contentOffsetAdjustment = CGPoint(
x: 0,
y: preferredAttributes.size.height - lastSizedElementPreferredHeight)
} else {
contentOffsetAdjustment = .zero
}
} else {
context.contentOffsetAdjustment.y = preferredAttributes.size.height - originalAttributes.size.height
contentOffsetAdjustment = CGPoint(
x: 0,
y: preferredAttributes.size.height - originalAttributes.size.height)
}
} else if
verticalLayoutDirection == .bottomToTop,
!isSizingElementBelowBottomEdge,
modelState.isPerformingBatchUpdates

Choose a reason for hiding this comment

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

I'm seeing in testing that we need this content offset even when batch updates aren't being performed. Here's a video of the below scenario where removing this check prevented a content offset jump. It's when you insert a cell at the top and it's offscreen.

Simulator.Screen.Recording.-.iPhone.15.-.2024-02-14.at.14.47.36.mp4

{
contentOffsetAdjustment = CGPoint(

Choose a reason for hiding this comment

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

it seems like we don't need to do this content offset adjustment for an onscreen cell, otherwise we get content offset jumps

Choose a reason for hiding this comment

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

Simulator.Screen.Recording.-.iPhone.15.-.2024-02-14.at.14.28.57.mp4

x: 0,
y: preferredAttributes.size.height - originalAttributes.size.height)
} else {
contentOffsetAdjustment = .zero
}

if modelState.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`.
// This needs to be set in `finalizeCollectionViewUpdates`, otherwise it doesn't get applied.
stagedContentOffsetAdjustment = contentOffsetAdjustment
} else if isScrolling {
context.contentOffsetAdjustment = contentOffsetAdjustment
Comment on lines +761 to +780
Copy link
Contributor Author

Choose a reason for hiding this comment

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

this code was already barely intelligible trash, and this certainly didn't help. It works though, and handles the cases where self-sizing items are inserted at the top / bottom

}

lastSizedElementMinY = currentElementY
Expand All @@ -760,30 +794,6 @@ public final class MagazineLayout: UICollectionViewLayout {
return
}

// We need to save a few content size and content inset values for different bounds. This
// allows us to compute the correct target content offset in `prepareForAnimatedBoundsChange`.
// The root issue is that in the aforementioned function, we need a way to know what the content
// size and content inset _were_ for a given bounds value.
let metricsSnapshot = MetricsSnapshot(
bounds: currentCollectionView.bounds,
contentSize: collectionViewContentSize,
contentInset: lastContentInset ?? contentInset)

// Replace existing snapshot if the bounds is the same
let indexOfExistingMetricsSnapshot = metricsSnapshots.firstIndex {
$0.bounds == metricsSnapshot.bounds
}
if let indexOfExistingMetricsSnapshot {
metricsSnapshots[indexOfExistingMetricsSnapshot] = metricsSnapshot
} else {
metricsSnapshots.append(metricsSnapshot)
}

// Limit to 3 snapshots
if metricsSnapshots.count > 3 {
metricsSnapshots.removeFirst()
}
Comment on lines -767 to -785
Copy link
Contributor Author

Choose a reason for hiding this comment

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

we don't need any of this historical tracking of layout metrics, since we simply no longer take into account content insets on rotation


let shouldInvalidateLayoutMetrics = !context.invalidateEverything &&
!context.invalidateDataSourceCounts

Expand Down Expand Up @@ -854,16 +864,8 @@ public final class MagazineLayout: UICollectionViewLayout {
private let _flipsHorizontallyInOppositeLayoutDirection: Bool
private let verticalLayoutDirection: MagazineLayoutVerticalLayoutDirection

private var lastContentInset: UIEdgeInsets?
private var cachedCollectionViewWidth: CGFloat?

private struct MetricsSnapshot {
let bounds: CGRect
let contentSize: CGSize
let contentInset: UIEdgeInsets
}
private var metricsSnapshots = [MetricsSnapshot]()

// These properties are used to prevent scroll jumpiness due to self-sizing after rotation; see
// comment in `invalidationContext(forPreferredLayoutAttributes:withOriginalAttributes:)` for more
// details.
Expand Down Expand Up @@ -900,6 +902,8 @@ public final class MagazineLayout: UICollectionViewLayout {

private var isPerformingAnimatedBoundsChange = false
private var targetContentOffsetAnchor: TargetContentOffsetAnchor?
private var preInvalidationContentSize: CGSize?
private var stagedContentOffsetAdjustment: CGPoint?

// Used to provide the model state with the current visible bounds for the sole purpose of
// supporting pinned headers and footers.
Expand Down Expand Up @@ -1271,17 +1275,15 @@ public final class MagazineLayout: UICollectionViewLayout {
bottomInset: CGFloat)
-> TargetContentOffsetAnchor?
{
let insetBounds = bounds.inset(by: .init(top: topInset, left: 0, bottom: bottomInset, right: 0))
var visibleItemLocationFramePairs = [ElementLocationFramePair]()
for itemLocationFramePair in modelState.itemLocationFramePairs(forItemsIn: bounds) {
for itemLocationFramePair in modelState.itemLocationFramePairs(forItemsIn: insetBounds) {
visibleItemLocationFramePairs.append(itemLocationFramePair)
}
visibleItemLocationFramePairs.sort { $0.elementLocation < $1.elementLocation }
Copy link
Contributor Author

Choose a reason for hiding this comment

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

there were some bugs because this array wasn't guaranteed to be sorted before - whoops!

Choose a reason for hiding this comment

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

good find!


let firstVisibleItemLocationFramePair = visibleItemLocationFramePairs.first {
$0.frame.minY >= bounds.minY + topInset
}
let lastVisibleItemLocationFramePair = visibleItemLocationFramePairs.last {
$0.frame.maxY <= bounds.minY + bounds.height - bottomInset
}
let firstVisibleItemLocationFramePair = visibleItemLocationFramePairs.first
let lastVisibleItemLocationFramePair = visibleItemLocationFramePairs.last

guard
let firstVisibleItemLocationFramePair,
Expand All @@ -1302,6 +1304,7 @@ public final class MagazineLayout: UICollectionViewLayout {
bottomInset: bottomInset,
bounds: bounds,
contentHeight: contentHeight,
scale: scale,
firstVisibleItemID: firstVisibleItemID,
lastVisibleItemID: lastVisibleItemID,
firstVisibleItemFrame: firstVisibleItemLocationFramePair.frame,
Expand Down
Loading
Loading