Skip to content

Commit 0ba9559

Browse files
authored
feat(zero-swift): let a VirtualList keep the views of rows that page out (#213)
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 <tmm@tmm.dev>
1 parent d1b27f8 commit 0ba9559

6 files changed

Lines changed: 208 additions & 4 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'zero-swift': patch
3+
---
4+
5+
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.

‎apps/zbugs-swift/Sources/ZBugsUI/IssueList.swift‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,9 @@
130130
VirtualList(list, selection: $selection) { item, isSelected in
131131
IssueRow(issue: item.row, isSelected: isSelected)
132132
}
133+
// A row scrolled back to is shown at once, not made and measured
134+
// again in the frame that brings it: about 7 MB per 100 rows.
135+
.maxKeptRows(200)
133136
.overlay {
134137
let s = list.snapshot
135138
if s.rowsEmpty {

‎swift/Sources/ZeroVirtual/RowsScrollView.swift‎

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,20 @@
9494
/// Views rows left, hidden, for other rows: as many as `spareViews`
9595
/// kept, the rest released a few a frame.
9696
private var pool: [PlatformView] = []
97+
/// Rows that left the snapshot, with their views, hidden, for when they
98+
/// come back, oldest first: as many as `maxKeptRows`.
99+
private var kept: [AnyHashable: RowCell] = [:]
100+
private var keptOrder: [AnyHashable] = []
101+
/// How many rows that leave the snapshot, as the list pages, keep their
102+
/// views for when they come back: shown again at once, with their state,
103+
/// not made and measured anew. 0 keeps none.
104+
var maxKeptRows = 0 {
105+
didSet {
106+
guard maxKeptRows != oldValue else { return }
107+
trimKept()
108+
releaseSpares()
109+
}
110+
}
97111
private var releasing = false
98112
private var width = 0.0
99113
/// Changes with each snapshot: rows are compared with their cells' then.
@@ -169,6 +183,10 @@
169183
/// The row with `key` changed height on its own: it's measured again in
170184
/// the next layout pass.
171185
func rowHeightDidChange(_ key: AnyHashable) {
186+
if let cell = kept[key] {
187+
cell.height = nil
188+
return
189+
}
172190
guard let cell = cells[key] else { return }
173191
cell.height = nil
174192
host.invalidate()
@@ -205,6 +223,7 @@
205223
if width != self.width {
206224
self.width = width
207225
for cell in cells.values { cell.height = nil }
226+
for cell in kept.values { cell.height = nil }
208227
header?.height = nil
209228
footer?.height = nil
210229
host.invalidateHeights()
@@ -216,8 +235,10 @@
216235
cells[key] = nil
217236
seen[key] = nil
218237
cell.view.isHidden = true
219-
pool.append(cell.view)
238+
kept[key] = cell
239+
keptOrder.append(key)
220240
}
241+
trimKept()
221242
releaseSpares()
222243
var accessories: [(view: PlatformView, minY: Double, maxY: Double)] = []
223244
if let header { accessories.append((header.view, 0, host.headerHeight)) }
@@ -231,6 +252,17 @@
231252
)
232253
}
233254

255+
/// The oldest kept rows beyond `maxKeptRows` give their views up, for
256+
/// other rows.
257+
private func trimKept() {
258+
let over = keptOrder.count - maxKeptRows
259+
guard over > 0 else { return }
260+
for key in keptOrder.prefix(over) {
261+
if let cell = kept.removeValue(forKey: key) { pool.append(cell.view) }
262+
}
263+
keptOrder.removeFirst(over)
264+
}
265+
234266
/// Releases a few of the views beyond `spareViews` in the next frame,
235267
/// not in the pass: a view's teardown is as slow as its making.
236268
private func releaseSpares() {
@@ -292,11 +324,25 @@
292324

293325
/// The row with `key` is shown again, in its state now.
294326
private func reshow(_ key: AnyHashable?) {
295-
guard let key, let cell = cells[key], let item = item(key) else { return }
327+
guard let key else { return }
328+
// A kept row would come back in the state it left in: it gives its
329+
// view up, and comes back anew.
330+
if let cell = unkeep(key) {
331+
pool.append(cell.view)
332+
releaseSpares()
333+
}
334+
guard let cell = cells[key], let item = item(key) else { return }
296335
show(item, in: cell)
297336
host.invalidate()
298337
}
299338

339+
/// The kept row with `key`, no longer kept.
340+
private func unkeep(_ key: AnyHashable) -> RowCell? {
341+
guard let cell = kept.removeValue(forKey: key) else { return nil }
342+
keptOrder.removeAll { $0 == key }
343+
return cell
344+
}
345+
300346
private func replace(_ old: RowsAccessory?, with new: RowsAccessory?) {
301347
guard old !== new else { return }
302348
if let old {
@@ -317,6 +363,8 @@
317363

318364
private func measure(_ key: AnyHashable) -> Double {
319365
guard let item = item(key) else { return 0 }
366+
// A kept row is back: its view, and its height, as it left.
367+
if cells[key] == nil, let cell = unkeep(key) { cells[key] = cell }
320368
if let cell = cells[key] {
321369
if seen[key] != generation {
322370
seen[key] = generation
@@ -362,7 +410,7 @@
362410
/// The row's height as it laid itself out: when it's another than the
363411
/// one measured, it's measured again.
364412
func row(_ key: AnyHashable, laidOutAt height: Double) {
365-
guard let old = cells[key]?.height, abs(old - height) > 0.5 else { return }
413+
guard let old = (cells[key] ?? kept[key])?.height, abs(old - height) > 0.5 else { return }
366414
rowHeightDidChange(key)
367415
}
368416
}
@@ -913,6 +961,7 @@
913961
/// The selected row, and what a tap or a key sets it to; nil when rows
914962
/// aren't selectable.
915963
let selection: (key: AnyHashable?, set: (AnyHashable) -> Void)?
964+
let maxKeptRows: Int
916965
let model: VirtualListModel
917966

918967
func make() -> RowsScrollView {
@@ -933,6 +982,7 @@
933982
view.selectable = selection != nil
934983
rows.onSelect = selection?.set ?? { _ in }
935984
rows.selected = selection?.key
985+
rows.maxKeptRows = maxKeptRows
936986
rows.update(source())
937987
}
938988
}

‎swift/Sources/ZeroVirtual/VirtualList.swift‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
private let selectable: Bool
5555
private let header: AnyView?
5656
private let footer: AnyView?
57+
private var maxKeptRows = 0
5758
@State private var model = VirtualListModel()
5859

5960
/// - Parameters:
@@ -99,6 +100,25 @@
99100
self.footer = Self.accessory(footer)
100101
}
101102

103+
/// Keeps the views of up to `count` rows that leave the list as it
104+
/// pages, for when they come back: a row scrolled back to is shown at
105+
/// once, with its state and its own queries as it left them, instead of
106+
/// being made and measured anew, which a fast scroll back can't keep up
107+
/// with. Each kept row holds its view, and its queries stay open, while
108+
/// it's kept; the oldest go first. 0, the default, keeps none.
109+
///
110+
/// ```swift
111+
/// VirtualList(issues) { item in
112+
/// IssueRow(issue: item.row)
113+
/// }
114+
/// .maxKeptRows(200)
115+
/// ```
116+
public func maxKeptRows(_ count: Int) -> Self {
117+
var list = self
118+
list.maxKeptRows = max(0, count)
119+
return list
120+
}
121+
102122
private static func accessory<V: View>(_ content: () -> V) -> AnyView? {
103123
V.self == EmptyView.self ? nil : AnyView(content())
104124
}
@@ -115,7 +135,7 @@
115135
? (
116136
selection.wrappedValue.map { AnyHashable($0) },
117137
{ [selection] key in selection.wrappedValue = key.base as? Key }
118-
) : nil, model: model
138+
) : nil, maxKeptRows: maxKeptRows, model: model
119139
)
120140
.onAppear { model.attach(list) }
121141
.onDisappear { model.detach(list) }

‎swift/Sources/ZeroVirtual/VirtualListView.swift‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,15 @@
181181
}
182182
}
183183

184+
/// How many rows that leave the list as it pages keep their views for
185+
/// when they come back: `row` doesn't make or show them again, and
186+
/// they aren't measured again. Each kept row holds its view while it's
187+
/// kept; the oldest go first. 0, the default, keeps none.
188+
public var maxKeptRows: Int {
189+
get { rows.rows.maxKeptRows }
190+
set { rows.rows.maxKeptRows = max(0, newValue) }
191+
}
192+
184193
private static func accessory(_ view: PlatformView) -> RowsAccessory {
185194
RowsAccessory(view: view) { [unowned view] width in
186195
AppRowViews.fittingHeight(of: view, width: width)

‎swift/Tests/AppleTests/VirtualListTests.swift‎

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,123 @@
237237
withExtendedLifetime((zero, window)) {}
238238
}
239239

240+
final class States {
241+
var byRow: [String: Set<UUID>] = [:]
242+
}
243+
244+
/// A row that records the state it's drawn with: one whose view starts
245+
/// anew has a new one.
246+
struct StatefulRow: View {
247+
let id: String
248+
let states: States
249+
@State private var state = UUID()
250+
251+
var body: some View {
252+
let _ = states.byRow[id, default: []].insert(state)
253+
Text(id).frame(height: 30)
254+
}
255+
}
256+
257+
@Test func aKeptRowComesBackWithItsState() async throws {
258+
let (zero, list) = try makeList()
259+
let states = States()
260+
let window = show(
261+
VirtualList(list) { StatefulRow(id: $0.row.id, states: states) }.maxKeptRows(300))
262+
let top = (0..<10).map(Self.id)
263+
try await run(30) { top.allSatisfy { states.byRow[$0] != nil } }
264+
list.scrollToItem(Self.id(250), align: .end)
265+
try await run(30) { list.lastVisibleItem()?.key == Self.id(250) }
266+
try await run(0.5) { false }
267+
#expect(!list.snapshot.items.contains { $0.key == Self.id(0) })
268+
list.scrollToItem(Self.id(0), align: .start)
269+
try await run(30) { list.firstVisibleItem()?.key == Self.id(0) }
270+
try await run(0.5) { false }
271+
#expect(top.map { states.byRow[$0]?.count } == top.map { _ in 1 })
272+
withExtendedLifetime((zero, window)) {}
273+
}
274+
275+
/// Row views that record what the layout asks of them: plain views,
276+
/// 30 points high.
277+
@MainActor final class CountingRowViews: RowViews {
278+
/// Each row shown in a view, and its state.
279+
var shown: [(key: AnyHashable, state: RowState)] = []
280+
var measured = 0
281+
282+
func view(
283+
for item: RowsItem, _ state: RowState, reusing view: PlatformView?, in layout: RowsLayout
284+
) -> PlatformView {
285+
shown.append((item.key, state))
286+
return view ?? PlatformView(frame: .zero)
287+
}
288+
289+
func height(of view: PlatformView, width: Double) -> Double {
290+
measured += 1
291+
return 30
292+
}
293+
294+
func discard(_ view: PlatformView) {}
295+
}
296+
297+
/// A layout of 30-point rows, with no frames after its passes: what a
298+
/// pass leaves for the next frame doesn't run.
299+
func countingLayout(keeping maxKeptRows: Int) -> (RowsLayout, CountingRowViews) {
300+
let views = CountingRowViews()
301+
let rows = RowsLayout(views: views)
302+
rows.host.estimatedRowHeight = { 30 }
303+
rows.nextFrame = { _ in }
304+
rows.maxKeptRows = maxKeptRows
305+
return (rows, views)
306+
}
307+
308+
/// A pass with rows `range` scrolled to at `width`, with `changed`
309+
/// rows' data changed: the rows it showed in a view.
310+
@discardableResult
311+
func pass(
312+
_ range: Range<Int>, in rows: RowsLayout, _ views: CountingRowViews, width: Double = 300,
313+
changed: Set<Int> = []
314+
) -> [AnyHashable] {
315+
views.shown = []
316+
views.measured = 0
317+
rows.update(
318+
RowsSource(
319+
items: range.map {
320+
RowsItem(
321+
key: AnyHashable(Self.id($0)), index: $0, row: changed.contains($0) ? -1 : $0,
322+
item: $0)
323+
}, spaceBefore: Double(range.lowerBound) * 30))
324+
_ = rows.layout(width: width, offset: Double(range.lowerBound) * 30, viewport: 400)
325+
return views.shown.map(\.key)
326+
}
327+
328+
// Rows that page out keep their views, up to `maxKeptRows`, and come
329+
// back in them, neither shown nor measured again; 0 keeps none.
330+
@Test(arguments: [0, 5, 100])
331+
func keepsTheViewsOfRowsThatLeave(maxKeptRows: Int) {
332+
let (rows, views) = countingLayout(keeping: maxKeptRows)
333+
let first = pass(0..<20, in: rows, views)
334+
pass(100..<120, in: rows, views)
335+
let back = pass(0..<20, in: rows, views)
336+
let anew = first.count - min(maxKeptRows, first.count)
337+
#expect(first.filter(back.contains).count == anew)
338+
#expect(views.measured == anew)
339+
}
340+
341+
// A kept row comes back as it is now: with its new data, in its
342+
// selection, and measured at the list's width, all changed while it was
343+
// kept.
344+
@Test func aKeptRowComesBackAsItIsNow() throws {
345+
let (rows, views) = countingLayout(keeping: 100)
346+
let first = pass(0..<20, in: rows, views)
347+
pass(100..<120, in: rows, views)
348+
rows.selected = AnyHashable(Self.id(3))
349+
pass(100..<120, in: rows, views, width: 250)
350+
let back = pass(0..<20, in: rows, views, width: 250, changed: [1])
351+
#expect(Set(back) == [AnyHashable(Self.id(1)), AnyHashable(Self.id(3))])
352+
let selected = try #require(views.shown.first { $0.key == AnyHashable(Self.id(3)) })
353+
#expect(selected.state.selected)
354+
#expect(views.measured == first.count)
355+
}
356+
240357
@Test func scrollsToARowFarBelow() async throws {
241358
let (zero, list) = try makeList()
242359
let window = show(VirtualList(list) { Text($0.row.text).frame(height: 30) })

0 commit comments

Comments
 (0)