| name | layout |
| description | Layout patterns: VStack/HStack/ZStack composition, view structure, subview extraction, GeometryReader alternatives, safe area handling. Use when arranging views, building screen layouts, or structuring view hierarchies. Triggers: VStack, HStack, ZStack, LazyVStack, Grid, Spacer, padding, frame, GeometryReader. |
SwiftUI Layout & View Structure Reference
Comprehensive guide to stack layouts, view composition, subview extraction, and layout best practices.
Relative Layout Over Constants
GeometryReader { geometry in
VStack {
HeaderView()
.frame(height: geometry.size.height * 0.2)
ContentView()
}
}
VStack {
HeaderView()
.frame(height: 150)
ContentView()
}
Context-Agnostic Views
Views should work in any context. Never assume presentation style or screen size.
struct ProfileCard: View {
let user: User
var body: some View {
VStack {
Image(user.avatar)
.resizable()
.aspectRatio(contentMode: .fit)
Text(user.name)
Spacer()
}
.padding()
}
}
Image(user.avatar)
.frame(width: UIScreen.main.bounds.width)
Own Your Container
Custom views should own static containers but not lazy/repeatable ones.
struct HeaderView: View {
var body: some View {
HStack {
Image(systemName: "star")
Text("Title")
Spacer()
}
}
}
View Structure Principles
SwiftUI's diffing algorithm compares view hierarchies to determine what needs updating.
Prefer Modifiers Over Conditional Views
SomeView()
.opacity(isVisible ? 1 : 0)
if isVisible {
SomeView()
}
Use conditionals when you truly have different views:
if isLoggedIn {
DashboardView()
} else {
LoginView()
}
Extract Subviews, Not Computed Properties
The Problem with @ViewBuilder Functions
struct ParentView: View {
@State private var count = 0
var body: some View {
VStack {
Button("Tap: \(count)") { count += 1 }
complexSection()
}
}
@ViewBuilder
func complexSection() -> some View {
ForEach(0..<100) { i in
HStack {
Image(systemName: "star")
Text("Item \(i)")
}
}
}
}
The Solution: Separate Structs
struct ParentView: View {
@State private var count = 0
var body: some View {
VStack {
Button("Tap: \(count)") { count += 1 }
ComplexSection()
}
}
}
struct ComplexSection: View {
var body: some View {
ForEach(0..<100) { i in
HStack {
Image(systemName: "star")
Text("Item \(i)")
}
}
}
}
Container View Pattern
struct MyContainer<Content: View>: View {
let content: () -> Content
var body: some View {
VStack { Text("Header"); content() }
}
}
struct MyContainer<Content: View>: View {
@ViewBuilder let content: Content
var body: some View {
VStack { Text("Header"); content }
}
}
ZStack vs overlay/background
Use ZStack to compose multiple peer views that should be layered together.
Prefer overlay / background when decorating a primary view.
Button("Continue") { }
.overlay(alignment: .trailing) {
Image(systemName: "lock.fill")
.padding(.trailing, 8)
}
HStack(spacing: 12) {
Image(systemName: "tray")
Text("Inbox")
}
.background {
Capsule()
.strokeBorder(.blue, lineWidth: 2)
}
Layout Performance
Avoid Layout Thrash
VStack { HStack { VStack { HStack { Text("Deep") } } } }
VStack { Text("Shallow"); Text("Structure") }
Minimize GeometryReader (use iOS 17+ alternatives)
containerRelativeFrame(.horizontal) { width, _ in
width * 0.8
}
Gate Frequent Geometry Updates
.onPreferenceChange(ViewSizeKey.self) { size in
let difference = abs(size.width - currentSize.width)
if difference > 10 { currentSize = size }
}
View Logic and Testability
@Observable
@MainActor
final class LoginViewModel {
var email = ""
var password = ""
var isValid: Bool {
!email.isEmpty && password.count >= 8
}
func login() async throws { }
}
struct LoginView: View {
@State private var viewModel = LoginViewModel()
var body: some View {
Form {
TextField("Email", text: $viewModel.email)
SecureField("Password", text: $viewModel.password)
Button("Login") {
Task { try? await viewModel.login() }
}
.disabled(!viewModel.isValid)
}
}
}
Action Handlers
struct PublishView: View {
@State private var viewModel = PublishViewModel()
var body: some View {
Button("Publish Project", action: viewModel.handlePublish)
}
}
iPad-Specific Patterns
Size Classes
Use @Environment(\.horizontalSizeClass) for layout decisions — never device checks:
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
| Context | horizontalSizeClass |
|---|
| iPad full-screen (any orientation) | .regular |
| iPad Split View (narrow) | .compact |
| iPad Split View (wide) | .regular |
Critical: iPad in Split View can report .compact — always use size classes, never UIDevice.current.
Adaptive Layout Switching
@Environment(\.horizontalSizeClass) private var sizeClass
var body: some View {
let layout = sizeClass == .compact
? AnyLayout(VStackLayout(spacing: 16))
: AnyLayout(HStackLayout(spacing: 24))
layout {
ContentBlockA()
ContentBlockB()
}
}
Adaptive Grids
Always use GridItem(.adaptive(minimum:maximum:)) — automatically adjusts columns:
LazyVGrid(
columns: [GridItem(.adaptive(minimum: 160, maximum: 320))],
spacing: 16
) {
ForEach(items) { item in CardView(item: item) }
}
.padding()
Readability on Wide Screens
Constrain text content width on iPad to maintain readability:
ScrollView {
content
.frame(maxWidth: 700)
.frame(maxWidth: .infinity)
}
Form Layout
Forms should not stretch full-width on iPad:
@Environment(\.horizontalSizeClass) private var sizeClass
Form {
Section("General") { }
}
.formStyle(.grouped)
.frame(maxWidth: sizeClass == .regular ? 600 : .infinity)
ViewThatFits (component-level)
Use when a component should pick the best layout for available space:
ViewThatFits {
HStack(spacing: 16) { icon; title; subtitle; actionButton }
VStack(alignment: .leading, spacing: 8) {
HStack { icon; title }; subtitle; actionButton
}
}
Relative Sizing
Prefer containerRelativeFrame over GeometryReader:
Image("photo")
.containerRelativeFrame(.horizontal, count: 3, span: 1, spacing: 16)
iPad Layout Rules
- NEVER use
UIDevice.current, UIScreen.main.bounds, or #if targetEnvironment for layout
- NEVER hardcode frame widths or column counts
- ALWAYS use
GridItem(.adaptive(minimum:)) for grids
- ALWAYS constrain text to ~700pt max width on wide screens
- ALWAYS use
.leading/.trailing — never .left/.right
- Use
@ScaledMetric for custom dimensions that respect Dynamic Type