diff --git a/.changeset/spread-row-creation.md b/.changeset/spread-row-creation.md new file mode 100644 index 0000000000..063ad95618 --- /dev/null +++ b/.changeset/spread-row-creation.md @@ -0,0 +1,5 @@ +--- +'zero-swift': patch +--- + +`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 4a40dde503..01c36a0f32 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,13 @@ /// 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. 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 @@ -48,6 +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 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) } + } + /// 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. @@ -60,6 +78,13 @@ package final class LayoutHost: VirtualHost { /// not the view's. private var scrolled = false private var inPass = 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() {} @@ -103,6 +128,10 @@ package final class LayoutHost: VirtualHost { ) { inPass = true defer { inPass = false } + if frameStarted { + frameStarted = false + deadline = now() + frameBudget + } if viewport != viewportHeight { viewportHeight = viewport viewportDidResize() @@ -117,11 +146,20 @@ 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 !framePending, !nearRowsMeasured() { + framePending = true + nextFrame { [weak self] in + guard let self else { return } + framePending = false + frameStarted = true + invalidate() + } + } return (frames, contentHeight, scrollOffset) } @@ -135,33 +173,74 @@ 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) } } 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 = [] var y = headerHeight + r.before 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 { + height = measure(item.key) + measured.insert(item.key) + } } if measured.contains(item.key) || heights[item.key] != nil { known[item.key] = height } defer { y += height } 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 824a6b10b8..cf8cacbd31 100644 --- a/swift/Tests/ZeroVirtualTests/LayoutHostTests.swift +++ b/swift/Tests/ZeroVirtualTests/LayoutHostTests.swift @@ -25,6 +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 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 @@ -35,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 } @@ -45,6 +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.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) } @@ -62,7 +70,19 @@ 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`, + /// The next frame starts: runs what the host asked to run then; whether + /// there was any. + @discardableResult + func frameDrawn() -> Bool { + clock += 1.0 / 60 + let work = nextFrame + nextFrame = [] + 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 +92,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 @@ -83,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() } @@ -475,3 +497,74 @@ final class LayoutScrollView { } } } + +@MainActor +@Suite("Layout host, rows made over frames") struct LayoutHostBudgetTests { + @Test( + "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 } + 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 == 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 { + #expect(view.measures[f.key.base as! String] != nil, "\(f.key) near and never measured") + } + } + } + + @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() + } + } +}