SwiftUI Layout Best Practices Reference
SwiftUI Layout Best Practices Reference
Section titled “SwiftUI Layout Best Practices Reference”Table of Contents
Section titled “Table of Contents”- Relative Layout Over Constants
- Context-Agnostic Views
- Own Your Container
- Layout Performance
- View Logic and Testability
- Full-Width Views
- Action Handlers
- Summary Checklist
Relative Layout Over Constants
Section titled “Relative Layout Over Constants”Use dynamic layout calculations instead of hard-coded values.
// Good - relative to actual layoutGeometryReader { geometry in VStack { HeaderView() .frame(height: geometry.size.height * 0.2) ContentView() }}
// Avoid - magic numbers that don't adaptVStack { HeaderView() .frame(height: 150) // Doesn't adapt to different screens ContentView()}Why: Hard-coded values don’t account for different screen sizes, orientations, or dynamic content (like status bars during phone calls).
Context-Agnostic Views
Section titled “Context-Agnostic Views”Views should work in any context. Never assume presentation style or screen size.
// Good - adapts to given spacestruct ProfileCard: View { let user: User
var body: some View { VStack { Image(user.avatar) .resizable() .aspectRatio(contentMode: .fit) Text(user.name) Spacer() } .padding() }}
// Avoid - assumes full screenstruct ProfileCard: View { let user: User
var body: some View { VStack { Image(user.avatar) .frame(width: UIScreen.main.bounds.width) // Wrong! Text(user.name) } }}Why: Views should work as full screens, modals, sheets, popovers, or embedded content.
Own Your Container
Section titled “Own Your Container”Custom views should own static containers but not lazy/repeatable ones.
// Good - owns static containerstruct HeaderView: View { var body: some View { HStack { Image(systemName: "star") Text("Title") Spacer() } }}
// Avoid - missing containerstruct HeaderView: View { var body: some View { Image(systemName: "star") Text("Title") // Caller must wrap in HStack }}
// Good - caller owns lazy containerstruct FeedView: View { let items: [Item]
var body: some View { LazyVStack { ForEach(items) { item in ItemRow(item: item) } } }}Layout Performance
Section titled “Layout Performance”Avoid Layout Thrash
Section titled “Avoid Layout Thrash”Minimize deep view hierarchies and excessive layout dependencies.
// Bad - deep nesting, excessive layout passesVStack { HStack { VStack { HStack { VStack { Text("Deep") } } } }}
// Good - flatter hierarchyVStack { Text("Shallow") Text("Structure")}Avoid excessive GeometryReader and preference chains:
// Bad - multiple geometry readers cause layout thrashGeometryReader { outerGeometry in VStack { GeometryReader { innerGeometry in // Layout recalculates multiple times } }}
// Good - single geometry reader or use alternatives (iOS 17+)containerRelativeFrame(.horizontal) { width, _ in width * 0.8}Gate frequent geometry updates:
// Bad - updates on every pixel change.onPreferenceChange(ViewSizeKey.self) { size in currentSize = size}
// Good - gate by threshold.onPreferenceChange(ViewSizeKey.self) { size in let difference = abs(size.width - currentSize.width) if difference > 10 { // Only update if significant change currentSize = size }}View Logic and Testability
Section titled “View Logic and Testability”Keep Business Logic in Services and Models
Section titled “Keep Business Logic in Services and Models”Business logic belongs in services and models, not in views. Views should stay simple and declarative — orchestrating UI state, not implementing business rules. This makes logic independently testable without requiring view instantiation.
iOS 17+: Use
@Observablewith@State.
@Observablefinal class AuthService { var email = "" var password = "" var isValid: Bool { !email.isEmpty && password.count >= 8 }
func login() async throws { // Business logic here — testable without the view }}
struct LoginView: View { @State private var authService = AuthService()
var body: some View { Form { TextField("Email", text: $authService.email) SecureField("Password", text: $authService.password) Button("Login") { Task { try? await authService.login() } } .disabled(!authService.isValid) } }}For iOS 16 and earlier, use ObservableObject with @StateObject – see state-management.md for the legacy pattern.
Avoid embedding business logic directly in view closures (e.g., validation checks inside a Button action). This makes logic untestable without view instantiation.
Note: This is about making business logic testable, not about enforcing a specific architecture. The key is that logic lives outside views where it can be tested independently.
Full-Width Views
Section titled “Full-Width Views”When a single view needs to fill the available width, use .frame(maxWidth: .infinity, alignment:) instead of wrapping it in a stack with a Spacer.
// Good - frame modifierText("Hello") .frame(maxWidth: .infinity, alignment: .leading)
// Avoid - unnecessary stack and spacerHStack { Text("Hello") Spacer()}Why: .frame(maxWidth:alignment:) is a single modifier that clearly communicates intent. Wrapping in an HStack with a Spacer adds an extra container to the view hierarchy for no benefit.
Action Handlers
Section titled “Action Handlers”Separate layout from logic. View body should reference action methods, not contain inline logic.
// Good - action references methodButton("Publish Project", action: publishService.handlePublish)
// Avoid - multi-line logic in closureButton("Publish Project") { isLoading = true apiService.publish(project) { result in /* ... */ }}Summary Checklist
Section titled “Summary Checklist”- Use relative layout over hard-coded constants
- Views work in any context (don’t assume screen size)
- Custom views own static containers
- Avoid deep view hierarchies (layout thrash)
- Gate frequent geometry updates by thresholds
- Business logic kept in services and models (not in views)
- Action handlers reference methods, not inline logic
- Use
.frame(maxWidth: .infinity, alignment:)for full-width views (notHStack+Spacer) - Avoid excessive
GeometryReaderusage - Use
containerRelativeFrame()when appropriate