Copilot, Claude, Cursor, and others all read from ~/.agents/. The npx skills CLI handles fan-out to tool-specific directories.
229 lines
5.4 KiB
Markdown
229 lines
5.4 KiB
Markdown
---
|
|
name: SwiftUI MVVM
|
|
description: View/State separation patterns for SwiftUI with Observable stores
|
|
globs: ["**/*.swift"]
|
|
---
|
|
|
|
# View/State Separation (MVVM-lite)
|
|
|
|
**Views should be "dumb" renderers.** All business logic belongs in stores or dedicated view models.
|
|
|
|
## What Belongs in State/Store
|
|
|
|
- **Business logic**: Calculations, validations, rules
|
|
- **Computed properties based on data**: Hints, recommendations, derived values
|
|
- **State checks**: `canSubmit`, `isLoading`, `hasError`
|
|
- **Data transformations**: Filtering, sorting, aggregations
|
|
- **Side effects**: Network calls, persistence, analytics
|
|
|
|
## What is Acceptable in Views
|
|
|
|
- **Pure UI layout logic**: Adaptive layouts based on size class
|
|
- **Visual styling**: Color selection based on state
|
|
- **@ViewBuilder sub-views**: Breaking up complex layouts (keep in same file if small)
|
|
- **Accessibility labels**: Combining data into accessible descriptions
|
|
- **Simple conditionals for UI**: `if isExpanded { ... }`
|
|
|
|
## Store Pattern
|
|
|
|
```swift
|
|
@Observable
|
|
@MainActor
|
|
final class FeatureStore {
|
|
// MARK: - State
|
|
private(set) var items: [Item] = []
|
|
private(set) var isLoading = false
|
|
private(set) var error: Error?
|
|
|
|
// MARK: - Computed Properties (Business Logic)
|
|
var isEmpty: Bool { items.isEmpty }
|
|
var itemCount: Int { items.count }
|
|
var canSubmit: Bool { !selectedItems.isEmpty && !isLoading }
|
|
|
|
var filteredItems: [Item] {
|
|
guard !searchText.isEmpty else { return items }
|
|
return items.filter { $0.name.localizedStandardContains(searchText) }
|
|
}
|
|
|
|
// MARK: - User Input
|
|
var searchText = ""
|
|
var selectedItems: Set<Item.ID> = []
|
|
|
|
// MARK: - Dependencies
|
|
private let service: FeatureServiceProtocol
|
|
|
|
init(service: FeatureServiceProtocol) {
|
|
self.service = service
|
|
}
|
|
|
|
// MARK: - Actions
|
|
func load() async {
|
|
isLoading = true
|
|
defer { isLoading = false }
|
|
|
|
do {
|
|
items = try await service.fetchItems()
|
|
} catch {
|
|
self.error = error
|
|
}
|
|
}
|
|
|
|
func submit() async {
|
|
guard canSubmit else { return }
|
|
// ...
|
|
}
|
|
}
|
|
```
|
|
|
|
## View Pattern
|
|
|
|
```swift
|
|
struct FeatureView: View {
|
|
@Bindable var store: FeatureStore
|
|
|
|
var body: some View {
|
|
List(store.filteredItems) { item in
|
|
ItemRow(item: item)
|
|
}
|
|
.searchable(text: $store.searchText)
|
|
.overlay {
|
|
if store.isEmpty {
|
|
ContentUnavailableView("No Items", systemImage: "tray")
|
|
}
|
|
}
|
|
.toolbar {
|
|
Button("Submit") {
|
|
Task { await store.submit() }
|
|
}
|
|
.disabled(!store.canSubmit)
|
|
}
|
|
.task {
|
|
await store.load()
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
## Bad vs Good Examples
|
|
|
|
### Validation Logic
|
|
|
|
```swift
|
|
// BAD - Business logic in view
|
|
struct MyView: View {
|
|
@Bindable var store: FeatureStore
|
|
|
|
private var isValid: Bool {
|
|
!store.name.isEmpty && store.email.contains("@")
|
|
}
|
|
|
|
var body: some View {
|
|
Button("Save") { }
|
|
.disabled(!isValid)
|
|
}
|
|
}
|
|
|
|
// GOOD - Logic in Store, view just reads
|
|
// In FeatureStore:
|
|
var isValid: Bool {
|
|
!name.isEmpty && email.contains("@")
|
|
}
|
|
|
|
// In View:
|
|
Button("Save") { store.save() }
|
|
.disabled(!store.isValid)
|
|
```
|
|
|
|
### Filtering Logic
|
|
|
|
```swift
|
|
// BAD - Filtering in view
|
|
struct ListView: View {
|
|
@Bindable var store: ListStore
|
|
|
|
var filteredItems: [Item] {
|
|
store.items.filter { $0.isActive && $0.category == selectedCategory }
|
|
}
|
|
|
|
var body: some View {
|
|
List(filteredItems) { ... }
|
|
}
|
|
}
|
|
|
|
// GOOD - Filtering in Store
|
|
// In ListStore:
|
|
var filteredItems: [Item] {
|
|
items.filter { $0.isActive && $0.category == selectedCategory }
|
|
}
|
|
|
|
// In View:
|
|
List(store.filteredItems) { ... }
|
|
```
|
|
|
|
### Error Handling
|
|
|
|
```swift
|
|
// BAD - Error formatting in view
|
|
struct ProfileView: View {
|
|
@Bindable var store: ProfileStore
|
|
|
|
var errorMessage: String? {
|
|
guard let error = store.error else { return nil }
|
|
if let networkError = error as? NetworkError {
|
|
switch networkError {
|
|
case .notFound: return "Profile not found"
|
|
case .unauthorized: return "Please sign in"
|
|
default: return "Something went wrong"
|
|
}
|
|
}
|
|
return error.localizedDescription
|
|
}
|
|
}
|
|
|
|
// GOOD - Error message in Store
|
|
// In ProfileStore:
|
|
var errorMessage: String? {
|
|
guard let error else { return nil }
|
|
return ErrorFormatter.message(for: error)
|
|
}
|
|
|
|
// In View:
|
|
if let message = store.errorMessage {
|
|
Text(message)
|
|
}
|
|
```
|
|
|
|
## When to Create a Separate ViewModel
|
|
|
|
Use a dedicated ViewModel (instead of a Store) when:
|
|
|
|
1. The view has complex local state that doesn't need to persist
|
|
2. You need to transform data from multiple stores for a single view
|
|
3. The view has form validation with many fields
|
|
4. You're wrapping a UIKit component that needs state management
|
|
|
|
```swift
|
|
@Observable
|
|
@MainActor
|
|
final class FormViewModel {
|
|
// Form-specific state
|
|
var firstName = ""
|
|
var lastName = ""
|
|
var email = ""
|
|
|
|
// Validation
|
|
var isValid: Bool {
|
|
!firstName.isEmpty && !lastName.isEmpty && email.contains("@")
|
|
}
|
|
|
|
var firstNameError: String? {
|
|
firstName.isEmpty ? "First name is required" : nil
|
|
}
|
|
|
|
// Submit creates domain object
|
|
func createUser() -> User {
|
|
User(firstName: firstName, lastName: lastName, email: email)
|
|
}
|
|
}
|
|
```
|