Copilot, Claude, Cursor, and others all read from ~/.agents/. The npx skills CLI handles fan-out to tool-specific directories.
5.4 KiB
5.4 KiB
| name | description | globs | |
|---|---|---|---|
| SwiftUI MVVM | View/State separation patterns for SwiftUI with Observable stores |
|
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
@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
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
// 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
// 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
// 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:
- The view has complex local state that doesn't need to persist
- You need to transform data from multiple stores for a single view
- The view has form validation with many fields
- You're wrapping a UIKit component that needs state management
@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)
}
}