From 37a69aca12e0586d8452b95240482b308f5f233a Mon Sep 17 00:00:00 2001 From: Amp Date: Sat, 14 Nov 2026 02:18:13 +0000 Subject: [PATCH] fix(zero-swift): stop macOS virtual list rows laying out on every scroll frame 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 --- .changeset/zero-swift-macos-row-safe-area.md | 5 +++++ .../Sources/ZeroVirtual/RowsScrollView.swift | 21 ++++++++++++++----- 2 files changed, 21 insertions(+), 5 deletions(-) create mode 100644 .changeset/zero-swift-macos-row-safe-area.md diff --git a/.changeset/zero-swift-macos-row-safe-area.md b/.changeset/zero-swift-macos-row-safe-area.md new file mode 100644 index 0000000000..2464a0cf3c --- /dev/null +++ b/.changeset/zero-swift-macos-row-safe-area.md @@ -0,0 +1,5 @@ +--- +'zero-swift': patch +--- + +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. diff --git a/swift/Sources/ZeroVirtual/RowsScrollView.swift b/swift/Sources/ZeroVirtual/RowsScrollView.swift index 338e7fb835..77ec5fb744 100644 --- a/swift/Sources/ZeroVirtual/RowsScrollView.swift +++ b/swift/Sources/ZeroVirtual/RowsScrollView.swift @@ -86,7 +86,10 @@ final class RowsLayout { let host = LayoutHost() let views: any RowViews - var source = RowsSource() + private(set) var source = RowsSource() + /// Each row's place in `source.items`, by key: a layout pass looks up + /// every row near the screen, each time it stacks them. + private var positions: [AnyHashable: Int] = [:] private var cells: [AnyHashable: RowCell] = [:] /// Views rows left, hidden, for other rows: as many as `spareViews` /// kept, the rest released a few a frame. @@ -153,10 +156,16 @@ /// A new snapshot to draw. func update(_ source: RowsSource) { self.source = source + positions = Dictionary( + source.items.enumerated().map { ($1.key, $0) }, uniquingKeysWith: { first, _ in first }) generation += 1 host.invalidate() } + private func item(_ key: AnyHashable) -> RowsItem? { + positions[key].map { source.items[$0] } + } + /// The row with `key` changed height on its own: it's measured again in /// the next layout pass. func rowHeightDidChange(_ key: AnyHashable) { @@ -283,8 +292,7 @@ /// 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 = source.items.first(where: { $0.key == key }) - else { return } + guard let key, let cell = cells[key], let item = item(key) else { return } show(item, in: cell) host.invalidate() } @@ -308,7 +316,7 @@ } private func measure(_ key: AnyHashable) -> Double { - guard let item = source.items.first(where: { $0.key == key }) else { return 0 } + guard let item = item(key) else { return 0 } if let cell = cells[key] { if seen[key] != generation { seen[key] = generation @@ -445,8 +453,11 @@ private func makeController() -> Controller { let controller = Controller(rootView: AnyView(EmptyView())) + // No safe area: following it, every row's view (on screen or not) + // worked its insets out again, and laid itself out, on each frame the + // list scrolled. + controller.safeAreaRegions = [] #if canImport(UIKit) - controller.safeAreaRegions = [] controller.view.backgroundColor = .clear #else controller.sizingOptions = []