374 lines
7.0 KiB
Markdown
374 lines
7.0 KiB
Markdown
---
|
|
name: Modern SwiftUI
|
|
description: Modern SwiftUI API usage and best practices
|
|
globs: ["**/*.swift"]
|
|
---
|
|
|
|
# Modern SwiftUI Patterns
|
|
|
|
Use modern SwiftUI APIs and avoid deprecated patterns.
|
|
|
|
## Styling APIs
|
|
|
|
### Use foregroundStyle() Not foregroundColor()
|
|
|
|
```swift
|
|
// BAD - Deprecated
|
|
Text("Hello")
|
|
.foregroundColor(.blue)
|
|
|
|
// GOOD
|
|
Text("Hello")
|
|
.foregroundStyle(.blue)
|
|
|
|
// GOOD - With gradients
|
|
Text("Hello")
|
|
.foregroundStyle(.linearGradient(colors: [.blue, .purple], startPoint: .leading, endPoint: .trailing))
|
|
```
|
|
|
|
### Use clipShape(.rect()) Not cornerRadius()
|
|
|
|
```swift
|
|
// BAD - Deprecated
|
|
Image("photo")
|
|
.cornerRadius(12)
|
|
|
|
// GOOD
|
|
Image("photo")
|
|
.clipShape(.rect(cornerRadius: 12))
|
|
|
|
// GOOD - With specific corners
|
|
Image("photo")
|
|
.clipShape(.rect(cornerRadii: .init(topLeading: 12, topTrailing: 12)))
|
|
```
|
|
|
|
### Use bold() Not fontWeight(.bold)
|
|
|
|
```swift
|
|
// Less preferred
|
|
Text("Title")
|
|
.fontWeight(.bold)
|
|
|
|
// Preferred
|
|
Text("Title")
|
|
.bold()
|
|
```
|
|
|
|
## Navigation
|
|
|
|
### Use NavigationStack with navigationDestination
|
|
|
|
```swift
|
|
// BAD - Old NavigationView with NavigationLink
|
|
NavigationView {
|
|
List(items) { item in
|
|
NavigationLink(destination: DetailView(item: item)) {
|
|
ItemRow(item: item)
|
|
}
|
|
}
|
|
}
|
|
|
|
// GOOD - NavigationStack with typed destinations
|
|
NavigationStack {
|
|
List(items) { item in
|
|
NavigationLink(value: item) {
|
|
ItemRow(item: item)
|
|
}
|
|
}
|
|
.navigationDestination(for: Item.self) { item in
|
|
DetailView(item: item)
|
|
}
|
|
}
|
|
```
|
|
|
|
### Use NavigationPath for Programmatic Navigation
|
|
|
|
```swift
|
|
@Observable
|
|
@MainActor
|
|
final class NavigationStore {
|
|
var path = NavigationPath()
|
|
|
|
func navigate(to item: Item) {
|
|
path.append(item)
|
|
}
|
|
|
|
func popToRoot() {
|
|
path.removeLast(path.count)
|
|
}
|
|
}
|
|
```
|
|
|
|
## Observable Pattern
|
|
|
|
### Use @Observable Not ObservableObject
|
|
|
|
```swift
|
|
// BAD - Old Combine-based pattern
|
|
class FeatureStore: ObservableObject {
|
|
@Published var items: [Item] = []
|
|
}
|
|
|
|
struct FeatureView: View {
|
|
@StateObject var store = FeatureStore()
|
|
// or @ObservedObject
|
|
}
|
|
|
|
// GOOD - Modern Observation
|
|
@Observable
|
|
@MainActor
|
|
final class FeatureStore {
|
|
var items: [Item] = []
|
|
}
|
|
|
|
struct FeatureView: View {
|
|
@State var store = FeatureStore()
|
|
// or for external injection:
|
|
@Bindable var store: FeatureStore
|
|
}
|
|
```
|
|
|
|
## Event Handling
|
|
|
|
### Use Button Not onTapGesture()
|
|
|
|
```swift
|
|
// BAD - No accessibility, no button styling
|
|
Text("Submit")
|
|
.onTapGesture {
|
|
submit()
|
|
}
|
|
|
|
// GOOD - Proper button semantics
|
|
Button("Submit") {
|
|
submit()
|
|
}
|
|
|
|
// When you need tap location/count, onTapGesture is acceptable
|
|
SomeView()
|
|
.onTapGesture(count: 2) { location in
|
|
handleDoubleTap(at: location)
|
|
}
|
|
```
|
|
|
|
### Use Two-Parameter onChange()
|
|
|
|
```swift
|
|
// BAD - Deprecated single parameter
|
|
.onChange(of: searchText) { newValue in
|
|
search(for: newValue)
|
|
}
|
|
|
|
// GOOD - Two parameter version
|
|
.onChange(of: searchText) { oldValue, newValue in
|
|
search(for: newValue)
|
|
}
|
|
|
|
// GOOD - When you don't need old value
|
|
.onChange(of: searchText) { _, newValue in
|
|
search(for: newValue)
|
|
}
|
|
```
|
|
|
|
## Layout
|
|
|
|
### Avoid UIScreen.main.bounds
|
|
|
|
```swift
|
|
// BAD - Hardcoded screen size
|
|
let width = UIScreen.main.bounds.width
|
|
|
|
// GOOD - GeometryReader when needed
|
|
GeometryReader { geometry in
|
|
SomeView()
|
|
.frame(width: geometry.size.width * 0.8)
|
|
}
|
|
|
|
// BETTER - containerRelativeFrame (iOS 17+)
|
|
SomeView()
|
|
.containerRelativeFrame(.horizontal) { size, _ in
|
|
size * 0.8
|
|
}
|
|
```
|
|
|
|
### Prefer containerRelativeFrame Over GeometryReader
|
|
|
|
```swift
|
|
// Avoid GeometryReader when possible
|
|
ScrollView(.horizontal) {
|
|
LazyHStack {
|
|
ForEach(items) { item in
|
|
ItemCard(item: item)
|
|
.containerRelativeFrame(.horizontal, count: 3, spacing: 16)
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
## View Composition
|
|
|
|
### Extract to View Structs Not Computed Properties
|
|
|
|
```swift
|
|
// BAD - Computed properties for view composition
|
|
struct ContentView: View {
|
|
private var header: some View {
|
|
HStack {
|
|
Text("Title")
|
|
Spacer()
|
|
Button("Action") { }
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
VStack {
|
|
header
|
|
// ...
|
|
}
|
|
}
|
|
}
|
|
|
|
// GOOD - Separate View struct
|
|
struct HeaderView: View {
|
|
let title: String
|
|
let action: () -> Void
|
|
|
|
var body: some View {
|
|
HStack {
|
|
Text(title)
|
|
Spacer()
|
|
Button("Action", action: action)
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### Avoid AnyView
|
|
|
|
```swift
|
|
// BAD - Type erasure loses optimization
|
|
func makeView(for type: ViewType) -> AnyView {
|
|
switch type {
|
|
case .list: return AnyView(ListView())
|
|
case .grid: return AnyView(GridView())
|
|
}
|
|
}
|
|
|
|
// GOOD - @ViewBuilder
|
|
@ViewBuilder
|
|
func makeView(for type: ViewType) -> some View {
|
|
switch type {
|
|
case .list: ListView()
|
|
case .grid: GridView()
|
|
}
|
|
}
|
|
```
|
|
|
|
## Lists and ForEach
|
|
|
|
### Use Identifiable Conformance for ForEach
|
|
|
|
Let `ForEach` use `Identifiable` conformance directly — don't bypass it with manual key paths or offset-based identity.
|
|
|
|
```swift
|
|
// BAD - offset-based id breaks animations when items change
|
|
ForEach(Array(items.enumerated()), id: \.offset) { index, item in
|
|
// ...
|
|
}
|
|
|
|
// BAD - index-based id has the same animation problem
|
|
ForEach(items.indices, id: \.self) { index in
|
|
let item = items[index]
|
|
// ...
|
|
}
|
|
|
|
// GOOD - Identifiable items work directly (protocol-based)
|
|
ForEach(items) { item in
|
|
// ...
|
|
}
|
|
|
|
// GOOD - When index is also needed, use enumerated keyed on the item's identity
|
|
ForEach(Array(items.enumerated()), id: \.element.id) { index, item in
|
|
// Stable identity comes from item.id (Identifiable conformance),
|
|
// not from the offset — so animations and diffing work correctly.
|
|
}
|
|
```
|
|
|
|
### Hide Scroll Indicators
|
|
|
|
```swift
|
|
// Use scrollIndicators modifier
|
|
ScrollView {
|
|
// content
|
|
}
|
|
.scrollIndicators(.hidden)
|
|
```
|
|
|
|
## Button Labels with Images
|
|
|
|
### Always Include Text with Image Buttons
|
|
|
|
```swift
|
|
// BAD - No accessibility label
|
|
Button {
|
|
addItem()
|
|
} label: {
|
|
Image(systemName: "plus")
|
|
}
|
|
|
|
// GOOD - Text alongside image
|
|
Button {
|
|
addItem()
|
|
} label: {
|
|
Label("Add Item", systemImage: "plus")
|
|
}
|
|
|
|
// GOOD - If you only want to show the image
|
|
Button {
|
|
addItem()
|
|
} label: {
|
|
Label("Add Item", systemImage: "plus")
|
|
.labelStyle(.iconOnly)
|
|
}
|
|
```
|
|
|
|
## Design Constants
|
|
|
|
### Never Use Raw Numeric Literals
|
|
|
|
```swift
|
|
// BAD
|
|
.padding(16)
|
|
.clipShape(.rect(cornerRadius: 12))
|
|
.opacity(0.7)
|
|
|
|
// GOOD - Use design constants
|
|
.padding(Design.Spacing.medium)
|
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
|
.opacity(Design.Opacity.strong)
|
|
```
|
|
|
|
### Never Use Inline Colors
|
|
|
|
```swift
|
|
// BAD
|
|
.foregroundStyle(Color(red: 0.2, green: 0.4, blue: 0.8))
|
|
.background(Color(hex: "#3366CC"))
|
|
|
|
// GOOD - Semantic color names
|
|
.foregroundStyle(Color.Theme.primary)
|
|
.background(Color.Background.secondary)
|
|
```
|
|
|
|
## Image Rendering
|
|
|
|
### Prefer ImageRenderer Over UIGraphicsImageRenderer
|
|
|
|
```swift
|
|
// For SwiftUI → Image conversion
|
|
let renderer = ImageRenderer(content: MyView())
|
|
if let uiImage = renderer.uiImage {
|
|
// use image
|
|
}
|
|
```
|