From ios-from-web-guide
MANDATORY for any view combining ScrollView, VStack, AsyncImage, or a custom Layout. Invoke before writing a ScrollView-based screen.
How 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.
.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.npx claudepluginhub j-morgan6/ios-from-web-guide --plugin ios-from-web-guideCreates, edits, and optimizes skills for Claude Code, including drafting, evaluating with test prompts, iterating on performance, and improving skill descriptions for better triggering accuracy.