Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion appleApp/ios/UI/Component/CollectionViewTimeline.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1171,7 +1171,8 @@ final class UITimelineCollectionViewController: UIViewController, UICollectionVi
pendingEffectiveContentOffsetYAfterSnapshot == nil, restoresScrollAnchorOnSnapshotChanges {
collectionView.prepareForSnapshotChange()
}
if plan.isInitialLoading, previousPlan != nil, pendingReloadPosition == nil,
// Repeated first-load inputs are not reloads of an existing reading position.
if plan.isInitialLoading, previousPlan?.isInitialLoading == false, pendingReloadPosition == nil,
pendingEffectiveContentOffsetYAfterSnapshot == nil, restoresScrollAnchorOnSnapshotChanges {
pendingReloadPosition = collectionView.captureReadingPosition()
}
Expand Down
13 changes: 11 additions & 2 deletions appleApp/ios/UI/Component/TimelineCollectionView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ final class TimelineCollectionView: UICollectionView {

// This is a pending layout transaction, not a continuously enforced offset.
private var readingPosition: ReadingPosition?
private var readingPositionWasAtTop = false
// Passive across size changes: a different column may become visually first,
// or a shorter card may temporarily clamp the original intra-item distance.
private var geometryReadingPosition: ReadingPosition?
Expand All @@ -58,7 +59,7 @@ final class TimelineCollectionView: UICollectionView {
var hasReadingPosition: Bool { readingPosition != nil }
var canRestoreTopReadingPosition: Bool {
if geometryReadingPosition?.itemID != nil { return geometryReadingPositionWasAtTop }
return readingPosition?.itemID == nil
return readingPosition?.itemID == nil || readingPositionWasAtTop
}
var isPresentingRefresh: Bool { refreshRequested || isEndingRefresh || refreshControl?.isRefreshing == true }

Expand Down Expand Up @@ -265,6 +266,8 @@ final class TimelineCollectionView: UICollectionView {

readingPositionGeneration += 1
if readingPosition == nil {
// Measuring a section below a pinned title can capture an item even at the top.
readingPositionWasAtTop = canRestoreTopReadingPosition && contentOffset.y + restingAdjustedTopInset <= 1
readingPosition = captureCurrentLayoutPosition(preferringVisibleTop: preferringVisibleTop)
}
}
Expand All @@ -274,12 +277,13 @@ final class TimelineCollectionView: UICollectionView {
allowsReadingPositionRestoration else { return }
if geometryReadingPosition == nil {
// A later size change can clamp a scrolled list to zero without making it a top bookmark.
geometryReadingPositionWasAtTop = readingPosition?.itemID == nil &&
geometryReadingPositionWasAtTop = canRestoreTopReadingPosition &&
contentOffset.y + restingAdjustedTopInset <= 1
geometryReadingPosition = captureReadingPosition()
}
readingPositionGeneration += 1
readingPosition = geometryReadingPosition
readingPositionWasAtTop = geometryReadingPositionWasAtTop
}

func prepareForSnapshotChange() {
Expand All @@ -290,6 +294,7 @@ final class TimelineCollectionView: UICollectionView {
// Reuse the item bookmark if a snapshot arrives during reflow. Once a top
// restore completes, later prepends capture the loaded reading item.
if readingPosition?.itemID == nil {
readingPositionWasAtTop = canRestoreTopReadingPosition && contentOffset.y + restingAdjustedTopInset <= 1
readingPosition = captureCurrentLayoutPosition()
}
readingPositionGeneration += 1
Expand Down Expand Up @@ -419,12 +424,14 @@ final class TimelineCollectionView: UICollectionView {
geometryReadingPosition = position.itemID == nil ? nil : position
geometryReadingPositionWasAtTop = false
readingPosition = position
readingPositionWasAtTop = position.itemID == nil
setNeedsLayout()
}

func resetReadingPosition() {
readingPositionGeneration += 1
readingPosition = nil
readingPositionWasAtTop = false
geometryReadingPosition = nil
}

Expand All @@ -437,6 +444,7 @@ final class TimelineCollectionView: UICollectionView {
!self.isEndingRefresh, self.allowsReadingPositionRestoration,
self.isReadingLayoutReady?(indexPath) != false else { return }
self.readingPosition = nil
self.readingPositionWasAtTop = false
if self.geometryReadingPosition?.itemID == nil { self.geometryReadingPosition = nil }
}
}
Expand Down Expand Up @@ -475,6 +483,7 @@ final class TimelineCollectionView: UICollectionView {
// No old item survives a replacement. Start below the bars;
// never turn the disappearing refresh gap into an item offset.
self.readingPosition = .top
readingPositionWasAtTop = true
geometryReadingPosition = nil
setNeedsLayout()
return
Expand Down
69 changes: 68 additions & 1 deletion appleApp/iosControllerTests/TimelineControllerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ final class TimelineControllerIntegrationTests: XCTestCase {
let originalHeight = fixture.controller.view.frame.height

// All rows fit after resizing, but a scrolled item's bookmark must survive.
fixture.collection.prepareForLayoutChange()
fixture.controller.view.frame.size.height = fixture.collection.contentSize.height + 1_000
await fixture.settle()
XCTAssertEqual(fixture.controller.effectiveContentOffsetY, 0, accuracy: 0.5)
Expand Down Expand Up @@ -159,6 +160,69 @@ final class TimelineControllerIntegrationTests: XCTestCase {
try fixture.assertPosition(position)
}

func testRepeatedInitialLoadingWithDetailTabsDoesNotRestoreAPlaceholderBookmark() async {
for repeatsAccessories in [false, true] {
let tabs = [UITimelineCollectionViewAccessoryItem(id: "vvo_status_tabs", view: Header())]
let fixture = await Fixture(initialState: .loading, header: UiStateLoading<UiTimelineV2>(), accessories: tabs)
// The real detail screen includes its header and tabs in the first snapshot.
if repeatsAccessories {
fixture.controller.accessoryItems = tabs
await fixture.settle()
} else {
await fixture.apply()
}
fixture.input.header = UiStateSuccess(data: makeRow(100))
await fixture.apply()
XCTAssertEqual(fixture.controller.effectiveContentOffsetY, 0, accuracy: 0.5)

fixture.input.state = .loaded
fixture.input.items = (0..<20).map { .post(makeRow($0)) }
await fixture.apply()
XCTAssertEqual(fixture.controller.effectiveContentOffsetY, 0, accuracy: 0.5)
}
}

func testHostedSectionsLoadingAboveAPendingLayoutBookmarkPreserveItsOriginalTopness() async throws {
for offset: CGFloat in [0, 120] {
let tags = TimelineHostedAccessoryView()
tags.update(AnyView(Color.clear.frame(height: 140)))
let accessories = [
UITimelineCollectionViewAccessoryItem(id: "tags_title", view: Header(), pinnedView: UIView()),
UITimelineCollectionViewAccessoryItem(id: "tags", view: tags),
UITimelineCollectionViewAccessoryItem(id: "posts_title", view: Header(), pinnedView: UIView())
]
let fixture = await Fixture(initialState: .loading, accessories: accessories)
if offset > 0 { await fixture.scroll(offset) }
// Hosted sections report their height while the post placeholders are still visible.
tags.update(AnyView(Color.clear.frame(height: 160)))
tags.setNeedsLayout()
tags.layoutIfNeeded()
await fixture.settle()
XCTAssertTrue(fixture.collection.hasReadingPosition)
let expected = try fixture.position()

fixture.controller.accessoryItems = [
UITimelineCollectionViewAccessoryItem(id: "users_title", view: Header(), pinnedView: UIView()),
UITimelineCollectionViewAccessoryItem(id: "users", view: Header())
] + accessories
await fixture.settle()
if offset == 0 {
XCTAssertEqual(fixture.controller.effectiveContentOffsetY, 0, accuracy: 0.5)
} else {
try fixture.assertPosition(expected)
}

fixture.input.state = .loaded
fixture.input.items = (0..<20).map { .post(makeRow($0)) }
await fixture.apply()
if offset == 0 {
XCTAssertEqual(fixture.controller.effectiveContentOffsetY, 0, accuracy: 0.5)
} else {
try fixture.assertPosition(expected)
}
}
}

func testLateTopSectionsKeepTopOrTheScrolledReadingItem() async throws {
// Home's notice, search users, and discover users/tags load separately from posts.
let scenarios: [(initial: [String], loaded: [String])] = [
Expand Down Expand Up @@ -605,8 +669,11 @@ final class TimelineControllerIntegrationTests: XCTestCase {
var input = TimelineContent()
var columns: Int

init(posts: Bool = false, columns: Int = 1, initialState: TimelineContent.State? = nil) async {
init(posts: Bool = false, columns: Int = 1, initialState: TimelineContent.State? = nil,
header: UiState<UiTimelineV2>? = nil, accessories: [UITimelineCollectionViewAccessoryItem] = []) async {
self.columns = columns
input.header = header
controller.accessoryItems = accessories
if let initialState {
input.state = initialState
} else if posts {
Expand Down
Loading