From 5d482387df1b58d327a5cde7ff109e355daeb606 Mon Sep 17 00:00:00 2001 From: Amp Date: Fri, 23 Oct 2026 05:14:06 +0000 Subject: [PATCH 1/3] perf(zero-swift): make a page's rows off screen over the next frames A layout pass that brought a new page made and measured every row within a viewport of the screen, 4-6 SwiftUI-hosted rows at about 2 ms each, in the frame that also reopened the page queries: 20-23 ms, two dropped frames at 60 Hz. LayoutHost now measures the rows on screen at once, at most two new rows off screen per pass, and lays out again once the frame is drawn until the rest are measured. Jumps and restores still measure every row in the pass. Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm --- .changeset/spread-row-creation.md | 5 ++ swift/Sources/ZeroVirtual/LayoutHost.swift | 57 +++++++++++++++--- .../ZeroVirtualTests/LayoutHostTests.swift | 59 ++++++++++++++++++- 3 files changed, 112 insertions(+), 9 deletions(-) create mode 100644 .changeset/spread-row-creation.md diff --git a/.changeset/spread-row-creation.md b/.changeset/spread-row-creation.md new file mode 100644 index 0000000000..31851a21ea --- /dev/null +++ b/.changeset/spread-row-creation.md @@ -0,0 +1,5 @@ +--- +'zero-swift': patch +--- + +`VirtualList` and `VirtualListView` make at most two new rows off screen per layout pass. Rows on screen are still made and measured in the pass that shows them; the rest of a newly loaded page is made in the frames after, so loading the next page while scrolling no longer drops frames. diff --git a/swift/Sources/ZeroVirtual/LayoutHost.swift b/swift/Sources/ZeroVirtual/LayoutHost.swift index 4a40dde503..2af14e3e40 100644 --- a/swift/Sources/ZeroVirtual/LayoutHost.swift +++ b/swift/Sources/ZeroVirtual/LayoutHost.swift @@ -1,3 +1,5 @@ +import Dispatch + /// A `VirtualHost` for a scroll view that lays its rows out itself, in one /// layout pass: the rows' frames, the content's height and the offset are /// worked out together, and the view applies them before it draws. The core @@ -18,6 +20,12 @@ /// changing height does, and the core keeps what's on screen in place. While /// a jump or a restore is landing every row is measured: where it lands is /// worked out from the rows above it. +/// +/// Rows on screen are measured in the pass that shows them. Off screen, at +/// most `newRowsPerPass` rows not measured before are, so a pass that +/// brings a page doesn't make all its views in one frame: the rest stand at +/// the estimate, and `later` lays out again once the frame is drawn, until +/// they're all measured. @MainActor package final class LayoutHost: VirtualHost { package private(set) var scrollOffset = 0.0 @@ -48,6 +56,12 @@ package final class LayoutHost: VirtualHost { package var isLanding: () -> Bool = { false } /// Asks the view for a layout pass. package var setNeedsLayout: () -> Void = {} + /// Runs work once the frame is drawn: the main queue's next turn. + package var later: (@escaping @MainActor () -> Void) -> Void = { work in + DispatchQueue.main.async { MainActor.assumeIsolated(work) } + } + /// How many rows off screen, not measured before, one pass measures. + package var newRowsPerPass = 2 private var frames: [VirtualRowFrame] = [] /// The rows' heights as last measured, for when they're out of reach. @@ -60,6 +74,11 @@ package final class LayoutHost: VirtualHost { /// not the view's. private var scrolled = false private var inPass = false + /// New rows off screen this pass may still measure. + private var budget = 0 + /// A pass left rows near the viewport unmeasured, and `later` will lay + /// out again. + private var laterPending = false package init() {} @@ -103,6 +122,7 @@ package final class LayoutHost: VirtualHost { ) { inPass = true defer { inPass = false } + budget = newRowsPerPass if viewport != viewportHeight { viewportHeight = viewport viewportDidResize() @@ -117,11 +137,19 @@ package final class LayoutHost: VirtualHost { } // What the core does about the update can be a new snapshot, or a // scroll to rows not measured yet; they're laid out in this pass too. - for _ in 0..<8 where stale || !nearRowsMeasured() { + for _ in 0..<8 where stale || !rowsOnScreenMeasured() { stale = false place() afterUpdate() } + if !laterPending, !nearRowsMeasured() { + laterPending = true + later { [weak self] in + guard let self else { return } + self.laterPending = false + self.invalidate() + } + } return (frames, contentHeight, scrollOffset) } @@ -135,10 +163,20 @@ package final class LayoutHost: VirtualHost { return (scrollOffset - viewportHeight)...(scrollOffset + 2 * viewportHeight) } - private func nearRowsMeasured() -> Bool { - let reach = reach - return frames.allSatisfy { - $0.minY > reach.upperBound || $0.maxY < reach.lowerBound || measured.contains($0.key) + /// Where rows are measured in the pass that draws them, however many are + /// new: the viewport, or all of them while a jump or a restore lands. + private var screen: ClosedRange { + if isLanding() { return reach } + return scrollOffset...(scrollOffset + viewportHeight) + } + + private func nearRowsMeasured() -> Bool { allMeasured(in: reach) } + + private func rowsOnScreenMeasured() -> Bool { allMeasured(in: screen) } + + private func allMeasured(in range: ClosedRange) -> Bool { + frames.allSatisfy { + $0.minY > range.upperBound || $0.maxY < range.lowerBound || measured.contains($0.key) } } @@ -146,6 +184,7 @@ package final class LayoutHost: VirtualHost { let r = rows() headerHeight = header() let reach = reach + let screen = screen let estimate = estimatedRowHeight() var known: [AnyHashable: Double] = [:] measured = [] @@ -153,8 +192,12 @@ package final class LayoutHost: VirtualHost { frames = r.keys.map { item in var height = heights[item.key] ?? estimate if y <= reach.upperBound && y + height >= reach.lowerBound { - height = measure(item.key) - measured.insert(item.key) + let onScreen = y <= screen.upperBound && y + height >= screen.lowerBound + if heights[item.key] != nil || onScreen || budget > 0 { + if heights[item.key] == nil && !onScreen { budget -= 1 } + height = measure(item.key) + measured.insert(item.key) + } } if measured.contains(item.key) || heights[item.key] != nil { known[item.key] = height } defer { y += height } diff --git a/swift/Tests/ZeroVirtualTests/LayoutHostTests.swift b/swift/Tests/ZeroVirtualTests/LayoutHostTests.swift index 824a6b10b8..391cdcb1f0 100644 --- a/swift/Tests/ZeroVirtualTests/LayoutHostTests.swift +++ b/swift/Tests/ZeroVirtualTests/LayoutHostTests.swift @@ -25,6 +25,8 @@ final class LayoutScrollView { /// Each drawn row's top, from the viewport's top, as of the last pass. private var drawn: [AnyHashable: Double] = [:] private var unsubscribe: (() -> Void)? + /// What the host asked to run once the frame is drawn. + var later: [@MainActor () -> Void] = [] init(_ h: Harness, viewportHeight: Double = 400) { self.h = h @@ -45,6 +47,7 @@ final class LayoutScrollView { host.viewportDidResize = { [unowned self] in self.h.core.viewportDidResize() } host.afterUpdate = { [unowned self] in self.h.core.afterUpdate() } host.isLanding = { [unowned self] in self.h.core.isLanding } + host.later = { [unowned self] in self.later.append($0) } unsubscribe = h.core.subscribe { [unowned self] in self.host.invalidate() } h.core.attach(host) } @@ -62,7 +65,18 @@ final class LayoutScrollView { for f in r.frames { drawn[f.key] = f.minY - r.offset } } - /// Passes until the query and the layout stop changing. With `holding`, + /// Runs what the host asked to run once the frame was drawn; whether + /// there was any. + @discardableResult + func frameDrawn() -> Bool { + let work = later + later = [] + for w in work { w() } + return !work.isEmpty + } + + /// Passes, the frames drawn between them, until the query and the layout + /// stop changing. With `holding`, /// that row, while drawn, is drawn in the same place on every pass. func settle(holding key: String? = nil, sourceLocation: SourceLocation = #_sourceLocation) { let top = key.flatMap(rowTop) @@ -72,7 +86,8 @@ final class LayoutScrollView { if let key, let top, let now = rowTop(key) { #expect(now == top, "\(key) drawn moved", sourceLocation: sourceLocation) } - if (h.queryKey(h.core.queryInputs), offset, h.core.snapshot.spaceBefore) == before, + if !frameDrawn(), + (h.queryKey(h.core.queryInputs), offset, h.core.snapshot.spaceBefore) == before, h.core.snapshot.complete { return @@ -475,3 +490,43 @@ final class LayoutScrollView { } } } + +@MainActor +@Suite("Layout host, rows made over frames") struct LayoutHostBudgetTests { + @Test( + "a page's rows off screen are measured a few per frame, the rows on screen in the pass that shows them" + ) + func spreadsNewRows() { + let h = Harness(rowCount: 1000) { $0.anchoring = .manual } + let view = LayoutScrollView(h) + for i in 0..<1000 { view.heights["r\(i)"] = i % 3 == 0 ? 50 : 30 } + view.settle() + for _ in 0..<40 { + let key = h.core.firstVisibleItem()!.key + let top = try! #require(view.rowTop(key)) + let known = Set(view.measures.keys) + view.userScroll(view.scrollOffset + 150) + #expect(view.rowTop(key) == top - 150) + let offset = view.scrollOffset + var newOff = 0 + for f in view.host.rowFrames() { + let k = f.key.base as! String + let onScreen = f.maxY > offset && f.minY < offset + 400 + if onScreen { #expect(view.measures[k] != nil, "\(k) on screen and not measured") } + if !onScreen, view.measures[k] != nil, !known.contains(k) { newOff += 1 } + } + #expect(newOff <= 2, "\(newOff) new rows off screen in one pass") + // The frames drawn after it measure the rest within reach, nothing + // on screen moving. + let held = h.core.firstVisibleItem()!.key + let heldTop = try! #require(view.rowTop(held)) + for _ in 0..<40 where view.frameDrawn() { + view.pass() + #expect(view.rowTop(held) == heldTop, "\(held) moved as rows off screen were measured") + } + for f in view.host.rowFrames() where f.maxY >= offset - 400 && f.minY <= offset + 800 { + #expect(view.measures[f.key.base as! String] != nil, "\(f.key) near and never measured") + } + } + } +} From b07dce09ea9052f6febf883c8bdaa02443548056 Mon Sep 17 00:00:00 2001 From: Amp Date: Fri, 23 Oct 2026 06:34:59 +0000 Subject: [PATCH 2/3] fix(zero-swift): measure the off-screen rows nearest the screen first The per-pass budget of new rows off screen went to the rows from the top of the window down, so flinging up spent it on rows far above the screen while the ones just above it reached the screen at the estimate, and measuring them there moved the rows on screen. Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm --- swift/Sources/ZeroVirtual/LayoutHost.swift | 20 +++++++++++--- .../ZeroVirtualTests/LayoutHostTests.swift | 27 +++++++++++++++++++ 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/swift/Sources/ZeroVirtual/LayoutHost.swift b/swift/Sources/ZeroVirtual/LayoutHost.swift index 2af14e3e40..7355b22c0a 100644 --- a/swift/Sources/ZeroVirtual/LayoutHost.swift +++ b/swift/Sources/ZeroVirtual/LayoutHost.swift @@ -22,7 +22,8 @@ import Dispatch /// worked out from the rows above it. /// /// Rows on screen are measured in the pass that shows them. Off screen, at -/// most `newRowsPerPass` rows not measured before are, so a pass that +/// most `newRowsPerPass` rows not measured before are, those nearest the +/// screen first (the next a scroll shows, either way), so a pass that /// brings a page doesn't make all its views in one frame: the rest stand at /// the estimate, and `later` lays out again once the frame is drawn, until /// they're all measured. @@ -188,13 +189,26 @@ package final class LayoutHost: VirtualHost { let estimate = estimatedRowHeight() var known: [AnyHashable: Double] = [:] measured = [] + // The new rows off screen this pass measures: those nearest the screen, + // which a scroll shows next, either way it goes. + var ahead: [(key: AnyHashable, distance: Double)] = [] var y = headerHeight + r.before + for item in r.keys where budget > 0 { + let height = heights[item.key] ?? estimate + defer { y += height } + guard heights[item.key] == nil, y <= reach.upperBound, y + height >= reach.lowerBound, + y > screen.upperBound || y + height < screen.lowerBound + else { continue } + ahead.append((item.key, max(y - screen.upperBound, screen.lowerBound - y - height))) + } + let next = Set(ahead.sorted { $0.distance < $1.distance }.prefix(budget).map(\.key)) + budget -= next.count + y = headerHeight + r.before frames = r.keys.map { item in var height = heights[item.key] ?? estimate if y <= reach.upperBound && y + height >= reach.lowerBound { let onScreen = y <= screen.upperBound && y + height >= screen.lowerBound - if heights[item.key] != nil || onScreen || budget > 0 { - if heights[item.key] == nil && !onScreen { budget -= 1 } + if heights[item.key] != nil || onScreen || next.contains(item.key) { height = measure(item.key) measured.insert(item.key) } diff --git a/swift/Tests/ZeroVirtualTests/LayoutHostTests.swift b/swift/Tests/ZeroVirtualTests/LayoutHostTests.swift index 391cdcb1f0..9d0de80b83 100644 --- a/swift/Tests/ZeroVirtualTests/LayoutHostTests.swift +++ b/swift/Tests/ZeroVirtualTests/LayoutHostTests.swift @@ -529,4 +529,31 @@ final class LayoutScrollView { } } } + + @Test("scrolling up, the new rows measured off screen are the ones just above it") + func measuresNearestRowsFirst() { + let h = Harness(rowCount: 1000) { $0.anchoring = .manual } + let view = LayoutScrollView(h) + for i in 0..<1000 { view.heights["r\(i)"] = i % 3 == 0 ? 50 : 30 } + view.settle() + for _ in 0..<30 { + view.userScroll(view.scrollOffset + 300) + view.settle() + } + for _ in 0..<10 { + // Up by more than the reach above the screen: every row above the + // screen, within reach, is new. + view.measures = [:] + view.userScroll(view.scrollOffset - 600) + let offset = view.scrollOffset + let above = view.host.rowFrames().filter { $0.maxY <= offset && $0.maxY >= offset - 400 } + .map { (key: $0.key.base as! String, distance: offset - $0.maxY) } + let made = above.filter { view.measures[$0.key] != nil }.map(\.distance) + let waiting = above.filter { view.measures[$0.key] == nil }.map(\.distance) + if let farthest = made.max(), let nearest = waiting.min() { + #expect(farthest <= nearest, "a row \(farthest) above measured before one \(nearest) above") + } + view.settle() + } + } } From 6fc38076d5b3eb0175f75be80247aa32854064c1 Mon Sep 17 00:00:00 2001 From: Amp Date: Fri, 23 Oct 2026 17:26:22 +0000 Subject: [PATCH 3/3] perf(zero-swift): reuse row views and make off-screen rows within a frame budget Rows that leave are hidden and pooled, and a pooled view (for VirtualList, its hosting controller, its root given the row's key as its identity) shows the next row that comes; spare views beyond 16 are released two per frame. Rows off screen that a page brings are made from a display link, nearest the screen first, until 4 ms of the frame is spent, instead of on the main queue's next turn, which ran before the frame was drawn. Rows on screen are still made in the pass that shows them; landing measures every row. Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm --- .changeset/spread-row-creation.md | 2 +- swift/Sources/ZeroVirtual/LayoutHost.swift | 100 ++++++++------ .../Sources/ZeroVirtual/RowsScrollView.swift | 130 ++++++++++++++---- swift/Tests/AppleTests/VirtualListTests.swift | 28 ++++ .../ZeroVirtualTests/LayoutHostTests.swift | 31 +++-- 5 files changed, 218 insertions(+), 73 deletions(-) diff --git a/.changeset/spread-row-creation.md b/.changeset/spread-row-creation.md index 31851a21ea..063ad95618 100644 --- a/.changeset/spread-row-creation.md +++ b/.changeset/spread-row-creation.md @@ -2,4 +2,4 @@ 'zero-swift': patch --- -`VirtualList` and `VirtualListView` make at most two new rows off screen per layout pass. Rows on screen are still made and measured in the pass that shows them; the rest of a newly loaded page is made in the frames after, so loading the next page while scrolling no longer drops frames. +`VirtualList` and `VirtualListView` reuse row views, as `UICollectionView` reuses cells: a row that scrolls away is hidden and its view (for `VirtualList`, its hosting controller) is given to the next row that comes, with the row's key as its identity, so state in a row's view stays with its row as in a `List`. Views beyond those kept are released a few per frame. Rows on screen are still made and measured in the pass that shows them; the rest of a newly loaded page is made in the frames after, nearest the screen first, up to a few milliseconds each frame, so loading the next page while scrolling no longer drops frames. diff --git a/swift/Sources/ZeroVirtual/LayoutHost.swift b/swift/Sources/ZeroVirtual/LayoutHost.swift index 7355b22c0a..01c36a0f32 100644 --- a/swift/Sources/ZeroVirtual/LayoutHost.swift +++ b/swift/Sources/ZeroVirtual/LayoutHost.swift @@ -21,12 +21,12 @@ import Dispatch /// a jump or a restore is landing every row is measured: where it lands is /// worked out from the rows above it. /// -/// Rows on screen are measured in the pass that shows them. Off screen, at -/// most `newRowsPerPass` rows not measured before are, those nearest the -/// screen first (the next a scroll shows, either way), so a pass that -/// brings a page doesn't make all its views in one frame: the rest stand at -/// the estimate, and `later` lays out again once the frame is drawn, until -/// they're all measured. +/// Rows on screen are measured in the pass that shows them. Rows off screen +/// not measured before are measured in the frames after, nearest the screen +/// first (the next a scroll shows, either way): at the start of each frame +/// (`nextFrame`), its pass measures them for up to `frameBudget`, so a pass +/// that brings a page doesn't make all its views in one frame. Until then +/// they stand at the estimate. @MainActor package final class LayoutHost: VirtualHost { package private(set) var scrollOffset = 0.0 @@ -57,12 +57,15 @@ package final class LayoutHost: VirtualHost { package var isLanding: () -> Bool = { false } /// Asks the view for a layout pass. package var setNeedsLayout: () -> Void = {} - /// Runs work once the frame is drawn: the main queue's next turn. - package var later: (@escaping @MainActor () -> Void) -> Void = { work in + /// Runs work at the start of the next frame: the view's display link. + package var nextFrame: (@escaping @MainActor () -> Void) -> Void = { work in DispatchQueue.main.async { MainActor.assumeIsolated(work) } } - /// How many rows off screen, not measured before, one pass measures. - package var newRowsPerPass = 2 + /// The time, in seconds. + package var now: () -> Double = { Double(DispatchTime.now().uptimeNanoseconds) / 1e9 } + /// How long, from a frame's first pass, its passes may spend measuring + /// rows off screen not measured before, in seconds. + package var frameBudget = 0.004 private var frames: [VirtualRowFrame] = [] /// The rows' heights as last measured, for when they're out of reach. @@ -75,11 +78,13 @@ package final class LayoutHost: VirtualHost { /// not the view's. private var scrolled = false private var inPass = false - /// New rows off screen this pass may still measure. - private var budget = 0 - /// A pass left rows near the viewport unmeasured, and `later` will lay - /// out again. - private var laterPending = false + /// Until when rows off screen not measured before are measured: the + /// frame's budget, from its first pass. + private var deadline = -Double.infinity + private var frameStarted = false + /// A pass left rows near the viewport unmeasured, and the next frame will + /// lay out again. + private var framePending = false package init() {} @@ -123,7 +128,10 @@ package final class LayoutHost: VirtualHost { ) { inPass = true defer { inPass = false } - budget = newRowsPerPass + if frameStarted { + frameStarted = false + deadline = now() + frameBudget + } if viewport != viewportHeight { viewportHeight = viewport viewportDidResize() @@ -143,12 +151,13 @@ package final class LayoutHost: VirtualHost { place() afterUpdate() } - if !laterPending, !nearRowsMeasured() { - laterPending = true - later { [weak self] in + if !framePending, !nearRowsMeasured() { + framePending = true + nextFrame { [weak self] in guard let self else { return } - self.laterPending = false - self.invalidate() + framePending = false + frameStarted = true + invalidate() } } return (frames, contentHeight, scrollOffset) @@ -184,31 +193,46 @@ package final class LayoutHost: VirtualHost { private func place() { let r = rows() headerHeight = header() + measured = [] + stack(r) + // Rows off screen not measured before, while the frame's budget lasts: + // those nearest the screen first, which a scroll shows next, either way + // it goes, each stacked where the rows measured before it put it. + let reach = reach + let screen = screen + while now() < deadline { + let new = frames.filter { + !measured.contains($0.key) && $0.minY <= reach.upperBound && $0.maxY >= reach.lowerBound + } + guard + let row = new.min(by: { + max($0.minY - screen.upperBound, screen.lowerBound - $0.maxY) + < max($1.minY - screen.upperBound, screen.lowerBound - $1.maxY) + }) + else { break } + heights[row.key] = measure(row.key) + stack(r) + } + footerHeight = footer() + contentHeight = (frames.last?.maxY ?? headerHeight + r.before) + r.after + footerHeight + } + + /// The rows stacked from the top: those within reach measured if they + /// were before or are on screen, the rest at their last height or the + /// estimate. + private func stack( + _ r: (keys: [(key: AnyHashable, index: Int)], before: Double, after: Double) + ) { let reach = reach let screen = screen let estimate = estimatedRowHeight() var known: [AnyHashable: Double] = [:] - measured = [] - // The new rows off screen this pass measures: those nearest the screen, - // which a scroll shows next, either way it goes. - var ahead: [(key: AnyHashable, distance: Double)] = [] var y = headerHeight + r.before - for item in r.keys where budget > 0 { - let height = heights[item.key] ?? estimate - defer { y += height } - guard heights[item.key] == nil, y <= reach.upperBound, y + height >= reach.lowerBound, - y > screen.upperBound || y + height < screen.lowerBound - else { continue } - ahead.append((item.key, max(y - screen.upperBound, screen.lowerBound - y - height))) - } - let next = Set(ahead.sorted { $0.distance < $1.distance }.prefix(budget).map(\.key)) - budget -= next.count - y = headerHeight + r.before frames = r.keys.map { item in var height = heights[item.key] ?? estimate if y <= reach.upperBound && y + height >= reach.lowerBound { let onScreen = y <= screen.upperBound && y + height >= screen.lowerBound - if heights[item.key] != nil || onScreen || next.contains(item.key) { + if heights[item.key] != nil || onScreen { height = measure(item.key) measured.insert(item.key) } @@ -218,7 +242,5 @@ package final class LayoutHost: VirtualHost { return VirtualRowFrame(index: item.index, key: item.key, minY: y, maxY: y + height) } heights = known - footerHeight = footer() - contentHeight = y + r.after + footerHeight } } diff --git a/swift/Sources/ZeroVirtual/RowsScrollView.swift b/swift/Sources/ZeroVirtual/RowsScrollView.swift index 9594a37459..338e7fb835 100644 --- a/swift/Sources/ZeroVirtual/RowsScrollView.swift +++ b/swift/Sources/ZeroVirtual/RowsScrollView.swift @@ -53,21 +53,20 @@ /// How a row's view is made and measured: hosted SwiftUI /// (`HostedRowViews`), or the app's own (`AppRowViews`). The rest of the - /// layout is the same for both. + /// layout is the same for both: views of rows that leave are kept, hidden, + /// for rows that come, as a collection view's cells are. @MainActor protocol RowViews: AnyObject { - /// Views of rows that leave are kept, hidden, for rows that come. - var reusesViews: Bool { get } /// The view showing `item` in `state`: `view` shown anew (the row's - /// own when its row or state changed, or, when views are reused, one a - /// row left), or a new one when it's nil. + /// own when its row or state changed, or one a row left), or a new one + /// when it's nil. func view( for item: RowsItem, _ state: RowState, reusing view: PlatformView?, in layout: RowsLayout ) -> PlatformView /// The view's height at `width`. func height(of view: PlatformView, width: Double) -> Double - /// The view's row left and the view isn't reused. + /// The view is no longer kept. func discard(_ view: PlatformView) } @@ -89,8 +88,10 @@ let views: any RowViews var source = RowsSource() private var cells: [AnyHashable: RowCell] = [:] - /// Views rows left, hidden, for other rows (when `views` reuses them). + /// Views rows left, hidden, for other rows: as many as `spareViews` + /// kept, the rest released a few a frame. private var pool: [PlatformView] = [] + private var releasing = false private var width = 0.0 /// Changes with each snapshot: rows are compared with their cells' then. private var generation = 0 @@ -99,6 +100,14 @@ /// a jump: its first frame is where the jump lands, not where it starts. private(set) var shown = false var addView: (PlatformView) -> Void = { _ in } + /// Runs work at the start of the next frame: the view's display link. + var nextFrame: (@escaping @MainActor () -> Void) -> Void = { work in + DispatchQueue.main.async { MainActor.assumeIsolated(work) } + } + /// The views kept for rows that come, beyond which the rest are + /// released, and how many a frame. + static let spareViews = 16 + static let releasedPerFrame = 2 /// The views above and below the rows. var header: RowsAccessory? { @@ -138,6 +147,7 @@ host.measure = { [unowned self] key in measure(key) } host.header = { [unowned self] in height(of: header) } host.footer = { [unowned self] in height(of: footer) } + host.nextFrame = { [unowned self] work in nextFrame(work) } } /// A new snapshot to draw. @@ -196,14 +206,10 @@ for (key, cell) in cells where !live.contains(key) { cells[key] = nil seen[key] = nil - if views.reusesViews { - cell.view.isHidden = true - pool.append(cell.view) - } else { - cell.view.removeFromSuperview() - views.discard(cell.view) - } + cell.view.isHidden = true + pool.append(cell.view) } + releaseSpares() var accessories: [(view: PlatformView, minY: Double, maxY: Double)] = [] if let header { accessories.append((header.view, 0, host.headerHeight)) } if let footer { @@ -216,6 +222,26 @@ ) } + /// 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() { + guard !releasing, pool.count > Self.spareViews else { return } + releasing = true + nextFrame { [weak self] in + guard let self else { return } + releasing = false + let n = min(Self.releasedPerFrame, pool.count - Self.spareViews) + guard n > 0 else { return } + // The ones left longest ago: the latest are reused first. + for view in pool.prefix(n) { + view.removeFromSuperview() + views.discard(view) + } + pool.removeFirst(n) + releaseSpares() + } + } + /// The header and the footer are hidden while the rows are: until the /// list has landed where it opens. var accessoriesHidden: Bool { !shown && host.isLanding() } @@ -293,7 +319,7 @@ } return measure(cell) } - let reused = views.reusesViews ? pool.popLast() : nil + let reused = pool.popLast() let view = views.view(for: item, state(key), reusing: reused, in: self) if view !== reused { if let reused { pool.append(reused) } @@ -343,9 +369,10 @@ fileprivate func isEqual(to other: Any) -> Bool { (other as? Self) == self } } - /// SwiftUI rows, each in its own hosting controller. A row's controller - /// isn't reused for another row: it would show the row it had until its - /// next layout. + /// SwiftUI rows, each in a hosting controller, reused for the rows that + /// come as a collection view reuses a cell. A reused controller's view has + /// the row's key for its identity, as a `List`'s row has its id: state in + /// the row's view belongs to its row, and starts anew in another. @MainActor final class HostedRowViews: RowViews { #if canImport(UIKit) @@ -358,8 +385,6 @@ init(row: @escaping (Any, Bool) -> AnyView) { self.row = row } - var reusesViews: Bool { false } - func view( for item: RowsItem, _ state: RowState, reusing view: PlatformView?, in layout: RowsLayout ) @@ -386,7 +411,8 @@ } action: { [weak layout] h in // The row changed height on its own, as a view inside it loaded. layout?.row(key, laidOutAt: h) - }) + } + .id(key)) return controller.view } @@ -448,8 +474,6 @@ init(make: @escaping (Any, Bool, PlatformView?) -> PlatformView) { self.make = make } - var reusesViews: Bool { true } - func view( for item: RowsItem, _ state: RowState, reusing view: PlatformView?, in layout: RowsLayout ) @@ -502,6 +526,57 @@ func discard(_ view: PlatformView) {} } + /// Runs work at the start of the next frame, from the view's display link, + /// which runs only while there's work. + @MainActor + final class FrameLink: NSObject { + private weak var view: PlatformView? + private var link: CADisplayLink? + private var work: [@MainActor () -> Void] = [] + + init(view: PlatformView) { self.view = view } + + func next(_ work: @escaping @MainActor () -> Void) { + self.work.append(work) + guard link == nil else { return } + #if canImport(UIKit) + let link = CADisplayLink(target: self, selector: #selector(frame)) + #else + // Out of a window, there are no frames: the main queue's next turn. + guard let view, view.window != nil else { + DispatchQueue.main.async { [weak self] in self?.frame() } + return + } + let link = view.displayLink(target: self, selector: #selector(frame)) + #endif + link.add(to: .main, forMode: .common) + self.link = link + } + + @objc private func frame() { + let work = self.work + self.work = [] + for w in work { w() } + if self.work.isEmpty { + link?.invalidate() + link = nil + } + } + + #if !canImport(UIKit) + /// The view moved to another window, or out of one: its link, the old + /// window's, no longer runs. + func viewDidMoveToWindow() { + guard let link else { return } + link.invalidate() + self.link = nil + let work = self.work + self.work = [] + for w in work { next(w) } + } + #endif + } + #if canImport(UIKit) /// A scroll view that lays out the rows itself: their frames, the /// header's and the footer's, its content size and its offset, all in @@ -509,6 +584,7 @@ final class RowsScrollView: UIScrollView, UIGestureRecognizerDelegate { let rows: RowsLayout private var laying = false + private lazy var frameLink = FrameLink(view: self) /// Behind the selected row. let highlight = UIView() private lazy var tap = UITapGestureRecognizer(target: self, action: #selector(tapped)) @@ -524,6 +600,7 @@ tap.cancelsTouchesInView = false tap.delegate = self rows.host.setNeedsLayout = { [weak self] in self?.setNeedsLayout() } + rows.nextFrame = { [weak self] in self?.frameLink.next($0) } rows.addView = { [weak self] view in self?.addSubview(view) } } @@ -624,6 +701,7 @@ let rows: RowsLayout let document = RowsDocumentView() private var laying = false + private lazy var frameLink = FrameLink(view: self) /// Behind the selected row. let highlight = RowsHighlightView() private lazy var click = NSClickGestureRecognizer(target: self, action: #selector(clicked)) @@ -645,6 +723,7 @@ self, selector: #selector(clipChanged), name: name, object: contentView) } rows.host.setNeedsLayout = { [weak self] in self?.document.needsLayout = true } + rows.nextFrame = { [weak self] in self?.frameLink.next($0) } rows.addView = { [weak self] view in self?.document.addSubview(view) } } @@ -726,6 +805,11 @@ rows.userSelect(key) } + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + frameLink.viewDidMoveToWindow() + } + override func viewWillMove(toWindow newWindow: NSWindow?) { super.viewWillMove(toWindow: newWindow) let center = NotificationCenter.default diff --git a/swift/Tests/AppleTests/VirtualListTests.swift b/swift/Tests/AppleTests/VirtualListTests.swift index ca420f9f9a..ea1d70e255 100644 --- a/swift/Tests/AppleTests/VirtualListTests.swift +++ b/swift/Tests/AppleTests/VirtualListTests.swift @@ -184,6 +184,34 @@ #endif } + /// A row with state of its own: set on the first row only. + struct MarkedRow: View { + let id: String + let seen: Seen + @State private var marked = false + + var body: some View { + let _ = marked ? seen.texts.insert(id) : nil + Text(id).frame(height: 30).onAppear { if id == VirtualListTests.id(0) { marked = true } } + } + } + + // A row's view is reused for the rows that come, as a `List`'s are; its + // state stays with its row. + @Test func aRowsStateDoesNotGoToTheRowReusingItsView() async throws { + let (zero, list) = try makeList() + let seen = Seen() + let window = show(VirtualList(list) { MarkedRow(id: $0.row.id, seen: seen) }) + try await run(30) { seen.texts.contains(Self.id(0)) } + for i in stride(from: 20, through: 120, by: 20) { + list.scrollToItem(Self.id(i), align: .end) + try await run(30) { list.lastVisibleItem()?.key == Self.id(i) } + } + try await run(0.5) { false } + #expect(seen.texts == [Self.id(0)]) + withExtendedLifetime((zero, window)) {} + } + @Test func scrollsToARowFarBelow() async throws { let (zero, list) = try makeList() let window = show(VirtualList(list) { Text($0.row.text).frame(height: 30) }) diff --git a/swift/Tests/ZeroVirtualTests/LayoutHostTests.swift b/swift/Tests/ZeroVirtualTests/LayoutHostTests.swift index 9d0de80b83..cf8cacbd31 100644 --- a/swift/Tests/ZeroVirtualTests/LayoutHostTests.swift +++ b/swift/Tests/ZeroVirtualTests/LayoutHostTests.swift @@ -25,8 +25,10 @@ final class LayoutScrollView { /// Each drawn row's top, from the viewport's top, as of the last pass. private var drawn: [AnyHashable: Double] = [:] private var unsubscribe: (() -> Void)? - /// What the host asked to run once the frame is drawn. - var later: [@MainActor () -> Void] = [] + /// What the host asked to run at the start of the next frame. + var nextFrame: [@MainActor () -> Void] = [] + /// The time: a frame takes 1/60 s, making a row 1 ms. + var clock = 0.0 init(_ h: Harness, viewportHeight: Double = 400) { self.h = h @@ -37,6 +39,8 @@ final class LayoutScrollView { } host.measure = { [unowned self] key in let key = key.base as! String + // Making a row's view takes the time; its height is kept. + if self.measures[key] == nil { self.clock += 0.001 } self.measures[key, default: 0] += 1 return self.heights[key] ?? self.h.view.rowHeight } @@ -47,7 +51,8 @@ final class LayoutScrollView { host.viewportDidResize = { [unowned self] in self.h.core.viewportDidResize() } host.afterUpdate = { [unowned self] in self.h.core.afterUpdate() } host.isLanding = { [unowned self] in self.h.core.isLanding } - host.later = { [unowned self] in self.later.append($0) } + host.nextFrame = { [unowned self] in self.nextFrame.append($0) } + host.now = { [unowned self] in self.clock } unsubscribe = h.core.subscribe { [unowned self] in self.host.invalidate() } h.core.attach(host) } @@ -65,12 +70,13 @@ final class LayoutScrollView { for f in r.frames { drawn[f.key] = f.minY - r.offset } } - /// Runs what the host asked to run once the frame was drawn; whether + /// The next frame starts: runs what the host asked to run then; whether /// there was any. @discardableResult func frameDrawn() -> Bool { - let work = later - later = [] + clock += 1.0 / 60 + let work = nextFrame + nextFrame = [] for w in work { w() } return !work.isEmpty } @@ -98,6 +104,7 @@ final class LayoutScrollView { /// The user scrolls to `y`, within the content, and the view lays out. func userScroll(_ y: Double) { + clock += 1.0 / 60 offset = max(0, min(y, host.contentHeight - viewportHeight)) pass() } @@ -494,7 +501,7 @@ final class LayoutScrollView { @MainActor @Suite("Layout host, rows made over frames") struct LayoutHostBudgetTests { @Test( - "a page's rows off screen are measured a few per frame, the rows on screen in the pass that shows them" + "a page's rows off screen are measured in later frames, the rows on screen in the pass that shows them" ) func spreadsNewRows() { let h = Harness(rowCount: 1000) { $0.anchoring = .manual } @@ -515,13 +522,17 @@ final class LayoutScrollView { if onScreen { #expect(view.measures[k] != nil, "\(k) on screen and not measured") } if !onScreen, view.measures[k] != nil, !known.contains(k) { newOff += 1 } } - #expect(newOff <= 2, "\(newOff) new rows off screen in one pass") - // The frames drawn after it measure the rest within reach, nothing - // on screen moving. + #expect(newOff == 0, "\(newOff) new rows off screen in the scroll's pass") + // The frames after it measure the rest within reach, as many as the + // frame's budget (4 ms, a row taking 1 ms) starts, nothing on screen + // moving. let held = h.core.firstVisibleItem()!.key let heldTop = try! #require(view.rowTop(held)) for _ in 0..<40 where view.frameDrawn() { + let before = view.measures.count view.pass() + let made = view.measures.count - before + #expect(made <= 5, "\(made) rows in a frame") #expect(view.rowTop(held) == heldTop, "\(held) moved as rows off screen were measured") } for f in view.host.rowFrames() where f.maxY >= offset - 400 && f.minY <= offset + 800 {