Fix the collapsed-lanes render on initial board open

The strip's standard-width division read the drop registry's strip
frame during body evaluation, but the registry is deliberately
unobserved (it exists for event-time reads): the first body ran
against a zero frame, every lane collapsed to the 1pt floor, and
nothing invalidated the view when the real measurement landed — the
board stayed corrupted until an unrelated re-render. The layout now
reads the GeometryReader's live proposal (reactive by construction);
the drop delegates keep reading the registry at event time.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-28 06:48:59 -04:00
parent 2fc4020a2b
commit 14e752895a
+16 -8
View File
@@ -121,7 +121,7 @@ struct BoardView: View {
private let spacing: CGFloat = 12 private let spacing: CGFloat = 12
var body: some View { var body: some View {
GeometryReader { _ in GeometryReader { proxy in
// The strip's slots: the lanes the strip should *show*, with the drag's N contiguous // The strip's slots: the lanes the strip should *show*, with the drag's N contiguous
// shadows opened at the proposal. Recomputed on every render, so a foreign reload // shadows opened at the proposal. Recomputed on every render, so a foreign reload
// mid-drag simply moves the zones (rule 1 of 04-interactions.md Drag and drop's // mid-drag simply moves the zones (rule 1 of 04-interactions.md Drag and drop's
@@ -129,7 +129,7 @@ struct BoardView: View {
let slots = stripSlots let slots = stripSlots
ZStack(alignment: .topLeading) { ZStack(alignment: .topLeading) {
backdrop backdrop
laneStrip(slots) laneStrip(slots, standard: standardWidth(stripWidth: proxy.size.width))
} }
.padding(spacing) .padding(spacing)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
@@ -254,8 +254,7 @@ struct BoardView: View {
/// The lanes and the drag's shadows, plus the trash column when it is shown. /// The lanes and the drag's shadows, plus the trash column when it is shown.
@ViewBuilder @ViewBuilder
private func laneStrip(_ slots: [StripSlot]) -> some View { private func laneStrip(_ slots: [StripSlot], standard: CGFloat) -> some View {
let standard = standardWidth
HStack(alignment: .top, spacing: spacing) { HStack(alignment: .top, spacing: spacing) {
ForEach(slots) { slot in ForEach(slots) { slot in
switch slot { switch slot {
@@ -480,11 +479,20 @@ struct BoardView: View {
gap: spacing, gap: spacing,
window: window, window: window,
stripFrame: { laneDrops.stripFrame }, stripFrame: { laneDrops.stripFrame },
standard: { standardWidth } standard: { standardWidth(stripWidth: laneDrops.stripFrame.width) }
) )
} }
/// The strip's 1× lane width. /// The strip's 1× lane width, for a strip `stripWidth` points wide.
///
/// **The width is a parameter, and which width the caller passes matters.** Body evaluation
/// passes the `GeometryReader`'s live proposal, because that is the one source that invalidates
/// the view when it changes the registry's `stripFrame` is deliberately unobserved (it exists
/// for event-time reads), so a body computed from it renders the *previous* layout's width: on
/// first open that is `.zero`, every lane collapses to `standardWidth`'s 1pt floor, and the
/// board stays that way until an unrelated invalidation happens by. The drop delegates pass
/// `laneDrops.stripFrame.width` instead, which is the same number read at event time exactly
/// the registry's purpose. The two agree because the padded container fills the reader.
/// ///
/// During a resize session the standard is **frozen** at its drag-start value: the window is /// During a resize session the standard is **frozen** at its drag-start value: the window is
/// animating mid-resize, so deriving the standard from the live width would feed that animation /// animating mid-resize, so deriving the standard from the live width would feed that animation
@@ -504,12 +512,12 @@ struct BoardView: View {
/// - a **cross-board lane arrival**'s units while its shadow hovers here, by the same rule read /// - a **cross-board lane arrival**'s units while its shadow hovers here, by the same rule read
/// from the destination's side: the shadow occupies its units, and the strip has to make room /// from the destination's side: the shadow occupies its units, and the strip has to make room
/// for them or the shadow would be drawn at a width the lane will not have. /// for them or the shadow would be drawn at a width the lane will not have.
private var standardWidth: CGFloat { private func standardWidth(stripWidth: CGFloat) -> CGFloat {
if resize.isActive { return resize.standard } if resize.isActive { return resize.standard }
var units = LaneLayoutMath.totalUnits(of: liveLanes, trashUnits: isTrashVisible ? 1 : 0) var units = LaneLayoutMath.totalUnits(of: liveLanes, trashUnits: isTrashVisible ? 1 : 0)
units += arrivingLaneUnits units += arrivingLaneUnits
return LaneLayoutMath.standardWidth( return LaneLayoutMath.standardWidth(
stripWidth: laneDrops.stripFrame.width, stripWidth: stripWidth,
totalUnits: units, totalUnits: units,
gap: spacing gap: spacing
) )