From e188a05f4c0a7e28ede4d3bd4a167eb375923007 Mon Sep 17 00:00:00 2001 From: Amp Date: Thu, 22 Oct 2026 18:52:42 +0000 Subject: [PATCH 1/4] perf(zero-swift): measure only the virtual list's rows near the viewport A layout pass made and measured a cell for every loaded row: after the first sync, 50 rows while 11 were visible (460-682 ms on zbugs-swift). LayoutHost now measures only rows within one viewport height above and below the viewport. Other rows use their last measured height, or the estimated row height, until they come that near. While a jump or a restore is landing, it measures every row, because where it lands depends on the rows above. A row above the viewport that is measured at a height other than its estimate goes through the same compensation as a row changing height, in the same pass, so rows on screen hold still. A scroll to rows not yet measured lays out again in the same pass. A jump to a loaded row that scrolled is lined up again in that pass, now measured, before it lands. Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm --- .changeset/lazy-row-measure.md | 5 + swift/Sources/ZeroVirtual/LayoutHost.swift | 66 ++++++++-- .../Sources/ZeroVirtual/RowsScrollView.swift | 2 +- swift/Sources/ZeroVirtual/VirtualList.swift | 1 + .../Sources/ZeroVirtual/VirtualizerCore.swift | 26 +++- .../ZeroVirtualTests/LayoutHostTests.swift | 124 +++++++++++++++++- 6 files changed, 204 insertions(+), 20 deletions(-) create mode 100644 .changeset/lazy-row-measure.md diff --git a/.changeset/lazy-row-measure.md b/.changeset/lazy-row-measure.md new file mode 100644 index 0000000000..677d1a79e6 --- /dev/null +++ b/.changeset/lazy-row-measure.md @@ -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. diff --git a/swift/Sources/ZeroVirtual/LayoutHost.swift b/swift/Sources/ZeroVirtual/LayoutHost.swift index 429dca6bc7..4a40dde503 100644 --- a/swift/Sources/ZeroVirtual/LayoutHost.swift +++ b/swift/Sources/ZeroVirtual/LayoutHost.swift @@ -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 @@ -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 } @@ -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 = [] /// 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, @@ -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() @@ -92,9 +115,9 @@ 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() @@ -102,15 +125,42 @@ package final class LayoutHost: VirtualHost { 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 { + 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 } diff --git a/swift/Sources/ZeroVirtual/RowsScrollView.swift b/swift/Sources/ZeroVirtual/RowsScrollView.swift index 9ec9f854d7..59a21e18d6 100644 --- a/swift/Sources/ZeroVirtual/RowsScrollView.swift +++ b/swift/Sources/ZeroVirtual/RowsScrollView.swift @@ -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 } diff --git a/swift/Sources/ZeroVirtual/VirtualList.swift b/swift/Sources/ZeroVirtual/VirtualList.swift index 0acd87527a..1d49eeb0cf 100644 --- a/swift/Sources/ZeroVirtual/VirtualList.swift +++ b/swift/Sources/ZeroVirtual/VirtualList.swift @@ -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) } } diff --git a/swift/Sources/ZeroVirtual/VirtualizerCore.swift b/swift/Sources/ZeroVirtual/VirtualizerCore.swift index 8524fd7730..7c7c373061 100644 --- a/swift/Sources/ZeroVirtual/VirtualizerCore.swift +++ b/swift/Sources/ZeroVirtual/VirtualizerCore.swift @@ -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 { @@ -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. @@ -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() { diff --git a/swift/Tests/ZeroVirtualTests/LayoutHostTests.swift b/swift/Tests/ZeroVirtualTests/LayoutHostTests.swift index c57445ce0f..824a6b10b8 100644 --- a/swift/Tests/ZeroVirtualTests/LayoutHostTests.swift +++ b/swift/Tests/ZeroVirtualTests/LayoutHostTests.swift @@ -13,6 +13,8 @@ final class LayoutScrollView { let h: Harness let viewportHeight: Double var heights: [String: Double] = [:] + /// How many times each row was measured. + var measures: [String: Int] = [:] /// The heights of the views above and below the rows. var header = 0.0 var footer = 0.0 @@ -32,8 +34,11 @@ final class LayoutScrollView { return (s.items.map { (AnyHashable($0.key), $0.index) }, s.spaceBefore, s.spaceAfter) } host.measure = { [unowned self] key in - self.heights[key.base as! String] ?? self.h.view.rowHeight + let key = key.base as! String + self.measures[key, default: 0] += 1 + return self.heights[key] ?? self.h.view.rowHeight } + host.estimatedRowHeight = { [unowned self] in self.h.core.options.estimatedRowHeight } host.header = { [unowned self] in self.header } host.footer = { [unowned self] in self.footer } host.didScroll = { [unowned self] in self.h.core.didScroll() } @@ -334,9 +339,12 @@ final class LayoutScrollView { return } let offset = view.scrollOffset - view.userScroll(offset - 100) - #expect( - view.rowTop(key) == top + (offset - view.scrollOffset), "fling \(flings) at \(offset)") + let target = max(0, offset - 100) + view.userScroll(target) + // What's on screen moves by the fling alone: near the start, rows + // above measured taller than estimated are taken up by a scroll in + // the same pass. + #expect(view.rowTop(key) == top + (offset - target), "fling \(flings) at \(offset)") view.settle(holding: key) // At the top, no space stands above the first row. if view.scrollOffset == 0, h.core.snapshot.items.first?.key == "r0" { @@ -358,4 +366,112 @@ final class LayoutScrollView { #expect(view.scrollOffset == 0) #expect(h.core.snapshot.spaceBefore == 0) } + + @Test("only the rows near the viewport are measured") + func measuresNearRows() { + let h = Harness(rowCount: 1000) { $0.anchoring = .manual } + let view = LayoutScrollView(h) + view.settle() + let items = h.core.snapshot.items + #expect(items.count >= 100) + // 20 rows on screen, and a viewport's height of them below. + let measured = Set(view.measures.keys) + #expect(measured == Set((0..<41).map { "r\($0)" })) + #expect(view.host.rowFrames().count == items.count) + } + + @Test("scrolling down measures the rows coming near, and the rows on screen move with the scroll") + func measuresAsItScrolls() { + let h = Harness(rowCount: 1000) { $0.anchoring = .manual } + let view = LayoutScrollView(h) + for i in 0..<1000 where i % 3 == 0 { view.heights["r\(i)"] = 60 } + view.settle() + for _ in 0..<30 { + let key = h.core.firstVisibleItem()!.key + let top = try! #require(view.rowTop(key)) + view.measures = [:] + view.userScroll(view.scrollOffset + 150) + #expect(view.rowTop(key) == top - 150) + // What it measured is near the viewport, and takes in what's on it. + let offset = view.scrollOffset + for f in view.host.rowFrames() { + let near = f.maxY >= offset - 400 && f.minY <= offset + 800 + let onScreen = f.maxY > offset && f.minY < offset + 400 + let measured = view.measures[f.key.base as! String] != nil + #expect(!measured || near, "\(f.key) measured at \(f.minY), the viewport at \(offset)") + #expect(!onScreen || measured, "\(f.key) on screen and not measured") + } + view.settle(holding: h.core.firstVisibleItem()?.key) + } + } + + @Test("rows above the viewport measured taller than estimated don't move what's on screen") + func measuredTallerAbove() { + // At the start, where the rows above are taken up by a scroll, and in + // mid-list, where the space before the rows takes them up. + for scrolled in [0.0, 6000] { + let h = Harness(rowCount: 1000) { $0.anchoring = .manual } + let view = LayoutScrollView(h) + for i in 0..<1000 { view.heights["r\(i)"] = 60 } + view.settle() + for _ in 0..<40 where view.scrollOffset < scrolled { + view.userScroll(view.scrollOffset + 300) + view.settle() + } + // A jump over rows it doesn't measure. + let first = h.core.firstVisibleItem()!.index + let to = h.core.snapshot.items.last!.index - 10 + #expect(to - first > 45) + h.core.scrollToRow(key: "r\(to)", align: .start) + view.settle() + #expect(view.rowTop("r\(to)") == 0) + // Past the rows measured below where it was. + let skipped = "r\(first + 16)" + #expect(view.measures[skipped] == nil) + + // Scrolling up, the rows above come near and come out 3× their + // estimate. + for _ in 0..<60 where view.measures[skipped] == nil { + let key = h.core.firstVisibleItem()!.key + let top = try! #require(view.rowTop(key)) + let offset = view.scrollOffset + view.userScroll(offset - 100) + #expect(view.rowTop(key) == top + 100, "\(key) after a scroll from \(offset)") + view.settle(holding: key) + } + #expect(view.measures[skipped] != nil) + } + } + + @Test("jumps to rows not measured yet land on them") + func jumpsToUnmeasuredRows() { + let alignments: [VirtualScrollAlignment] = [.start, .center, .end, .auto] + for align in alignments { + for byID in [false, true] { + let h = Harness(rowCount: 1000) { $0.anchoring = .manual } + let view = LayoutScrollView(h) + for i in 0..<1000 { view.heights["r\(i)"] = i % 2 == 0 ? 60 : 35 } + view.settle() + #expect(view.measures["r80"] == nil) + if byID { + h.core.scrollToItem("r80", align: align) + } else { + h.core.scrollToRow(key: "r80", align: align) + } + view.settle() + let top = try! #require(view.rowTop("r80")) + let expected: Double = + switch align { + case .start: 0 + case .center: (view.viewportHeight - 60) / 2 + // Below the viewport, `.auto` lines its bottom up with the + // viewport's. + case .end, .auto: view.viewportHeight - 60 + } + #expect(abs(top - expected) <= 1, "\(align) by id: \(byID)") + h.scheduler.advance(by: 200) + view.settle(holding: "r80") + } + } + } } From ea8a2fda1c1d93c464e157bff5c47bac7303b57f Mon Sep 17 00:00:00 2001 From: Amp Date: Thu, 22 Oct 2026 19:42:02 +0000 Subject: [PATCH 2/4] test(zero-swift): wait for the selection highlight instead of a fixed 0.2 s On a slow CI runner the layout pass that places the highlight can run after 0.2 s; the test now polls up to 5 s, then checks as before. Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm --- swift/Tests/AppleTests/VirtualListTests.swift | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/swift/Tests/AppleTests/VirtualListTests.swift b/swift/Tests/AppleTests/VirtualListTests.swift index 5611841027..ca420f9f9a 100644 --- a/swift/Tests/AppleTests/VirtualListTests.swift +++ b/swift/Tests/AppleTests/VirtualListTests.swift @@ -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. @@ -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) @@ -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)) } @@ -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) From 6f7c0ae34208ef8c4b541ffe1fe1b11765ef04e4 Mon Sep 17 00:00:00 2001 From: Amp Date: Thu, 22 Oct 2026 23:50:01 +0000 Subject: [PATCH 3/4] test(zero-swift): count a jump's late landing in the jump's phase When a stalled frame holds a jump's landing past the script's pause, the next phase used to end the unmoved jump and check its landing frame as an ordinary frame of that phase, flagging it as wrong. A jump that hasn't moved when a phase begins now takes the next frame and ends with it. The wrong-frame checks are unchanged. Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm --- swift/TestHost/App/FrameProbe.swift | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/swift/TestHost/App/FrameProbe.swift b/swift/TestHost/App/FrameProbe.swift index 2073509aba..fdf5757fe9 100644 --- a/swift/TestHost/App/FrameProbe.swift +++ b/swift/TestHost/App/FrameProbe.swift @@ -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)] = [] @@ -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)) } @@ -136,6 +150,8 @@ final class FrameProbe { jumpStill = 0 jumpTime = 0 jumpMoved = false + jumpPhase = phase + jumpCarried = false log("JUMP begin top=\(previous?.ids.first ?? "-")") } @@ -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. @@ -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 ?? "-")" @@ -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: " ") From 3b2eac221af10378a85581edfb5961c11230af85 Mon Sep 17 00:00:00 2001 From: Amp Date: Fri, 23 Oct 2026 01:46:05 +0000 Subject: [PATCH 4/4] fix(zero-swift): measure a new AppKit row view from its fitting height, not 0 Resized from 0 high at a fractional list offset, as when lazy measuring makes a view mid-scroll, a row view's autoresizing subviews came out that fraction of a point above it, which a 1x display rounds to a whole point for the view's life. Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm --- swift/Sources/ZeroVirtual/RowsScrollView.swift | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/swift/Sources/ZeroVirtual/RowsScrollView.swift b/swift/Sources/ZeroVirtual/RowsScrollView.swift index 59a21e18d6..9594a37459 100644 --- a/swift/Sources/ZeroVirtual/RowsScrollView.swift +++ b/swift/Sources/ZeroVirtual/RowsScrollView.swift @@ -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