267 lines
6.8 KiB
Markdown
267 lines
6.8 KiB
Markdown
# SwiftUI Layout Best Practices Reference
|
|
|
|
## Table of Contents
|
|
|
|
- [Relative Layout Over Constants](#relative-layout-over-constants)
|
|
- [Context-Agnostic Views](#context-agnostic-views)
|
|
- [Own Your Container](#own-your-container)
|
|
- [Layout Performance](#layout-performance)
|
|
- [View Logic and Testability](#view-logic-and-testability)
|
|
- [Full-Width Views](#full-width-views)
|
|
- [Action Handlers](#action-handlers)
|
|
- [Summary Checklist](#summary-checklist)
|
|
|
|
## Relative Layout Over Constants
|
|
|
|
**Use dynamic layout calculations instead of hard-coded values.**
|
|
|
|
```swift
|
|
// Good - relative to actual layout
|
|
GeometryReader { geometry in
|
|
VStack {
|
|
HeaderView()
|
|
.frame(height: geometry.size.height * 0.2)
|
|
ContentView()
|
|
}
|
|
}
|
|
|
|
// Avoid - magic numbers that don't adapt
|
|
VStack {
|
|
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
|
|
|
|
**Views should work in any context.** Never assume presentation style or screen size.
|
|
|
|
```swift
|
|
// Good - adapts to given space
|
|
struct 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 screen
|
|
struct 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
|
|
|
|
**Custom views should own static containers but not lazy/repeatable ones.**
|
|
|
|
```swift
|
|
// Good - owns static container
|
|
struct HeaderView: View {
|
|
var body: some View {
|
|
HStack {
|
|
Image(systemName: "star")
|
|
Text("Title")
|
|
Spacer()
|
|
}
|
|
}
|
|
}
|
|
|
|
// Avoid - missing container
|
|
struct HeaderView: View {
|
|
var body: some View {
|
|
Image(systemName: "star")
|
|
Text("Title")
|
|
// Caller must wrap in HStack
|
|
}
|
|
}
|
|
|
|
// Good - caller owns lazy container
|
|
struct FeedView: View {
|
|
let items: [Item]
|
|
|
|
var body: some View {
|
|
LazyVStack {
|
|
ForEach(items) { item in
|
|
ItemRow(item: item)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
## Layout Performance
|
|
|
|
### Avoid Layout Thrash
|
|
|
|
**Minimize deep view hierarchies and excessive layout dependencies.**
|
|
|
|
```swift
|
|
// Bad - deep nesting, excessive layout passes
|
|
VStack {
|
|
HStack {
|
|
VStack {
|
|
HStack {
|
|
VStack {
|
|
Text("Deep")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Good - flatter hierarchy
|
|
VStack {
|
|
Text("Shallow")
|
|
Text("Structure")
|
|
}
|
|
```
|
|
|
|
**Avoid excessive `GeometryReader` and preference chains:**
|
|
|
|
```swift
|
|
// Bad - multiple geometry readers cause layout thrash
|
|
GeometryReader { 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:**
|
|
|
|
```swift
|
|
// 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
|
|
|
|
### 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 `@Observable` with `@State`.
|
|
|
|
```swift
|
|
@Observable
|
|
final 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
|
|
|
|
**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`.**
|
|
|
|
```swift
|
|
// Good - frame modifier
|
|
Text("Hello")
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
// Avoid - unnecessary stack and spacer
|
|
HStack {
|
|
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
|
|
|
|
**Separate layout from logic.** View body should reference action methods, not contain inline logic.
|
|
|
|
```swift
|
|
// Good - action references method
|
|
Button("Publish Project", action: publishService.handlePublish)
|
|
|
|
// Avoid - multi-line logic in closure
|
|
Button("Publish Project") {
|
|
isLoading = true
|
|
apiService.publish(project) { result in /* ... */ }
|
|
}
|
|
```
|
|
|
|
## 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 (not `HStack` + `Spacer`)
|
|
- [ ] Avoid excessive `GeometryReader` usage
|
|
- [ ] Use `containerRelativeFrame()` when appropriate
|