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 }