Skip to content

Fix: recompute line breaking in placeSubviews against bounds.width (stale single-line cache clips items)#11

Open
sanghun0724 wants to merge 1 commit into
FluidGroup:mainfrom
sanghun0724:fix/placesubviews-recompute-bounds-width
Open

Fix: recompute line breaking in placeSubviews against bounds.width (stale single-line cache clips items)#11
sanghun0724 wants to merge 1 commit into
FluidGroup:mainfrom
sanghun0724:fix/placesubviews-recompute-bounds-width

Conversation

@sanghun0724

Copy link
Copy Markdown

What

placeSubviews(in:proposal:subviews:cache:) recomputes the line breaking against the actual placement width (bounds.width) instead of reusing cache.lines from the last sizeThatFits call.

Why

placeSubviews reused the cache produced by the last sizeThatFits, assuming that proposal equals the bounds passed to placeSubviews. SwiftUI doesn't guarantee that — it can interleave extra measurement passes with different proposals. When a host measures the ideal/intrinsic size with an unspecified width (maxWidth = proposal.width ?? .infinity.infinity), every item is cached onto a single line. If layout then happens at a narrower width without a matching sizeThatFits, that stale single-line cache is placed into the narrower bounds and the items clip to one row.

Reproduces with:

  • WrapLayout inside ViewThatFits (measures each candidate's ideal size with a nil proposal),
  • a hierarchy hosted by UIHostingController with sizingOptions = [.intrinsicContentSize],
  • and is most visible when the container resizes dynamically (e.g. a bottom-sheet detent shrinking) — the post-resize pass uses the stale cache.

Per the Layout contract, placement should honor the given bounds.

Fixes #10

How

  • Extracted the line-breaking loop into a private calculateLines(maxWidth:maxHeight:subviews:) helper.
  • sizeThatFits uses it with the proposed width (unchanged behavior).
  • placeSubviews calls it with bounds.width, then applies the existing horizontal/vertical alignment logic.

For very large child counts a width-keyed cache would avoid the extra measurement; for typical wrap content the recompute cost is negligible. Happy to switch to a width-keyed cache instead if you prefer.

Notes on tests

The existing snapshot tests already fail on a current iOS simulator (iPhone 17 Pro) before this change — the recorded references were taken on an older toolchain, so text metrics differ (e.g. testWrapping renders at height 54.67 vs reference 93.0). With this change the freshly-rendered sizes are identical to the unchanged code, i.e. the change is behavior-preserving for the covered cases; only the stale-cache placement path is fixed. I left the reference images untouched to avoid re-recording on an unrelated toolchain — glad to regenerate them if you'd like.

placeSubviews reused cache.lines from the last sizeThatFits call, which
assumes that proposal matches the bounds given to placeSubviews. SwiftUI
does not guarantee this and may interleave an ideal/intrinsic measurement
with an unspecified width (ViewThatFits, or UIHostingController
sizingOptions .intrinsicContentSize), which caches a single line. Reusing
that stale cache when bounds is narrower clips all items onto one line,
most visibly when the container resizes dynamically (e.g. a bottom sheet
detent shrinking).

Extract the line-breaking into calculateLines(maxWidth:maxHeight:subviews:)
and recompute it in placeSubviews using bounds.width. Behavior is identical
for the existing cases; only the stale-cache path is fixed.

Fixes FluidGroup#10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

placeSubviews reuses stale line cache → items collapse to one line when bounds width differs from the last sizeThatFits proposal

1 participant