Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/zero-swift-virtual-list-kept-rows.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 3 additions & 0 deletions apps/zbugs-swift/Sources/ZBugsUI/IssueList.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
56 changes: 53 additions & 3 deletions swift/Sources/ZeroVirtual/RowsScrollView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand All @@ -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)) }
Expand All @@ -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() {
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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)
}
}
Expand Down Expand Up @@ -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 {
Expand All @@ -933,6 +982,7 @@
view.selectable = selection != nil
rows.onSelect = selection?.set ?? { _ in }
rows.selected = selection?.key
rows.maxKeptRows = maxKeptRows
rows.update(source())
}
}
Expand Down
22 changes: 21 additions & 1 deletion swift/Sources/ZeroVirtual/VirtualList.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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<V: View>(_ content: () -> V) -> AnyView? {
V.self == EmptyView.self ? nil : AnyView(content())
}
Expand All @@ -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) }
Expand Down
9 changes: 9 additions & 0 deletions swift/Sources/ZeroVirtual/VirtualListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
117 changes: 117 additions & 0 deletions swift/Tests/AppleTests/VirtualListTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,123 @@
withExtendedLifetime((zero, window)) {}
}

final class States {
var byRow: [String: Set<UUID>] = [:]
}

/// 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<Int>, in rows: RowsLayout, _ views: CountingRowViews, width: Double = 300,
changed: Set<Int> = []
) -> [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) })
Expand Down
Loading