diff --git a/appleApp/ios/UI/Component/CollectionViewTimeline.swift b/appleApp/ios/UI/Component/CollectionViewTimeline.swift index f9c5ebbe5..c6a920be4 100644 --- a/appleApp/ios/UI/Component/CollectionViewTimeline.swift +++ b/appleApp/ios/UI/Component/CollectionViewTimeline.swift @@ -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() } diff --git a/appleApp/ios/UI/Component/TimelineCollectionView.swift b/appleApp/ios/UI/Component/TimelineCollectionView.swift index bd77bb3e7..22ea5c1fc 100644 --- a/appleApp/ios/UI/Component/TimelineCollectionView.swift +++ b/appleApp/ios/UI/Component/TimelineCollectionView.swift @@ -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? @@ -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 } @@ -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) } } @@ -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() { @@ -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 @@ -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 } @@ -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 } } } @@ -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 diff --git a/appleApp/iosControllerTests/TimelineControllerTests.swift b/appleApp/iosControllerTests/TimelineControllerTests.swift index d692bc651..670064915 100644 --- a/appleApp/iosControllerTests/TimelineControllerTests.swift +++ b/appleApp/iosControllerTests/TimelineControllerTests.swift @@ -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) @@ -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(), 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])] = [ @@ -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? = nil, accessories: [UITimelineCollectionViewAccessoryItem] = []) async { self.columns = columns + input.header = header + controller.accessoryItems = accessories if let initialState { input.state = initialState } else if posts {