Help us improve
Share bugs, ideas, or general feedback.
From ios-from-web-guide
MANDATORY for any view combining ScrollView, VStack, AsyncImage, or a custom Layout. Invoke before writing a ScrollView-based screen.
npx claudepluginhub j-morgan6/ios-from-web-guide --plugin ios-from-web-guideHow this skill is triggered — by the user, by Claude, or both
Slash command
/ios-from-web-guide:swiftui-layout-pitfallsThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
1. **Use `.containerRelativeFrame(.horizontal, alignment: .leading)` to pin a VStack's width inside a ScrollView** when it contains async or variable-width content (images, chips, flow layouts). This is the **only** reliable fix for the symmetric-clipping bug.
Creates p5.js generative art with seeded randomness, noise fields, and interactive parameter exploration. Use for algorithmic art, flow fields, or particle systems.
Share bugs, ideas, or general feedback.
.containerRelativeFrame(.horizontal, alignment: .leading) to pin a VStack's width inside a ScrollView when it contains async or variable-width content (images, chips, flow layouts). This is the only reliable fix for the symmetric-clipping bug..frame(maxWidth: .infinity) does NOT cap width. It accepts up to infinity. It tells the parent "I'll grow as wide as you offer." It does not constrain wide children.Layout protocol sizeThatFits MUST return a finite size during the measurement pass (.unspecified proposal). Returning .infinity for width or height poisons the parent chain and produces silent layout corruption.AsyncImage must be explicitly framed before it loads — otherwise the natural size of the loaded image bleeds up through the parent VStack. Use .frame(maxWidth: .infinity).frame(height: X) or .aspectRatio(contentMode: .fill).frame(height: X).clipped().proposal.width ?? .infinity as a layout's own reported width. Use a finite fallback (e.g., the sum of subview widths, or a concrete number).ScrollView { VStack { ... } }.AsyncImage to an existing layout.Layout (FlowLayout, WaterfallLayout, etc.).// ❌ Classic symmetric-clipping bug
ScrollView {
VStack(alignment: .leading, spacing: 12) {
Text(post.title)
AsyncImage(url: post.imageURL.asBackendURL) // no frame!
Text(post.body)
}
.padding()
}
What happens: A VStack inside a ScrollView sizes to the max intrinsic width of its children. AsyncImage with no .frame proposes its natural image width once it loads — often 2000+ px. The VStack grows to 2000 px. The ScrollView centers the oversized content. Result: symmetric edge clipping on load.
Why .frame(maxWidth: .infinity) doesn't fix it:
VStack {
AsyncImage(url: url).frame(maxWidth: .infinity)
}
.frame(maxWidth: .infinity) // ❌ still broken
.frame(maxWidth: .infinity) just tells the layout "I'm willing to be wide." It doesn't cap width. The AsyncImage natural size still wins during re-layout.
The fix:
// ✅
ScrollView {
VStack(alignment: .leading, spacing: 12) {
Text(post.title)
AsyncImage(url: post.imageURL.asBackendURL) { image in
image.resizable().aspectRatio(contentMode: .fill)
} placeholder: {
Color.gray.opacity(0.2)
}
.frame(height: 240)
.clipped()
Text(post.body)
}
.containerRelativeFrame(.horizontal, alignment: .leading)
.padding()
}
.containerRelativeFrame(.horizontal) pins the width to the ScrollView's viewport. The children can no longer stretch the VStack wider than the screen.
Layout protocol — finite-size rule// ❌ The Trays FlowLayout bug
struct FlowLayout: Layout {
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
CGSize(width: proposal.width ?? .infinity, height: rowsHeight)
// ^^^^^^^^^ poisons the parent
}
}
When SwiftUI measures with a .unspecified proposal (proposal.width is nil), returning .infinity tells the parent chain "I'm infinite wide." The parent VStack adopts that and the ScrollView centers infinite content.
// ✅
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
let width = proposal.width ?? maxNaturalRowWidth(subviews: subviews)
return CGSize(width: width, height: rowsHeight(width: width, subviews: subviews))
}
Always return finite values during measurement. If you don't know the width yet, compute it from subviews.
.frame(maxWidth: .infinity) and it still clips"Right — it doesn't cap width. It accepts infinity. You need .containerRelativeFrame(.horizontal) to actually pin.
Classic signal of a variable-width child (images, chips, links) stretching the VStack. Switch to .containerRelativeFrame. This is exactly the Trays PostDetail-with-tools bug.
Pin the frame before load:
AsyncImage(url: url) { $0.resizable().scaledToFill() }
placeholder: { Color.gray.opacity(0.15) }
.frame(height: 240)
.clipped()
The placeholder takes the same frame — no reflow when the image arrives.
Usually means sizeThatFits returned .zero or the width proposal wasn't honored. Log the proposal and subview sizes — verify finite values come out.
.containerRelativeFrame "not found"Requires iOS 17+. If project.yml sets deploymentTarget.iOS: "17.0" (per ios-project-structure), you're fine.
No dedicated template. Apply the pattern directly in feature views.
swiftui-async-image-with-backend-paths — the .asBackendURL helper used above.swiftui-navigation-foundations — many navigation-pushed detail views hit this bug.