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 = []