Skip to content
Merged
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/lazy-row-measure.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'zero-swift': patch
---

`VirtualList` and `VirtualListView` make and measure only the rows within a viewport's height of the viewport. Rows further away use their last measured height, or the estimated row height, until they come near. A row that turns out taller or shorter than its estimate doesn't move what's on screen, and jumps line up again once their row is measured. Before this, the first layout after a sync measured every loaded row.
66 changes: 58 additions & 8 deletions swift/Sources/ZeroVirtual/LayoutHost.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,17 @@
///
/// The view calls `layout(offset:viewport:)` from its layout pass, with the
/// offset it's at (the user's scroll, since the last pass) and its
/// viewport's height. Rows are measured with `measure` and stacked from the
/// snapshot's space before them, after the header: the content is the
/// header, the space before the rows, the rows, the space after them, and
/// the footer.
/// viewport's height. Rows are stacked from the snapshot's space before
/// them, after the header: the content is the header, the space before the
/// rows, the rows, the space after them, and the footer.
///
/// Only the rows within a viewport's height of the viewport are measured
/// (with `measure`, which makes their views): the rest stand at the height
/// they were last measured at, or the estimate, until they come that near.
/// A row measured at another height moves the rows below it, as a row
/// 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.
@MainActor
package final class LayoutHost: VirtualHost {
package private(set) var scrollOffset = 0.0
Expand All @@ -25,6 +32,8 @@ package final class LayoutHost: VirtualHost {
{ ([], 0, 0) }
/// A row's height, at the view's width.
package var measure: (AnyHashable) -> Double = { _ in 0 }
/// The height that stands in for a row not measured yet.
package var estimatedRowHeight: () -> Double = { 0 }
/// The heights of the views above and below the rows, at the view's
/// width: 0 for none.
package var header: () -> Double = { 0 }
Expand All @@ -41,6 +50,10 @@ package final class LayoutHost: VirtualHost {
package var setNeedsLayout: () -> Void = {}

private var frames: [VirtualRowFrame] = []
/// The rows' heights as last measured, for when they're out of reach.
private var heights: [AnyHashable: Double] = [:]
/// The rows the last placing measured.
private var measured: Set<AnyHashable> = []
/// A snapshot, or a row's height, not laid out yet.
private var stale = true
/// The core scrolled outside a pass: the next pass applies its offset,
Expand All @@ -59,10 +72,20 @@ package final class LayoutHost: VirtualHost {
if !inPass { setNeedsLayout() }
}

/// The rows' heights at the view's width are no longer their measured
/// ones, as when the width changed: each is measured again once it's near.
package func invalidateHeights() {
heights = [:]
invalidate()
}

package func scroll(to target: Double) {
let offset = max(0, min(target, contentHeight - viewportHeight))
guard offset != scrollOffset else { return }
scrollOffset = offset
// Laid out again where it scrolled to: rows that came near are
// measured, and the core sees them.
stale = true
if !inPass {
scrolled = true
setNeedsLayout()
Expand Down Expand Up @@ -92,25 +115,52 @@ package final class LayoutHost: VirtualHost {
scrollOffset = offset
didScroll()
}
// What the core does about the update can be a new snapshot; it's laid
// out in this pass too.
for _ in 0..<8 where stale {
// 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() {
stale = false
place()
afterUpdate()
}
return (frames, contentHeight, scrollOffset)
}

/// Where rows are measured: the viewport and a viewport's height above
/// and below it, as far as a fling goes in a few frames, so rows are
/// measured before they're drawn and a row above that comes out another
/// height than estimated does so off screen. While a jump or a restore is
/// landing, all of them.
private var reach: ClosedRange<Double> {
if isLanding() { return -Double.infinity...Double.infinity }
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)
}
}

private func place() {
let r = rows()
headerHeight = header()
let reach = reach
let estimate = estimatedRowHeight()
var known: [AnyHashable: Double] = [:]
measured = []
var y = headerHeight + r.before
frames = r.keys.map { item in
let height = measure(item.key)
var height = heights[item.key] ?? estimate
if y <= reach.upperBound && y + height >= reach.lowerBound {
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
}
Expand Down
11 changes: 8 additions & 3 deletions swift/Sources/ZeroVirtual/RowsScrollView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@
for cell in cells.values { cell.height = nil }
header?.height = nil
footer?.height = nil
host.invalidate()
host.invalidateHeights()
}
let r = host.layout(offset: offset, viewport: viewport)
if !shown, !r.frames.isEmpty, !host.isLanding() { shown = true }
Expand Down Expand Up @@ -487,8 +487,13 @@
constraint.isActive = true
defer { constraint.isActive = false }
// Laid out at the width first: a wrapping label wraps at the width
// it was laid out at, and its height follows.
view.setFrameSize(NSSize(width: width, height: view.frame.height))
// it was laid out at, and its height follows. A new view, 0 high, at
// its fitting height: resized from 0 high where the list's offset is
// between pixels, as when it's measured mid-scroll, its autoresizing
// subviews come out that fraction of a point above it, which a 1x
// display rounds to a whole one, for the view's life.
let height = view.frame.height > 0 ? view.frame.height : view.fittingSize.height
view.setFrameSize(NSSize(width: width, height: height))
view.layoutSubtreeIfNeeded()
return Double(view.fittingSize.height)
#endif
Expand Down
1 change: 1 addition & 0 deletions swift/Sources/ZeroVirtual/VirtualList.swift
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@
host.viewportDidResize = { [list] in list.viewportDidResize() }
host.afterUpdate = { [list] in list.afterUpdate() }
host.isLanding = { [list] in list.isLanding }
host.estimatedRowHeight = { [list] in list.estimatedRowHeight }
view.rows.scrollToRow = { [list] key in
if let key = key.base as? K { list.scrollToRow(key: key, align: .auto) }
}
Expand Down
26 changes: 19 additions & 7 deletions swift/Sources/ZeroVirtual/VirtualizerCore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1053,7 +1053,14 @@ package final class VirtualizerCore<
guard rowsAnswerQuery else { return }
setScrollOffset(offset)
case .row(let pending):
guard landRow(pending) else { return }
guard let moved = landRow(pending) else { return }
// A host that lays out with its scroll measures the rows it scrolled
// to only then: the row may have been lined up by an estimate, and is
// lined up again, measured, in the pass that lays the scroll out.
if moved, host?.scrollsWithLayout == true {
placed = true
return
}
}
placed = true
if rowsSettled {
Expand All @@ -1062,12 +1069,14 @@ package final class VirtualizerCore<
}
}

private func landRow(_ pending: PendingScroll) -> Bool {
/// Lines the row up; whether it scrolled to, or nil when the row isn't
/// there.
private func landRow(_ pending: PendingScroll) -> Bool? {
if pending.source == .option, pending.id != options.permalinkID {
setTarget(nil)
return false
return nil
}
guard let host else { return false }
guard let host else { return nil }
var f = findTarget(host, pending)
if f == nil, pending.rowKey == nil, let id = pending.id {
// The row's key, once its own page queries have completed.
Expand All @@ -1083,11 +1092,14 @@ package final class VirtualizerCore<
let missing = rows.permalinkNotFound && rows.lookup?.id == pending.id
let abandoned = rows.complete && !(pending.id.map(isTargeting) ?? false)
if missing || abandoned { setTarget(nil) }
return false
return nil
}
let delta = alignDelta(host, f, pending.align)
if abs(delta) > 1 { setScrollOffset(host.scrollOffset + delta) }
return true
guard abs(delta) > 1 else { return false }
// Clamped to the content, it can't go all the way.
let from = host.scrollOffset
setScrollOffset(from + delta)
return abs(host.scrollOffset - from) > 1
}

private func evaluatePaging() {
Expand Down
27 changes: 23 additions & 4 deletions swift/TestHost/App/FrameProbe.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ final class FrameProbe {
/// Since the jump began, and whether it has moved yet.
@ObservationIgnored private var jumpTime = 0.0
@ObservationIgnored private var jumpMoved = false
/// The phase the jump began in, which its frames count in; and whether
/// a new phase began before it landed: then it ends with the next frame.
@ObservationIgnored private var jumpPhase = ""
@ObservationIgnored private var jumpCarried = false
@ObservationIgnored private var flingStart: (t: Double, moved: Double)?
@ObservationIgnored private(set) var phase = "start"
@ObservationIgnored private var wrongByPhase: [(String, Int)] = []
Expand Down Expand Up @@ -113,7 +117,17 @@ final class FrameProbe {
}

func setPhase(_ name: String) {
finishJump()
// A jump that hasn't moved yet, as when a stalled frame (a slow
// simulator's) held its landing back past the phase's end, takes the
// next frame, which the new phase can't have moved yet: it's checked as
// the jump's, in the jump's phase, and the jump ends with it, moved or
// not (a jump to where the list already is never moves).
if jump != nil, !jumpMoved, !jumpCarried {
jumpCarried = true
log("JUMP still landing as \(name) begins")
} else {
finishJump()
}
log("PHASE \(name)")
phase = name
if !wrongByPhase.contains(where: { $0.0 == name }) { wrongByPhase.append((name, 0)) }
Expand All @@ -136,6 +150,8 @@ final class FrameProbe {
jumpStill = 0
jumpTime = 0
jumpMoved = false
jumpPhase = phase
jumpCarried = false
log("JUMP begin top=\(previous?.ids.first ?? "-")")
}

Expand Down Expand Up @@ -255,7 +271,7 @@ final class FrameProbe {
// Settled half a second after it last moved. Its rows can take longer
// than that to load (a simulator's can), so it waits for its first
// move; a jump to where the list already is never moves.
if (jumpMoved && jumpStill >= 0.5) || jumpTime >= 5 { finishJump() }
if jumpCarried || (jumpMoved && jumpStill >= 0.5) || jumpTime >= 5 { finishJump() }
return
}
// Rows, not the header or the footer, which the checks below place.
Expand Down Expand Up @@ -394,7 +410,9 @@ final class FrameProbe {
}
for f in frames.dropFirst() where !same(f, first) && !same(f, last) {
bad += 1
flag(f, "jump frame neither where it was (\(first.ids.first ?? "-")) nor where it lands")
flag(
f, "jump frame neither where it was (\(first.ids.first ?? "-")) nor where it lands",
phase: jumpPhase)
}
log(
"JUMP end frames=\(frames.count) wrong=\(bad) from \(first.ids.first ?? "-")…\(first.ids.last ?? "-") to \(last.ids.first ?? "-")…\(last.ids.last ?? "-")"
Expand All @@ -408,7 +426,8 @@ final class FrameProbe {
return a.rows.allSatisfy { abs($0.value.minY - b.rows[$0.key]!.minY) <= 0.5 }
}

private func flag(_ s: Sample, _ why: String, _ before: Sample? = nil) {
private func flag(_ s: Sample, _ why: String, _ before: Sample? = nil, phase: String? = nil) {
let phase = phase ?? self.phase
wrong += 1
if let i = wrongByPhase.firstIndex(where: { $0.0 == phase }) { wrongByPhase[i].1 += 1 }
let rows = s.ids.prefix(2).map { "\($0)@\(fmt(s.rows[$0]!.minY))" }.joined(separator: " ")
Expand Down
15 changes: 12 additions & 3 deletions swift/Tests/AppleTests/VirtualListTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,7 @@
try await t.run(5) { state().key == id(5) }
#expect(state().key == id(5))
#expect(state().shown[id(5)] == true)
try await t.run(0.2) { false }
try await t.run(5) { highlighted(id(5)) }
try expectHighlight(on: id(5))

// Again: the app hears of it, to open the row again.
Expand All @@ -255,7 +255,7 @@
try await t.run(5) { state().key == id(7) }
#expect(state().shown[id(5)] == false)
#expect(state().shown[id(7)] == true)
try await t.run(0.2) { false }
try await t.run(5) { highlighted(id(7)) }
try expectHighlight(on: id(7))
#else
try await keys(t)
Expand All @@ -281,7 +281,7 @@
#expect(state().shown[id(15)] == false)
#expect(state().shown[id(5)] == false)
#expect(state().shown[id(14)] == true)
try await t.run(0.2) { false }
try await t.run(5) { highlighted(id(14)) }
try expectHighlight(on: id(14))
}

Expand Down Expand Up @@ -316,6 +316,15 @@
view.select(at: (f.minY + f.maxY) / 2)
}

/// The highlight is shown behind the row's frame. It's placed in the
/// view's next layout pass, which a slow runner can take a while to run.
func highlighted(_ key: String) -> Bool {
guard let f = try? frame(key) else { return false }
let h = view.highlight
return !h.isHidden && abs(Double(h.frame.minY) - f.minY) <= 0.5
&& abs(Double(h.frame.maxY) - f.maxY) <= 0.5
}

/// The highlight stands behind the row's frame.
func expectHighlight(on key: String) throws {
let f = try frame(key)
Expand Down
Loading
Loading