From 6fc1308174b3bf7f5489b25c1a6c4c9f6848ee63 Mon Sep 17 00:00:00 2001 From: Amp Date: Sat, 14 Nov 2026 08:06:06 +0000 Subject: [PATCH] feat(zero-swift): let a VirtualList keep the views of rows that page out Most of the macOS issue list's remaining scroll stalls were rows paging back in: each made its SwiftUI view again and measured it (about 1.4 ms a row, 4 to 6 ms for a frame's first) in the frame that brought it. `VirtualList.maxKeptRows(_:)` and `VirtualListView.maxKeptRows` keep the views of up to that many rows that leave the snapshot, hidden, oldest evicted first. A kept row that comes back is shown in its view again, with its height and state, unless its data, selection or the list's width changed meanwhile. Its own queries stay open while it's kept. The default, 0, keeps none, as before. zbugs-swift's SwiftUI list keeps 200, about 14 MB. On a 60 Hz satellite, the flings-and-drags benchmark over 155 open issues (three alternating runs, #202 then this): main-thread stalls over 8.3 ms 238/259/302 then 171/157/175, over 16.7 ms 12/15/19 then 13/16/14, dropped frames 5/4/4 then 6/2/5. Traced, stalls of rows coming into reach went from 200 spans (2.2 s) to 50 (0.6 s); the stalls over 16.7 ms are page changes, which this doesn't touch. Amp-Thread-ID: https://ampcode.com/threads/T-02a95685-6d19-4dd4-b1de-8210ec5599df Co-authored-by: tmm --- .../zero-swift-virtual-list-kept-rows.md | 5 + .../Sources/ZBugsUI/IssueList.swift | 3 + .../Sources/ZeroVirtual/RowsScrollView.swift | 56 ++++++++- swift/Sources/ZeroVirtual/VirtualList.swift | 22 +++- .../Sources/ZeroVirtual/VirtualListView.swift | 9 ++ swift/Tests/AppleTests/VirtualListTests.swift | 117 ++++++++++++++++++ 6 files changed, 208 insertions(+), 4 deletions(-) create mode 100644 .changeset/zero-swift-virtual-list-kept-rows.md diff --git a/.changeset/zero-swift-virtual-list-kept-rows.md b/.changeset/zero-swift-virtual-list-kept-rows.md new file mode 100644 index 0000000000..26b476df3b --- /dev/null +++ b/.changeset/zero-swift-virtual-list-kept-rows.md @@ -0,0 +1,5 @@ +--- +'zero-swift': patch +--- + +Add `VirtualList.maxKeptRows(_:)` and `VirtualListView.maxKeptRows`: how many rows that page out of the list keep their views for when they come back. A kept row scrolled back to is shown at once, with its state, instead of being made and measured again in the frame that brings it, which is what stalls a fast scroll back over rows it paged out. Kept rows hold their views, and their own queries stay open, until they come back or are among the oldest beyond the limit. The default, 0, keeps none, as before. diff --git a/apps/zbugs-swift/Sources/ZBugsUI/IssueList.swift b/apps/zbugs-swift/Sources/ZBugsUI/IssueList.swift index 9062341bdc..cf522c5210 100644 --- a/apps/zbugs-swift/Sources/ZBugsUI/IssueList.swift +++ b/apps/zbugs-swift/Sources/ZBugsUI/IssueList.swift @@ -130,6 +130,9 @@ VirtualList(list, selection: $selection) { item, isSelected in IssueRow(issue: item.row, isSelected: isSelected) } + // A row scrolled back to is shown at once, not made and measured + // again in the frame that brings it: about 7 MB per 100 rows. + .maxKeptRows(200) .overlay { let s = list.snapshot if s.rowsEmpty { diff --git a/swift/Sources/ZeroVirtual/RowsScrollView.swift b/swift/Sources/ZeroVirtual/RowsScrollView.swift index 77ec5fb744..46c7d890fb 100644 --- a/swift/Sources/ZeroVirtual/RowsScrollView.swift +++ b/swift/Sources/ZeroVirtual/RowsScrollView.swift @@ -94,6 +94,20 @@ /// Views rows left, hidden, for other rows: as many as `spareViews` /// kept, the rest released a few a frame. private var pool: [PlatformView] = [] + /// Rows that left the snapshot, with their views, hidden, for when they + /// come back, oldest first: as many as `maxKeptRows`. + private var kept: [AnyHashable: RowCell] = [:] + private var keptOrder: [AnyHashable] = [] + /// How many rows that leave the snapshot, as the list pages, keep their + /// views for when they come back: shown again at once, with their state, + /// not made and measured anew. 0 keeps none. + var maxKeptRows = 0 { + didSet { + guard maxKeptRows != oldValue else { return } + trimKept() + releaseSpares() + } + } private var releasing = false private var width = 0.0 /// Changes with each snapshot: rows are compared with their cells' then. @@ -169,6 +183,10 @@ /// The row with `key` changed height on its own: it's measured again in /// the next layout pass. func rowHeightDidChange(_ key: AnyHashable) { + if let cell = kept[key] { + cell.height = nil + return + } guard let cell = cells[key] else { return } cell.height = nil host.invalidate() @@ -205,6 +223,7 @@ if width != self.width { self.width = width for cell in cells.values { cell.height = nil } + for cell in kept.values { cell.height = nil } header?.height = nil footer?.height = nil host.invalidateHeights() @@ -216,8 +235,10 @@ cells[key] = nil seen[key] = nil cell.view.isHidden = true - pool.append(cell.view) + kept[key] = cell + keptOrder.append(key) } + trimKept() releaseSpares() var accessories: [(view: PlatformView, minY: Double, maxY: Double)] = [] if let header { accessories.append((header.view, 0, host.headerHeight)) } @@ -231,6 +252,17 @@ ) } + /// The oldest kept rows beyond `maxKeptRows` give their views up, for + /// other rows. + private func trimKept() { + let over = keptOrder.count - maxKeptRows + guard over > 0 else { return } + for key in keptOrder.prefix(over) { + if let cell = kept.removeValue(forKey: key) { pool.append(cell.view) } + } + keptOrder.removeFirst(over) + } + /// Releases a few of the views beyond `spareViews` in the next frame, /// not in the pass: a view's teardown is as slow as its making. private func releaseSpares() { @@ -292,11 +324,25 @@ /// The row with `key` is shown again, in its state now. private func reshow(_ key: AnyHashable?) { - guard let key, let cell = cells[key], let item = item(key) else { return } + guard let key else { return } + // A kept row would come back in the state it left in: it gives its + // view up, and comes back anew. + if let cell = unkeep(key) { + pool.append(cell.view) + releaseSpares() + } + guard let cell = cells[key], let item = item(key) else { return } show(item, in: cell) host.invalidate() } + /// The kept row with `key`, no longer kept. + private func unkeep(_ key: AnyHashable) -> RowCell? { + guard let cell = kept.removeValue(forKey: key) else { return nil } + keptOrder.removeAll { $0 == key } + return cell + } + private func replace(_ old: RowsAccessory?, with new: RowsAccessory?) { guard old !== new else { return } if let old { @@ -317,6 +363,8 @@ private func measure(_ key: AnyHashable) -> Double { guard let item = item(key) else { return 0 } + // A kept row is back: its view, and its height, as it left. + if cells[key] == nil, let cell = unkeep(key) { cells[key] = cell } if let cell = cells[key] { if seen[key] != generation { seen[key] = generation @@ -362,7 +410,7 @@ /// The row's height as it laid itself out: when it's another than the /// one measured, it's measured again. func row(_ key: AnyHashable, laidOutAt height: Double) { - guard let old = cells[key]?.height, abs(old - height) > 0.5 else { return } + guard let old = (cells[key] ?? kept[key])?.height, abs(old - height) > 0.5 else { return } rowHeightDidChange(key) } } @@ -913,6 +961,7 @@ /// The selected row, and what a tap or a key sets it to; nil when rows /// aren't selectable. let selection: (key: AnyHashable?, set: (AnyHashable) -> Void)? + let maxKeptRows: Int let model: VirtualListModel func make() -> RowsScrollView { @@ -933,6 +982,7 @@ view.selectable = selection != nil rows.onSelect = selection?.set ?? { _ in } rows.selected = selection?.key + rows.maxKeptRows = maxKeptRows rows.update(source()) } } diff --git a/swift/Sources/ZeroVirtual/VirtualList.swift b/swift/Sources/ZeroVirtual/VirtualList.swift index 471c2bc110..c3b0a27e8b 100644 --- a/swift/Sources/ZeroVirtual/VirtualList.swift +++ b/swift/Sources/ZeroVirtual/VirtualList.swift @@ -54,6 +54,7 @@ private let selectable: Bool private let header: AnyView? private let footer: AnyView? + private var maxKeptRows = 0 @State private var model = VirtualListModel() /// - Parameters: @@ -99,6 +100,25 @@ self.footer = Self.accessory(footer) } + /// Keeps the views of up to `count` rows that leave the list as it + /// pages, for when they come back: a row scrolled back to is shown at + /// once, with its state and its own queries as it left them, instead of + /// being made and measured anew, which a fast scroll back can't keep up + /// with. Each kept row holds its view, and its queries stay open, while + /// it's kept; the oldest go first. 0, the default, keeps none. + /// + /// ```swift + /// VirtualList(issues) { item in + /// IssueRow(issue: item.row) + /// } + /// .maxKeptRows(200) + /// ``` + public func maxKeptRows(_ count: Int) -> Self { + var list = self + list.maxKeptRows = max(0, count) + return list + } + private static func accessory(_ content: () -> V) -> AnyView? { V.self == EmptyView.self ? nil : AnyView(content()) } @@ -115,7 +135,7 @@ ? ( selection.wrappedValue.map { AnyHashable($0) }, { [selection] key in selection.wrappedValue = key.base as? Key } - ) : nil, model: model + ) : nil, maxKeptRows: maxKeptRows, model: model ) .onAppear { model.attach(list) } .onDisappear { model.detach(list) } diff --git a/swift/Sources/ZeroVirtual/VirtualListView.swift b/swift/Sources/ZeroVirtual/VirtualListView.swift index 853363a4db..cbc3f43db3 100644 --- a/swift/Sources/ZeroVirtual/VirtualListView.swift +++ b/swift/Sources/ZeroVirtual/VirtualListView.swift @@ -181,6 +181,15 @@ } } + /// How many rows that leave the list as it pages keep their views for + /// when they come back: `row` doesn't make or show them again, and + /// they aren't measured again. Each kept row holds its view while it's + /// kept; the oldest go first. 0, the default, keeps none. + public var maxKeptRows: Int { + get { rows.rows.maxKeptRows } + set { rows.rows.maxKeptRows = max(0, newValue) } + } + private static func accessory(_ view: PlatformView) -> RowsAccessory { RowsAccessory(view: view) { [unowned view] width in AppRowViews.fittingHeight(of: view, width: width) diff --git a/swift/Tests/AppleTests/VirtualListTests.swift b/swift/Tests/AppleTests/VirtualListTests.swift index a4c9bb91f3..6b24ec7419 100644 --- a/swift/Tests/AppleTests/VirtualListTests.swift +++ b/swift/Tests/AppleTests/VirtualListTests.swift @@ -237,6 +237,123 @@ withExtendedLifetime((zero, window)) {} } + final class States { + var byRow: [String: Set] = [:] + } + + /// A row that records the state it's drawn with: one whose view starts + /// anew has a new one. + struct StatefulRow: View { + let id: String + let states: States + @State private var state = UUID() + + var body: some View { + let _ = states.byRow[id, default: []].insert(state) + Text(id).frame(height: 30) + } + } + + @Test func aKeptRowComesBackWithItsState() async throws { + let (zero, list) = try makeList() + let states = States() + let window = show( + VirtualList(list) { StatefulRow(id: $0.row.id, states: states) }.maxKeptRows(300)) + let top = (0..<10).map(Self.id) + try await run(30) { top.allSatisfy { states.byRow[$0] != nil } } + list.scrollToItem(Self.id(250), align: .end) + try await run(30) { list.lastVisibleItem()?.key == Self.id(250) } + try await run(0.5) { false } + #expect(!list.snapshot.items.contains { $0.key == Self.id(0) }) + list.scrollToItem(Self.id(0), align: .start) + try await run(30) { list.firstVisibleItem()?.key == Self.id(0) } + try await run(0.5) { false } + #expect(top.map { states.byRow[$0]?.count } == top.map { _ in 1 }) + withExtendedLifetime((zero, window)) {} + } + + /// Row views that record what the layout asks of them: plain views, + /// 30 points high. + @MainActor final class CountingRowViews: RowViews { + /// Each row shown in a view, and its state. + var shown: [(key: AnyHashable, state: RowState)] = [] + var measured = 0 + + func view( + for item: RowsItem, _ state: RowState, reusing view: PlatformView?, in layout: RowsLayout + ) -> PlatformView { + shown.append((item.key, state)) + return view ?? PlatformView(frame: .zero) + } + + func height(of view: PlatformView, width: Double) -> Double { + measured += 1 + return 30 + } + + func discard(_ view: PlatformView) {} + } + + /// A layout of 30-point rows, with no frames after its passes: what a + /// pass leaves for the next frame doesn't run. + func countingLayout(keeping maxKeptRows: Int) -> (RowsLayout, CountingRowViews) { + let views = CountingRowViews() + let rows = RowsLayout(views: views) + rows.host.estimatedRowHeight = { 30 } + rows.nextFrame = { _ in } + rows.maxKeptRows = maxKeptRows + return (rows, views) + } + + /// A pass with rows `range` scrolled to at `width`, with `changed` + /// rows' data changed: the rows it showed in a view. + @discardableResult + func pass( + _ range: Range, in rows: RowsLayout, _ views: CountingRowViews, width: Double = 300, + changed: Set = [] + ) -> [AnyHashable] { + views.shown = [] + views.measured = 0 + rows.update( + RowsSource( + items: range.map { + RowsItem( + key: AnyHashable(Self.id($0)), index: $0, row: changed.contains($0) ? -1 : $0, + item: $0) + }, spaceBefore: Double(range.lowerBound) * 30)) + _ = rows.layout(width: width, offset: Double(range.lowerBound) * 30, viewport: 400) + return views.shown.map(\.key) + } + + // Rows that page out keep their views, up to `maxKeptRows`, and come + // back in them, neither shown nor measured again; 0 keeps none. + @Test(arguments: [0, 5, 100]) + func keepsTheViewsOfRowsThatLeave(maxKeptRows: Int) { + let (rows, views) = countingLayout(keeping: maxKeptRows) + let first = pass(0..<20, in: rows, views) + pass(100..<120, in: rows, views) + let back = pass(0..<20, in: rows, views) + let anew = first.count - min(maxKeptRows, first.count) + #expect(first.filter(back.contains).count == anew) + #expect(views.measured == anew) + } + + // A kept row comes back as it is now: with its new data, in its + // selection, and measured at the list's width, all changed while it was + // kept. + @Test func aKeptRowComesBackAsItIsNow() throws { + let (rows, views) = countingLayout(keeping: 100) + let first = pass(0..<20, in: rows, views) + pass(100..<120, in: rows, views) + rows.selected = AnyHashable(Self.id(3)) + pass(100..<120, in: rows, views, width: 250) + let back = pass(0..<20, in: rows, views, width: 250, changed: [1]) + #expect(Set(back) == [AnyHashable(Self.id(1)), AnyHashable(Self.id(3))]) + let selected = try #require(views.shown.first { $0.key == AnyHashable(Self.id(3)) }) + #expect(selected.state.selected) + #expect(views.measured == first.count) + } + @Test func scrollsToARowFarBelow() async throws { let (zero, list) = try makeList() let window = show(VirtualList(list) { Text($0.row.text).frame(height: 30) })