Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/spread-row-creation.md
Original file line number Diff line number Diff line change
@@ -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.
99 changes: 89 additions & 10 deletions swift/Sources/ZeroVirtual/LayoutHost.swift
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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() {}

Expand Down Expand Up @@ -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()
Expand All @@ -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)
}

Expand All @@ -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<Double> {
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<Double>) -> 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
}
}
Loading
Loading