Skip to content

Commit 9f6b1cd

Browse files
authored
fix(zero-swift): stop macOS virtual list rows laying out on every scroll frame (#202)
Flinging zbugs-swift's issue list on macOS, every row the list kept laid itself out on every frame: about 52 NSHostingView layouts a frame, three in four of them off screen. Each row's hosting controller followed the safe area (the default on macOS; iOS already turned it off), so as the list scrolled, AppKit's update-constraints pass ran each row's SizeConstraints.update -> minSize() -> updateSafeArea(), then its layout and render. In a Time Profiler trace of fast flings that was about 17% of the main thread. Rows now take no safe area on either platform; they draw the same (screenshots before and after match, but for the menu bar clock). A layout pass also looked each row near the screen up with a linear first(where:) over the snapshot's rows, every time it stacked them: 3-4% of the main thread. RowsLayout now keeps each row's position by key. Release builds on a Mac satellite (60 Hz display, 155 open issues), the same scripted trackpad flings (about 36,000 pt), three alternating runs: - row layouts a frame: 52 -> 0.5 (0.87 -> 0.23 ms a frame) - main thread busy over 4 ms: 1177 / 1199 / 1177 -> 349 / 351 / 381 - over 8.3 ms (a 120 Hz frame): 166 / 171 / 158 -> 117 / 135 / 146 - over 16.7 ms: 17 / 18 / 18 -> 12 / 11 / 10 - frames dropped at 60 Hz: 12 / 9 / 11 -> 12 / 9 / 7 The stalls left over 8.3 ms are measuring rows that come into reach (SwiftUI sizeThatFits) and page changes (the virtualizer reopening its queries from didScroll inside the layout pass). Amp-Thread-ID: https://ampcode.com/threads/T-02a95685-6d19-4dd4-b1de-8210ec5599df Co-authored-by: tmm <tmm@tmm.dev>
1 parent 16ee0b8 commit 9f6b1cd

2 files changed

Lines changed: 21 additions & 5 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+
Cut the main-thread work of scrolling a `VirtualList` on macOS. Each row's hosting view followed the safe area, so on every frame the list scrolled, every row it kept (on screen or not) worked out its safe area insets and size constraints again and laid itself out: about 50 row layouts a frame. Rows no longer follow the safe area, as on iOS. A layout pass also finds each row by its key instead of searching the snapshot's rows for it.

‎swift/Sources/ZeroVirtual/RowsScrollView.swift‎

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,10 @@
8686
final class RowsLayout {
8787
let host = LayoutHost()
8888
let views: any RowViews
89-
var source = RowsSource()
89+
private(set) var source = RowsSource()
90+
/// Each row's place in `source.items`, by key: a layout pass looks up
91+
/// every row near the screen, each time it stacks them.
92+
private var positions: [AnyHashable: Int] = [:]
9093
private var cells: [AnyHashable: RowCell] = [:]
9194
/// Views rows left, hidden, for other rows: as many as `spareViews`
9295
/// kept, the rest released a few a frame.
@@ -153,10 +156,16 @@
153156
/// A new snapshot to draw.
154157
func update(_ source: RowsSource) {
155158
self.source = source
159+
positions = Dictionary(
160+
source.items.enumerated().map { ($1.key, $0) }, uniquingKeysWith: { first, _ in first })
156161
generation += 1
157162
host.invalidate()
158163
}
159164

165+
private func item(_ key: AnyHashable) -> RowsItem? {
166+
positions[key].map { source.items[$0] }
167+
}
168+
160169
/// The row with `key` changed height on its own: it's measured again in
161170
/// the next layout pass.
162171
func rowHeightDidChange(_ key: AnyHashable) {
@@ -283,8 +292,7 @@
283292

284293
/// The row with `key` is shown again, in its state now.
285294
private func reshow(_ key: AnyHashable?) {
286-
guard let key, let cell = cells[key], let item = source.items.first(where: { $0.key == key })
287-
else { return }
295+
guard let key, let cell = cells[key], let item = item(key) else { return }
288296
show(item, in: cell)
289297
host.invalidate()
290298
}
@@ -308,7 +316,7 @@
308316
}
309317

310318
private func measure(_ key: AnyHashable) -> Double {
311-
guard let item = source.items.first(where: { $0.key == key }) else { return 0 }
319+
guard let item = item(key) else { return 0 }
312320
if let cell = cells[key] {
313321
if seen[key] != generation {
314322
seen[key] = generation
@@ -445,8 +453,11 @@
445453

446454
private func makeController() -> Controller {
447455
let controller = Controller(rootView: AnyView(EmptyView()))
456+
// No safe area: following it, every row's view (on screen or not)
457+
// worked its insets out again, and laid itself out, on each frame the
458+
// list scrolled.
459+
controller.safeAreaRegions = []
448460
#if canImport(UIKit)
449-
controller.safeAreaRegions = []
450461
controller.view.backgroundColor = .clear
451462
#else
452463
controller.sizingOptions = []

0 commit comments

Comments
 (0)