Swiftui mvvm
Production-tested iOS Agent Skills for Claude Code, Codex, and 40+ AI coding tools. 8 enterprise-grade skills covering SwiftUI MVVM, UIKit MVVM, VIPER, TCA, Swift Concurrency, GCD, Testing, and Security Audit.
npx -y skills add rusel95/ios-agent-skills --skill swiftui-mvvmAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 7 stars7 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
What its author says it does
Copied from the file, not written here
Use this skill when working with SwiftUI ViewModels — creating, refactoring, or testing them. Triggers for: setting up a ViewModel for a SwiftUI screen, extracting logic from a View into a ViewModel, migrating from ObservableObject to @Observable, modeling async state (instead of separate Bool flags like isLoading/hasError), injecting dependencies into ViewModels, writing unit tests for @Observable ViewModels, NavigationStack/Router setup, or any question about SwiftUI app architecture. Also use when a SwiftUI View imports too much business logic, when someone asks how to structure a SwiftUI screen 'the modern way,' or when they ask about @State/@Bindable ownership, ViewState patterns, or why their ViewModel shouldn't import SwiftUI.
SKILL.md
16.6 KB, as published. Nobody here has run it
Approach: Production-First Iterative Refactoring — This skill is built for production enterprise codebases where stability and reviewability matter more than speed. Architecture changes are delivered through iterative refactoring — small, focused PRs (≤200 lines, single concern) tracked in a
refactoring/directory. Critical safety issues ship first; cosmetic improvements come last.
SwiftUI MVVM Architecture (iOS 17+)
Enterprise-grade SwiftUI MVVM architecture skill. Opinionated: prescribes @Observable ViewModels, Router navigation, constructor injection, ViewState enum, and Repository-based networking. Adopts a production-first iterative refactoring approach — every pattern is chosen for testability, reviewability, and safe incremental adoption in large teams. For non-architectural SwiftUI API guidance (animations, modern API replacements, Liquid Glass), use a general SwiftUI skill instead.
Architecture Layers
View Layer → SwiftUI Views. Declarative UI only. Owns ViewModel via @State.
ViewModel Layer → @Observable @MainActor final class. Exposes ViewState<T>.
Repository Layer → Protocol-based data access. Hides data source details.
Service Layer → URLSession, persistence. Injected via protocol.
Quick Decision Trees
"Should this View have a ViewModel?"
Is there business logic, networking, or complex state?
├── YES → Create @Observable ViewModel
└── NO → Is it a reusable UI component (button, card, cell)?
├── YES → Plain struct with data parameters, NO ViewModel
└── NO → No ViewModel needed unless it simplifies testing
"How should I own this ViewModel?"
Does THIS view create the ViewModel?
├── YES → @State private var viewModel = MyViewModel()
└── NO → Does the view need $ bindings to ViewModel properties?
├── YES → @Bindable var viewModel: MyViewModel
└── NO → let viewModel: MyViewModel (plain property)
"Where do dependencies come from?"
ViewModel always receives dependencies via constructor:
init(repository: ItemRepositoryProtocol)
How does the View get the dependency to pass?
├── Shared service (used across many screens)
│ └── Register via @Entry in EnvironmentValues
│ View reads @Environment(\.repo), passes to VM init
├── Screen-specific dependency (passed by parent)
│ └── View receives it as init parameter, passes to VM init
└── Outside view hierarchy (background service, deep utility)
└── @Injected property wrapper (legacy/convenience only)
Workflows
Default workflow: Analyze & Refactor (below). New screen creation applies the same patterns but from a clean slate. In production enterprise codebases, most work is iterative modernization — not greenfield.
Workflow: Analyze & Refactor Existing Codebase
When: First encounter with a legacy SwiftUI codebase — the most common enterprise scenario.
- Scan for anti-patterns using the detection checklist (
references/anti-patterns.md) - Create
refactoring/directory with per-feature plan files (references/refactoring-workflow.md) - Write each issue with full description (Location, Severity, Problem, Fix) — titles alone get forgotten
- Categorize issues by severity: 🔴 Critical → 🟡 High → 🟢 Medium
- Plan Phase 1 PR: fix critical safety issues only (≤200 lines per PR)
- Execute one PR at a time. New findings go to
refactoring/discovered.mdwith full descriptions, NOT into current PR - After completing each fix: mark the task
- [x]in the feature file and updaterefactoring/README.mdprogress table - Proceed through phases: Critical → @Observable migration → ViewState → Architecture
Workflow: Create a New Screen
When: Building a new feature screen from scratch. Apply enterprise patterns from the start so no refactoring is needed later.
- Define the data model and repository protocol (
references/networking.md) - Create ViewModel:
@Observable @MainActor final classwithViewState<T>(references/mvvm-observable.md) - Add
// MARK: -sections: Properties, Init, Actions, Computed Properties - Create the screen View with
@State private var viewModel - Wire data loading via
.task { await viewModel.load() } - Add navigation route to Router enum (
references/navigation.md) - Register dependencies in
@Environmentor@Injected(references/dependency-injection.md) - Create test file with mock repository (
references/testing.md)
Workflow: Migrate ViewModel from ObservableObject
When: Modernizing existing code from Combine-based observation to @Observable.
- Add
Self._printChanges()to the View body — note current redraw triggers - Replace
ObservableObjectconformance with@Observablemacro - Remove all
@Published— plainvarproperties are auto-tracked - Replace
@StateObjectwith@Statein the owning View - Replace
@ObservedObjectwith plainlet(or@Bindableif$bindings needed) - Replace
@EnvironmentObjectwith@Environment(Type.self) - Add
@MainActorto the ViewModel class declaration - Verify with
Self._printChanges()— confirm fewer/more specific redraw triggers - Run existing tests — all must pass
- Remove
Self._printChanges()before committing
Code Generation Rules
<critical_rules> Whether generating new code or refactoring existing code, every output must be production-ready and PR-shippable — small, focused, and testable. ALWAYS:
- Mark ViewModels as
@Observable @MainActor final class - Use
private(set) varfor state properties modified only by the ViewModel - Use
ViewState<T>enum for async data — never separate boolean flags - Inject dependencies via constructor with protocol types
- Use
.task { }for initial data loading - Keep View bodies pure — no
Task { }inside body, no business logic - Use typed
enum Route: Hashablefor navigation - Add
// MARK: -sections: Properties, Init, Actions, Computed Properties - Import only
Foundation(and domain modules) in ViewModels — neverSwiftUI - Keep every generated file ≤ 400 lines. Extract subviews into dedicated files. Split ViewModel logic into extensions (
MyVM+Search.swift) or child ViewModels when approaching that limit. For legacy files, log a split task in the feature'srefactoring/plan instead of forcing it mid-refactor. - Before modifying a View or ViewModel, output a brief
<thought>analyzing its current state and redraw triggers. @Observableinstances passed to.environment(...)must be owned by a stable@State—.environment(AppTheme())creates a newAppThemeon every parent redraw, which defeats the point of environment injection and breaks observation. Always own it:@State private var theme = AppTheme()then.environment(theme).- ViewModel signals navigation intent via closures; View owns the
@Statetoggle. Instead ofviewModel.router.push(.profile(user)), the ViewModel exposesvar onOpenProfile: ((User) -> Void)?and the View sets.onOpenProfile = { user in path.append(.profile(user)) }at wire-up time. This keeps the ViewModel ignorant of navigation mechanics (testable, reusable across hosts) while the View remains the single owner of@State path. Self._printChanges()verification steps for splitting views. When fixing redraw storms, verification is a sequence, not a single check: (1) addlet _ = Self._printChanges()to both the parent view body AND the split child, (2) type/tap to produce change events, (3) confirm ONLY the target child prints during the interaction — if the parent also prints, it has a hidden read of the changing value (search the body for everystore.propertyaccess and verify equality-only reads use stored, not computed, state), (4) remove both_printChangesbefore committing. </critical_rules>
Anti-Pattern Severity Reference
When auditing a SwiftUI codebase, assign severities from this canonical table. Models systematically mis-classify these — memorize the boundaries.
| Anti-pattern | Severity | Why this level |
|---|---|---|
import SwiftUI in a ViewModel | 🔴 Critical | ViewModel leaks View-layer types; makes the ViewModel untestable outside a SwiftUI host |
UIViewController/UIView reference inside a ViewModel | 🔴 Critical | Same — hard coupling to UIKit, blocks cross-platform and blocks unit testing |
Missing @MainActor on a ViewModel that mutates UI-facing state | 🔴 Critical | Undefined behavior — UI reads off-main-thread state, potential crashes |
Force-unwrap ! on an async result in a ViewModel | 🔴 Critical | Crash vector in production |
viewModel declared as plain var without @State in the owning View | 🟡 High | A new ViewModel instance is created on every parent redraw — loses all state, fires effects repeatedly |
.onAppear { Task { await viewModel.load() } } | 🟡 High | Unmanaged: fires on every view appearance (back-nav, tab switch), not tied to task lifetime. Replace with .task { } |
| Business logic in View body (network calls, mutation) | 🟡 High | Breaks reviewability and testability, but not a crash |
Separate isLoading / error / data boolean flags on a ViewModel | 🟢 Medium | Functionally works but creates impossible states (isLoading && error != nil) — migrate to ViewState<T> enum |
Missing // MARK: - section comments | 🟢 Medium | Cosmetic, affects reviewability |
@StateObject / @ObservedObject / @EnvironmentObject in an @Observable migration path | 🟢 Medium | Works with legacy ObservableObject but should be migrated in phased PRs |
Decision rules:
- Critical = crash, data corruption, or blocks all testing
- High = not a crash, but breaks an architectural invariant that cascades (unmanaged tasks, lost state, hidden coupling)
- Medium = functional code with a long-tail maintenance cost or impossible states
Migration Mapping: ObservableObject → @Observable
This table must appear verbatim in every "migrate from ObservableObject" response — readers copy it into code reviews.
Legacy (ObservableObject) | Modern (@Observable) | Note |
|---|---|---|
class VM: ObservableObject | @Observable class VM | Add @MainActor final if it mutates UI state |
@Published var count | var count | Plain var — the macro auto-tracks reads |
@StateObject var vm = VM() in the owner | @State private var vm = VM() | @State owns the lifecycle; same semantics |
@ObservedObject var vm: VM in a child that only reads | let vm: VM | No wrapper — plain reference, tracked automatically |
@ObservedObject var vm: VM in a child that needs $vm.property | @Bindable var vm: VM | @Bindable enables two-way bindings without ownership |
@EnvironmentObject var theme: AppTheme | @Environment(AppTheme.self) private var theme | Type-based lookup; env value must be registered via .environment(themeInstance) |
ObservableObject + @Published tests assert on publisher changes | Reads to properties inside withObservationTracking { } | Modern Observation framework replaces Combine plumbing |
Common gotcha: models often apply @State to the VM in the owning view but forget to switch @ObservedObject → let or @Bindable in child views — the whole chain must migrate together or child views won't observe changes.
When generating tests, ALWAYS:
- Use protocol mocks with
var stubbed*andvar *CallCounttracking - Test through public interface, never test private methods
- Mark test classes/structs
@MainActorwhen testing@MainActorViewModels - Use
await fulfillment(of:)for async tests — NEVERwait(for:)(deadlocks) - Include memory leak detection with
addTeardownBlock { [weak sut] in XCTAssertNil(sut) }
Fallback Strategies & Loop Breakers
<fallback_strategies> When refactoring legacy code, you may encounter stubborn Swift compiler errors. If you fail to fix the same error twice, break the loop:
- @State vs @Bindable Generics: If the compiler complains about property wrapper bindings (
$), ensure you use@Bindablein subviews for@Observabletypes. Why:@Statecreates ownership (single source of truth), while@Bindableenables two-way bindings without ownership — the compiler enforces this distinction. If unresolved, temporarily use plainletand closure callbacks to unblock compilation. - NavigationStack Path Issues: If the compiler complains about
Hashableroutes ornavigationDestinationtypes, ensure yourenum Routeis perfectlyHashableand avoid passing complex models (prefer passing IDs). Why: NavigationStack serializes the path for state restoration, so every route case must be deterministically hashable. - Revert and Restart: If a View refactor spirals into 50+ compiler errors related to ambiguous type inference, stop. Propose reverting the changes and breaking the problem into two smaller phases (e.g. migrate properties first, then extract subviews). Why: SwiftUI's type inference cascades — a single change can destabilize unrelated code, and small PRs are far easier to review and debug. </fallback_strategies>
Confidence Checks
Before finalizing generated or refactored code, verify ALL:
□ No duplicate functionality — searched codebase for existing implementations
□ Architecture adherence — follows patterns already established in the project
□ Naming conventions — matches existing project naming style
□ Import check — ViewModel imports only Foundation, NOT SwiftUI
□ @MainActor — present on all ViewModel class declarations
□ ViewState — used for all async data, no separate isLoading/error booleans
□ DI — dependencies injected via protocol, not accessed via singletons
□ Task management — .task modifier for lifecycle, explicit cancellation handling
□ CancellationError — handled silently, never shown to user
□ Tests — corresponding test file exists or is created alongside
□ PR scope — changes within defined scope, new findings go to `refactoring/discovered.md`
□ File size — new files ≤ 400 lines; existing oversized files have a split task logged in `refactoring/`
Companion Skills
Before generating async ViewModel, Task, or actor code: determine the project's concurrency approach. If unclear from context, ask the user.
| Project's concurrency stack | Companion skill | Apply when |
|---|---|---|
async/await, actors, Swift 6, @MainActor | skills/swift-concurrency/SKILL.md | Writing async ViewModel methods, Task creation, actor-isolated state |
DispatchQueue, OperationQueue (legacy or hybrid) | skills/gcd-operations/SKILL.md | Writing queue-based networking, background work, thread-safe state |
If unclear, ask: "Does this project use Swift Concurrency (async/await) or GCD for async operations?"
References
| Reference | When to Read |
|---|---|
references/rules.md | Do's and Don'ts quick reference: priority rules and critical anti-patterns |
references/mvvm-observable.md | Creating ViewModels, @State/@Bindable ownership rules, migration mapping |
references/navigation.md | Router pattern, deep linking, TabView setup, sheets |
references/dependency-injection.md | @Environment, @Injected wrapper, constructor injection, testing DI |
references/networking.md | ViewState enum, Repository pattern, HTTPClient, task cancellation |
references/anti-patterns.md | Code review detection checklist, severity-ranked violations |
references/testing.md | ViewModel unit tests, async patterns, mocks, memory leak detection |
references/performance.md | Self._printChanges(), Instruments, launch time, verification evidence |
references/file-organization.md | File size guidelines, extension splitting, child ViewModels, subview extraction |
references/refactoring-workflow.md | refactoring/ directory protocol, per-feature plans, PR sizing, phase ordering |