From 67c4ab5dc3dc3b8016cd959e357023f10d7e90b2 Mon Sep 17 00:00:00 2001 From: Amp Date: Sun, 25 Oct 2026 21:27:17 +0000 Subject: [PATCH] fix(zero-swift): a list stuck to the bottom takes the user's first scroll When the core scrolled a host to where the host clamped it, no didScroll followed, so programmaticScroll stayed set and the user's next scroll was taken for the core's own: stuck wasn't measured again, and the list pulled the rows back to the bottom against the scroll. setScrollOffset now clears the flag when the host didn't move. Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm --- .changeset/stuck-list-first-scroll.md | 5 +++++ .../Sources/ZeroVirtual/VirtualizerCore.swift | 7 +++++++ .../ZeroVirtualTests/LayoutHostTests.swift | 21 +++++++++++++++++++ 3 files changed, 33 insertions(+) create mode 100644 .changeset/stuck-list-first-scroll.md diff --git a/.changeset/stuck-list-first-scroll.md b/.changeset/stuck-list-first-scroll.md new file mode 100644 index 0000000000..daf6d7d2b6 --- /dev/null +++ b/.changeset/stuck-list-first-scroll.md @@ -0,0 +1,5 @@ +--- +'zero-swift': patch +--- + +A `VirtualList` or `VirtualListView` stuck to the bottom (`stickToBottom`) no longer pulls the user's first scroll up back to the bottom. When the list tried to scroll past where it could, its next scroll, the user's, was taken for its own, and the list stayed stuck. diff --git a/swift/Sources/ZeroVirtual/VirtualizerCore.swift b/swift/Sources/ZeroVirtual/VirtualizerCore.swift index 7c7c373061..85d51edc13 100644 --- a/swift/Sources/ZeroVirtual/VirtualizerCore.swift +++ b/swift/Sources/ZeroVirtual/VirtualizerCore.swift @@ -719,10 +719,17 @@ package final class VirtualizerCore< @discardableResult private func setScrollOffset(_ offset: Double) -> Bool { guard let host, host.scrollOffset.rounded() != offset.rounded() else { return false } + let from = host.scrollOffset programmaticScroll = true writing = true host.scroll(to: offset) writing = false + guard host.scrollOffset != from else { + // Clamped to where it was: no scroll, and the user's next one isn't + // ours. + programmaticScroll = false + return false + } if depth == 0, scrollDuringWrite { scrollDuringWrite = false onScroll() diff --git a/swift/Tests/ZeroVirtualTests/LayoutHostTests.swift b/swift/Tests/ZeroVirtualTests/LayoutHostTests.swift index 824a6b10b8..8a936d20a9 100644 --- a/swift/Tests/ZeroVirtualTests/LayoutHostTests.swift +++ b/swift/Tests/ZeroVirtualTests/LayoutHostTests.swift @@ -269,6 +269,27 @@ final class LayoutScrollView { #expect(view.scrollOffset == offset) } + @Test("stuck to the bottom of a list opened at its last row, a scroll up moves the rows by it") + func scrollUpFromTheBottom() { + let h = Harness(rowCount: 400, rowHeight: 80) { + $0.anchoring = .manual + $0.permalinkID = "r399" + $0.stickToBottom = true + $0.minPageSize = 20 + } + let view = LayoutScrollView(h) + for i in 0..<400 { view.heights["r\(i)"] = [62.3, 180.6, 38.5, 121.0, 76.0][i % 5] } + view.settle() + h.scheduler.advance(by: 200) + view.settle() + #expect(view.bottomGap == 0) + // The core's scrolls past the bottom stay where they are: the user's + // next scroll is the user's, not taken back to the bottom. + let top = try! #require(view.rowTop("r396")) + view.userScroll(view.scrollOffset - 20.5) + #expect(view.rowTop("r396") == top + 20.5) + } + @Test("scrolls to a row not loaded yet") func scrollToItemFarBelow() { let h = Harness(rowCount: 200) { $0.anchoring = .manual }