iOS SwiftUI MVVM Skill
Description
This skill defines the architecture, conventions, patterns, and templates for iOS application development using SwiftUI + MVVM + async/await. It enforces scope-based feature isolation, mandatory preview stubs, extension-heavy code style, and protocol-based dependency injection.
Activates when: The task involves SwiftUI views, iOS app features, MVVM architecture, Swift models, ViewModels, repositories, networking, navigation, or any iOS-specific development.
Target: iOS 17+ · SwiftUI · Swift 5.9+ · @Observable macro · Swift Concurrency · Swift Testing
References
| File | Content |
|---|---|
references/architecture-examples.md |
Full working code examples for every layer (Model, ViewModel, View, Repository, DI, App Root, Extensions, Error Handling, Persistence, Theming, AppRadius, Liquid Glass, Assets/Fonts, Testing) |
references/mvvm-templates.md |
Copy-paste scaffolding templates with {Feature} placeholders for quick feature creation |
references/routing-and-networking.md |
Navigation (Route enum, AppRouter, MainTabView, DeepLinkHandler) + NetworkService (Protocol, Impl, Endpoint, HTTPMethod) |
references/checklist.md |
Pre-submission verification checklist (Architecture, Models, ViewModels, Views, Naming, Errors, Testing, Design, Accessibility, Localisation, Components) |
../../docs/overview.md |
Full architecture philosophy, scope-based MVVM deep-dive, data flow diagrams, and working examples |
Project Structure — Mandatory Layout
ProjectName/
├── App/
│ ├── ProjectNameApp.swift # @main, .environment() setup
│ ├── AppState.swift # @Observable shared app state
│ ├── Launch/
│ │ └── SplashScreen.swift # Splash / launch animation screen
│ └── DI/
│ └── DependencyContainer.swift # Core service registration
│
├── Network/
│ ├── NetworkService.swift # Protocol + URLSession implementation
│ ├── Endpoint.swift # Enum-based API endpoint definitions
│ ├── HTTPMethod.swift # HTTP method enum
│ └── NetworkError.swift # Network-layer error types
│
├── Core/
│ ├── Storage/
│ │ ├── SessionService.swift
│ │ └── KeychainService.swift
│ ├── Extensions/
│ │ ├── ViewModifiers.swift
│ │ ├── DateFormatting.swift
│ │ ├── StringValidation.swift
│ │ ├── ColorTheme.swift
│ │ └── URLRequestBuilder.swift
│ ├── Components/
│ │ ├── AppButton.swift
│ │ ├── AppTextField.swift
│ │ ├── LoadingView.swift
│ │ └── ErrorView.swift
│ ├── Theme/
│ │ ├── AppTheme.swift
│ │ ├── AppSpacing.swift
│ │ └── AppRadius.swift
│ ├── Utils/
│ │ ├── AppError.swift
│ │ └── ResultExtensions.swift
│ └── Constants/
│ ├── AppConstants.swift
│ ├── ApiEndpoints.swift
│ └── AppStrings.swift # Shared localized string keys
│
├── Features/
│ └── {Feature}/
│ ├── Model/
│ │ ├── {Feature}Model.swift
│ │ └── {Feature}ModelStub.swift
│ ├── View/
│ │ ├── {Feature}Screen.swift
│ │ ├── {Feature}View.swift
│ │ └── {Feature}Row.swift # Feature-specific components (flat, no subfolder)
│ ├── ViewModel/
│ │ ├── {Feature}ViewModel.swift
│ │ └── {Feature}ViewModelStub.swift
│ └── Repository/
│ ├── {Feature}Repository.swift
│ └── {Feature}RepositoryImpl.swift
│
├── Shared/
│ ├── Models/
│ ├── Views/
│ └── Modifiers/
│
├── Navigation/
│ ├── AppRouter.swift
│ ├── Route.swift
│ ├── MainTabView.swift
│ └── DeepLinkHandler.swift
│
└── Resources/
├── Assets.xcassets
└── Localizable.xcstrings
Naming Conventions
| Item | Convention | Example |
|---|---|---|
| Feature folder | PascalCase singular | Product/, Auth/, Profile/ |
| Model file | {Feature}Model.swift |
ProductModel.swift |
| Model stub | {Feature}ModelStub.swift |
ProductModelStub.swift |
| ViewModel file | {Feature}ViewModel.swift |
ProductViewModel.swift |
| ViewModel stub | {Feature}ViewModelStub.swift |
ProductViewModelStub.swift |
| Screen (owns VM) | {Feature}Screen.swift |
ProductScreen.swift |
| View (pure UI) | {Feature}{Purpose}View.swift |
ProductListView.swift |
| Repository protocol | {Feature}Repository.swift |
ProductRepository.swift |
| Repository impl | {Feature}RepositoryImpl.swift |
ProductRepositoryImpl.swift |
| Extension file | TypePurpose.swift |
ViewModifiers.swift, DateFormatting.swift |
| Component (shared) | App{Component}.swift |
AppButton.swift, AppTextField.swift |
| Component (feature) | {Feature}{Component}.swift |
ProductRow.swift, ProductPriceTag.swift |
CRITICAL: No + character in ANY filename. Use TypePurpose.swift not Type+Purpose.swift.
MVVM Architecture
View Layer → ViewModel Layer → Data Layer
(SwiftUI Views) (@Observable classes) (Repository + NetworkService)
- Renders UI - Holds state - Fetches data
- Dispatches actions - Async business logic - Maps responses
- Observes state - Calls repository - Returns Result<T, AppError>
Architecture Rules
- Views never call NetworkService directly — always through ViewModel → Repository
- ViewModels never import SwiftUI — only
Foundationand domain types - Repositories never hold UI state — they return data, ViewModels manage state
- NetworkService is generic — it doesn't know about specific models
- Each layer depends only on the layer below it — no circular dependencies
State Management Rules
| Wrapper | Where | When |
|---|---|---|
@State |
View | Local value-type state OR owning an @Observable ViewModel |
@Binding |
Child View | Two-way communication with parent |
@Bindable |
View | Creating bindings to @Observable ViewModel properties |
@Environment |
View | Accessing shared dependencies (DependencyContainer, AppState, AppRouter) |
- All ViewModels use
@Observable @MainActor— no@Published, noObservableObject - No
@StateObject,@ObservedObjectfor new code (iOS 17+) ObservableObjectonly as fallback when supporting pre-iOS 17
See
references/architecture-examples.mdfor full state management examples and anti-patterns.
Model Rules
- Conform to
Codable+Identifiable+Hashable(impliesEquatable) - Use
CodingKeyswhen API field names differ from Swift property names - All properties are
letunless mutation is required - Use optionals for truly optional API fields — don't default to empty strings
- One model per file — named
{Feature}Model.swift - No logic in models — pure data containers
- Every model must have a companion
{Feature}ModelStub.swift - Components MUST accept model types — never pass decomposed primitives (
String,Int,URL?) as separate parameters when a model exists - ModelStub is the ONLY data source for previews — no inline literals, no hardcoded values, no "example" strings anywhere in
#Previewblocks
Stub Requirements
- Wrapped in
#if DEBUG - Provide
static var stub: Self— single instance with realistic data - Provide
static var stubs: [Self]— array with 3+ items showing variety - Provide named edge-case variants for preview coverage:
static var stubLongText: Self— maximum-length strings to test truncationstatic var stubNilOptionals: Self— all optional fields set tonilstatic var stubEmpty: Self— empty collections, blank strings
- Include boundary values: dates at epoch, zero counts, maximum IDs
See
references/mvvm-templates.mdfor Model and ModelStub templates.
ViewModel Rules
- Always use
@Observablemacro - Always annotate with
@MainActor— ensures all state mutations are main-thread-safe - Never import
SwiftUI - Receive repository via init (protocol-typed)
- All data methods are
async— no callbacks, no Combine - Use
defer { isLoading = false }for loading state cleanup - Handle both
.successand.failurefrom repository results - Expose
var errorMessage: String?for error display - Methods are imperative verbs:
fetchProducts(),deleteProduct(),submitForm() - Every ViewModel must have a companion
{Feature}ViewModelStub.swift
ViewModel Stub Requirements
- Wrapped in
#if DEBUG - Provide
static func stub(...)with default parameters for all observable state - Include a private
StubRepositoryclass that returns model stubs stub()works with zero arguments usingModelStubdefaults
See
references/mvvm-templates.mdfor ViewModel and ViewModelStub templates.
View Rules
Screen vs View Pattern
| Widget | Responsibility | Owns ViewModel? |
|---|---|---|
| Screen | Creates/owns ViewModel, provides to children, triggers initial load | Yes — via @State |
| View | Pure UI rendering, receives ViewModel or data, no lifecycle logic | No — receives as parameter |
View Requirements
- Every view and component file must have
#Previewblocks covering all relevant states:#Preview— default / populated state#Preview("Loading")— loading indicator visible#Preview("Empty")— empty state messaging#Preview("Error")— error state with message#Preview("Edge Case")— long text, nil optionals, boundary data (useModelStubedge variants)#Preview("Dark Mode")— with.preferredColorScheme(.dark)
- Use
#Preview("StateName")labels to clearly identify each variant - CRITICAL — No inline data in previews. Previews MUST use
ViewModelStub.stub()orModelStub.stubexclusively. NEVER pass hardcoded strings, numbers, or literal values as preview parameters.
// ❌ NEVER — inline data in preview
#Preview {
HomeScreen(title: "Welcome", userName: "John", itemCount: 5)
}
// ✅ ALWAYS — stub-driven preview
#Preview {
HomeScreen(viewModel: .stub())
}
- Components accept Model types, not primitives — a row takes
ProductModel, not(title: String, price: Double). If a component needs data, pass the model struct. - Use
.task { }for async work on appear — neveronAppearwith Task - Use
.refreshable { }for pull-to-refresh - Keep
bodyconcise — extract subviews as computed properties or separate structs (see Component Decomposition Rules) - No business logic in
body— all logic lives in ViewModel - Use
AppSpacingconstants — no magic numbers - Use
AppRadiusconstants — no raw corner radius values - Use semantic system colors (
.primary,.secondary) orAppTheme
See
references/architecture-examples.mdfor Screen, View, and Component examples. Seereferences/mvvm-templates.mdfor scaffolding templates.
Component Decomposition Rules
CRITICAL — Decompose During Initial Generation
DO NOT write a monolithic Screen. When creating a new feature, plan and create separate component files UPFRONT — not as a later refactoring step.
- A Screen's
bodyshould be ≤50 lines — it orchestrates components, not renders UI directly - Every distinct UI section (header, card, row, list, banner, stat block) MUST be its own struct in a separate file under
View/ - Each extracted component MUST have its own
#PreviewwithModelStub.stubdata
// ❌ NEVER — everything in one Screen file
struct HomeScreen: View {
var body: some View {
ScrollView {
// 200+ lines of header, cards, lists, banners...
}
}
}
// ✅ ALWAYS — Screen orchestrates, components render
struct HomeScreen: View {
@State private var viewModel: HomeViewModel
var body: some View {
ScrollView {
HomeHeaderView(user: viewModel.user)
HomeFeaturedCard(item: viewModel.featuredItem)
HomeRecentList(items: viewModel.recentItems)
}
.task { await viewModel.fetch() }
}
}
// Each component lives in its own file with its own #Preview
When to Extract a Component
Extract a piece of UI into its own file when any of these triggers apply:
| Trigger | Threshold |
|---|---|
| Line count | View body or subview exceeds ~50 lines (extract immediately) |
| Identity | The piece has its own data model or identity (Identifiable) |
| State | It manages its own @State or @Binding |
| Reuse | Used ≥2 times within the feature or across features |
| Preview need | Needs isolated preview states (loading, error, edge case) |
| Distinct section | Visually distinct UI block (header, card, row, banner, stat) |
Placement Rules
| Scope | Location | Example |
|---|---|---|
| Shared across features | Core/Components/ |
AppButton.swift, LoadingView.swift, ErrorView.swift |
| Feature-specific | Features/{Feature}/View/ (flat — no nested subfolder) |
ProductRow.swift, ProductPriceTag.swift |
Requirements
- One component per file — named
{Feature}{Component}.swift(feature) orApp{Component}.swift(shared) - Every component file must have its own
#Previewwith stub data — no exceptions - Components accept model structs — never raw primitives as parameters. Pass
ProductModelnot(title: String, price: Double, imageURL: URL?) - NEVER use inline literals in component previews — always
ModelStub.stuborModelStub.stubs. If a stub doesn't exist, create{Feature}ModelStub.swiftFIRST before writing the preview - Before writing ANY
#Previewblock, verify the corresponding{Feature}ModelStub.swiftexists. If it doesn't — stop and create it - No nested
Components/subfolder inside featureView/directories — keep flat - Inline private structs are allowed only if <20 lines with zero
@State— otherwise extract to own file
// ❌ NEVER — component with loose parameters
struct HomeItemRow: View {
let title: String
let subtitle: String
let imageURL: URL?
}
// ✅ ALWAYS — component accepts model
struct HomeItemRow: View {
let item: HomeItemModel
}
// ❌ NEVER — inline preview data
#Preview {
HomeItemRow(title: "Hello", subtitle: "World", imageURL: nil)
}
// ✅ ALWAYS — stub data
#Preview {
HomeItemRow(item: .stub)
}
Migration Note
Existing features with nested Components/ subfolders should be flattened to View/ during refactoring.
See
references/architecture-examples.mdfor Component examples.
Repository Rules
- Always define a protocol (abstract interface)
- Always create a separate
Implclass (concrete) - Return
Result<T, AppError>— never throw from public methods - Repository is stateless — no held state
- Takes
NetworkServicevia init (protocol-typed) - ViewModel depends on the protocol — enables mock injection
See
references/architecture-examples.mdfor Repository protocol + implementation examples.
NetworkService Rules
- Protocol-defined — enables mock injection
- Returns
Result<T, AppError>— never throws from public interface - Token injection via
SessionServiceautomatically - 401 responses auto-clear session
Endpointenum is single source of truth for API paths- JSON decoding uses
.iso8601date strategy
See
references/routing-and-networking.mdfor full NetworkService, Endpoint, and HTTPMethod implementations.
Extension Rules
- Naming:
TypePurpose.swiftinCore/Extensions/— no+in filename - Stubs:
{Feature}ModelStub.swiftin featureModel/folder - Extensions must be pure — no stored state, no side effects
- Group related extensions in one file
- Prefer extensions over global functions
See
references/architecture-examples.mdfor ViewModifiers, DateFormatting, StringValidation examples.
Error Handling Rules
- All repositories return
Result<T, AppError>— never throw - ViewModels switch on Result and surface
errorMessage - Views display errors via
.alertorErrorViewcomponent - No silent failures — every error shows to user or is logged
- Network errors are human-readable — never raw error strings
- Use
defer { isLoading = false }to guarantee cleanup
See
references/architecture-examples.mdfor AppError + NetworkErrorReason implementations.
Navigation Rules
- All routes defined in
Routeenum — no string-based navigation - Use
NavigationStackwithNavigationPath— notNavigationView AppRouterinjected via.environment()- Associated values for route parameters
- Sheets and full-screen covers managed through
AppRouter - Deep links convert URLs to
Routeenum values
See
references/routing-and-networking.mdfor Route, AppRouter, MainTabView, and DeepLinkHandler.
App Root Structure
Flow: Splash → Auth Check → Main App (TabView) or Login
SplashScreenis the first view — handles auth gate- Each tab has its own
NavigationStack - Splash performs startup: token validation, cache warm-up, version check
AppState.isAuthenticateddrives root-level conditional rendering- Use
withAnimationfor smooth splash → main transition
See
references/architecture-examples.mdfor SplashScreen template.
Environment Configuration Rules
- Use
#if DEBUGfor compile-time environment switching - For staging: Xcode scheme + custom build flags (
-D STAGING) AppConfig.baseURLis the single source for API host- No secrets in source code — use
.xcconfigfiles - Feature flags as static properties in
AppConfig
See
references/architecture-examples.mdfor AppConfig implementation.
DI & Environment Rules
DependencyContaineris@Observable— injected at App root via.environment()- All services are protocol-typed
- Repositories are
lazy var— created on first access - Views access container via
@Environment(DependencyContainer.self) - For previews, mock repos live in
ViewModelStub— no need to mock container AppStateholds auth status and shared user dataAppRouterholds navigation state
See
references/architecture-examples.mdfor DependencyContainer, AppState, and App entry point.
Persistence Rules
- All storage access wrapped behind protocols
- Keys in private enum — no scattered string literals
- NEVER store tokens in UserDefaults — it is plaintext on disk. Use Keychain for all sensitive data (access tokens, refresh tokens, credentials)
- Non-sensitive preferences (flags, theme, last-viewed IDs) use
UserDefaultswrapped inSessionService clearSession()removes all auth data atomically- For structured local data (offline cache, drafts), use SwiftData with
@Model— see SwiftData rules below
See
references/architecture-examples.mdfor SessionService protocol + implementation.
Theming Rules
- Never hard-coded spacing — always
AppSpacing.md,AppSpacing.lg, etc. - Never hard-coded colors — system colors (
.primary,.secondary) orAppTheme - Never hard-coded corner radii — always
AppRadius.sm,AppRadius.md,AppRadius.lg, etc. - Color assets must define light, dark, and tinted variants in the asset catalog
- Contrast ratios documented as comments next to every color definition in
AppTheme - Typography must use scalable system fonts (
.body,.headline) or.custom(..., relativeTo:)— no fixedsize:values - Prefer system semantic colors for accessibility and dark mode
- All design tokens are value types —
CGFloatconstants inAppSpacing,AppRadius,AppTheme
AppRadius Constants
// Core/Theme/AppRadius.swift
import SwiftUI
enum AppRadius {
static let xs: CGFloat = 4
static let sm: CGFloat = 8
static let md: CGFloat = 12
static let lg: CGFloat = 16
static let xl: CGFloat = 24
static let full: CGFloat = .infinity // Capsule
}
// Usage
.clipShape(RoundedRectangle(cornerRadius: AppRadius.md))
.background(Color.appSurface, in: RoundedRectangle(cornerRadius: AppRadius.lg))
See
references/architecture-examples.mdfor AppSpacing, AppRadius, and AppTheme.
Accessibility Rules — WCAG AA
WCAG AA is the enforced baseline. AAA (7:1 contrast) is aspirational.
Mandatory Requirements
| Rule | Implementation |
|---|---|
| Labels on interactive elements | .accessibilityLabel("Description") on every Button, Link, Toggle, custom control |
| Hints for non-obvious actions | .accessibilityHint("Double-tap to...") when the action isn't clear from label alone |
| Decorative images hidden | .accessibilityHidden(true) on all decorative/background images |
| Semantic traits | .accessibilityAddTraits(.isHeader) on section titles, .isButton on custom tap targets, .isSelected on active states |
| Touch targets | Minimum 44×44pt — use .frame(minWidth: 44, minHeight: 44) or .contentShape(Rectangle()) |
| Dynamic Type | No fixed font sizes — use system styles (.body, .headline) or .custom(..., relativeTo:) |
| Contrast — body text | 4.5:1 minimum against background |
| Contrast — large text & UI components | 3:1 minimum |
| No colour-only meaning | Always pair colour indicators with an icon, label, or pattern |
Testing Requirements
- Test with VoiceOver enabled — verify reading order and labels
- Test at AX5 (largest Dynamic Type) — no truncation of critical content
- Test with Reduce Motion — disable spring animations, use crossfade
- Test with Increase Contrast — verify all elements remain visible
- Test with Bold Text — verify layout doesn't break
Anti-Patterns
// ❌ No label — VoiceOver reads "button"
Button(action: delete) {
Image(systemName: "trash")
}
// ✅ Accessible
Button(action: delete) {
Image(systemName: "trash")
}
.accessibilityLabel("Delete item")
.accessibilityHint("Removes this item permanently")
// ❌ Colour-only status
Circle().fill(isActive ? .green : .red)
// ✅ Colour + icon
HStack {
Image(systemName: isActive ? "checkmark.circle.fill" : "xmark.circle.fill")
Text(isActive ? "Active" : "Inactive")
}
.foregroundStyle(isActive ? .green : .red)
// ❌ Fixed font size
Text("Title").font(.system(size: 24))
// ✅ Scalable
Text("Title").font(.title)
Localisation Rules
String Handling
- All user-facing strings must use
LocalizedStringKeyconstants — never hardcode strings in views - Shared strings live in
Core/Constants/AppStrings.swift - Feature-specific strings live in
Features/{Feature}/Strings{Feature}.swift - Every key must exist in
Localizable.xcstringswith English as the default language - Adding a new string = adding the key constant + English value to the catalog in the same commit
- Use
String(localized:)for non-view contexts (ViewModels, services) - Never concatenate localized strings — use interpolation:
"Hello, \(name)"insideLocalizedStringKey - Plurals and grammar rules use String Catalog plural variants
Pattern
// Core/Constants/AppStrings.swift
import SwiftUI
enum AppStrings {
enum General {
static let ok: LocalizedStringKey = "general_ok"
static let cancel: LocalizedStringKey = "general_cancel"
static let error: LocalizedStringKey = "general_error"
static let retry: LocalizedStringKey = "general_retry"
}
enum Product {
static let title: LocalizedStringKey = "product_title"
static let emptyState: LocalizedStringKey = "product_empty_state"
static func itemCount(_ count: Int) -> LocalizedStringKey {
"product_item_count \(count)"
}
}
}
// Usage in View
Text(AppStrings.Product.title)
Button(AppStrings.General.retry) { await viewModel.fetch() }
// Usage in ViewModel (non-view context)
errorMessage = String(localized: "network_error_no_connection")
Anti-Patterns
// ❌ Hardcoded string in view
Text("No products found")
// ❌ Concatenation
Text("Hello, " + username + "!")
// ❌ Key without catalog entry
Text("some_key_that_doesnt_exist_in_xcstrings")
// ✅ Correct
Text(AppStrings.Product.emptyState)
Liquid Glass & iOS 26 Design Rules
Liquid Glass is the primary design material in iOS 26. It forms a distinct functional layer for controls and navigation elements that floats above content.
Core Principles
- Use standard components — NavigationStack, TabView, toolbars, sheets, popovers auto-adopt Liquid Glass
- Don't apply Liquid Glass in the content layer — it's for navigation/controls only, not content views
- Use sparingly on custom views — limit
.glassEffect()to the most important functional elements - Remove custom bar backgrounds — let the system handle toolbar, tab bar, and navigation bar appearance
- Don't layer Liquid Glass on Liquid Glass — avoid overcrowding or stacking glass elements
- Use
.scrollEdgeEffectStyle()for content beneath bars — maintains legibility automatically - Prefer system button styles — use
.buttonStyle(.glass)or.buttonStyle(.glassProminent)instead of custom glass - Test with accessibility settings — reduced transparency, increased contrast, preferred Liquid Glass look
Liquid Glass Variants
| Variant | When | API |
|---|---|---|
| Regular | Default — blurs/adjusts luminosity for legibility. Alerts, sidebars, popovers | .glassEffect(.regular) |
| Clear | Over visually rich backgrounds (photos, videos) — highly translucent | .glassEffect(.clear) |
SwiftUI APIs
// Basic glass effect on custom control
Text("Label")
.padding()
.glassEffect(in: .capsule)
// Rounded rectangle shape
Text("Label")
.padding()
.glassEffect(in: .rect(cornerRadius: 16))
// Tinted + interactive (reacts to touch)
Image(systemName: "play.fill")
.padding()
.glassEffect(.regular.tint(.blue).interactive())
// Button styles
Button("Action") { }
.buttonStyle(.glass)
Button("Primary") { }
.buttonStyle(.glassProminent)
// Container for multiple glass elements (performance + morphing)
GlassEffectContainer(spacing: 20) {
HStack(spacing: 20) {
ForEach(items) { item in
ItemView(item: item)
.glassEffect()
.glassEffectID(item.id, in: namespace)
}
}
}
// Tab bar minimization on scroll
TabView { /* ... */ }
.tabBarMinimizeBehavior(.onScrollDown)
// Scroll edge effect for custom bars
CustomBar()
.safeAreaBar(edge: .bottom) { content }
Layout & Controls Changes (iOS 26)
- Lists/forms have larger row height and padding — don't fight system metrics
- Section corners are more rounded — concentric with hardware
- Section headers use title-style capitalization — not all caps
- Controls (sliders, toggles) adopt glass on interaction — no custom glass needed
- Action sheets originate from source element — always specify source view/item
- Sheets have increased corner radius and are inset from display edge
- Use
ConcentricRectangle/.rect(corners:isUniform:)for shapes matching hardware curvature
What NOT to Do
// ❌ Applying glass to content layer
List { ... }
.glassEffect() // WRONG — glass is for controls/nav, not content
// ❌ Custom toolbar backgrounds that override system
.toolbar { ... }
.toolbarBackground(.visible, for: .navigationBar)
.toolbarBackground(Color.blue, for: .navigationBar) // Remove this
// ❌ Stacking glass on glass
VStack {
Text("Item").glassEffect()
Text("Another").glassEffect() // Too many — use GlassEffectContainer or reduce
}
// ✅ Let system handle it — standard components get glass automatically
NavigationStack { ... } // Tab bars, toolbars get glass free
See
references/architecture-examples.mdfor full Liquid Glass code patterns.
Assets & Fonts Rules
- SF Symbols are the primary icon system — use
Image(systemName:)first - Custom assets go in
Assets.xcassetswith proper organization (image sets, color sets) - Support light/dark/tinted variants for all color assets
- Custom fonts registered in Info.plist — access via type-safe
Fontextension - Prefer system Dynamic Type styles (
.headline,.body) — custom fonts only when brand requires - All custom images must include @2x and @3x variants
- Use Symbol Effects for animated SF Symbols (
.symbolEffect(.bounce),.symbolEffect(.pulse)) - App icons must provide layered assets for Liquid Glass icon effects (foreground, middle, background layers)
Font Extension Pattern
// Core/Extensions/AppFonts.swift
import SwiftUI
extension Font {
static func appFont(_ style: AppFontStyle, size: CGFloat) -> Font {
.custom(style.rawValue, size: size, relativeTo: style.textStyle)
}
}
enum AppFontStyle: String {
case regular = "BrandFont-Regular"
case medium = "BrandFont-Medium"
case bold = "BrandFont-Bold"
var textStyle: Font.TextStyle {
switch self {
case .regular: return .body
case .medium: return .headline
case .bold: return .title
}
}
}
See
references/architecture-examples.mdfor asset organization and SF Symbol patterns.
Testing Rules
- Use Swift Testing (
@Test,#expect,#require,@Suite) — not XCTest - Mock via protocol conformance — no third-party frameworks
- Reuse
Model.stub/.stubs— same data as previews - Test success and failure paths for every async method
- ViewModel tests are highest priority
- Use configurable stub repositories
- Use
#requirefor unwrapping optionals — fails test immediately if nil @Suiteruns tests in parallel by default — keep tests independent
See
references/architecture-examples.mdfor full test suite example. Seereferences/mvvm-templates.mdfor test suite template.
Concurrency & Sendable Rules
Swift 6 strict concurrency is the target. Prepare all new code for full compliance.
- ViewModels —
@MainActor @Observablehandles thread safety automatically - Models —
Codable+Hashablestructs withletproperties are implicitlySendable - Repositories — mark
final classimplementations asSendablewhen they hold no mutable state (onlyletdependencies) - NetworkService — mark protocol methods as
sendingwhere applicable; implementation isSendable(stateless URLSession wrapper) - Never use
@unchecked Sendableunless wrapping a proven-safe third-party type — document why - Closures crossing actor boundaries must be
@Sendable— captured values must beSendable - Enable
-strict-concurrency=completein build settings for new projects
Common Patterns
// ✅ Repository is Sendable — all dependencies are let + Sendable
final class ProductRepositoryImpl: ProductRepository, Sendable {
private let networkService: NetworkService // protocol is Sendable
init(networkService: NetworkService) {
self.networkService = networkService
}
}
// ✅ Model structs with let properties are implicitly Sendable
struct ProductModel: Codable, Identifiable, Hashable, Sendable {
let id: String
let name: String
}
// ❌ WRONG — mutable state makes this non-Sendable
class BadRepository {
var cachedItems: [ProductModel] = [] // Not safe across actors
}
Import Organization Rules
Imports are organized in groups, separated by a blank line, alphabetically within each group:
// 1. Foundation / system frameworks
import Foundation
import SwiftUI // Only in Views — never in ViewModels
// 2. Third-party packages (if any)
import Kingfisher
// 3. Project modules (for multi-module projects)
import NetworkKit
import SharedModels
Rules
- ViewModels import only
Foundation(and domain module if multi-module) - Views import
SwiftUI— which re-exportsFoundation - Repositories import only
Foundation - Never import UIKit unless wrapping a UIKit component in
UIViewRepresentable - Remove unused imports — Xcode warns about these
- No
@testable importoutside of test targets
Task Cancellation Rules
- Prefer
.task { }modifier — SwiftUI automatically cancels when view disappears - Manual
Task {}blocks in event handlers are NOT auto-cancelled — store and cancel explicitly if needed - Check cancellation in long loops:
try Task.checkCancellation()orguard !Task.isCancelled - Never ignore cancellation — let
CancellationErrorpropagate or handle gracefully
Patterns
// ✅ .task handles cancellation automatically — preferred
struct ProductScreen: View {
var body: some View {
ProductListView(viewModel: viewModel)
.task { await viewModel.fetchProducts() } // Cancelled on disappear
.task(id: searchQuery) { await viewModel.search(searchQuery) } // Re-runs + cancels previous
}
}
// ✅ Manual cancellation for user-triggered Tasks
@Observable @MainActor
class SearchViewModel {
private var searchTask: Task<Void, Never>?
func search(_ query: String) {
searchTask?.cancel() // Cancel previous search
searchTask = Task {
try? await Task.sleep(for: .milliseconds(300)) // Debounce
guard !Task.isCancelled else { return }
await performSearch(query)
}
}
}
// ❌ WRONG — Task leaks, never cancelled
Button("Load") {
Task { await viewModel.fetchProducts() } // OK for one-shot actions
// But DON'T do this for repeating/cancellable work
}
ViewModel Cancellation
- One-shot actions (fetch, delete, submit) — no manual cancellation needed;
.taskor short-lived - Ongoing/repeating work (search-as-you-type, polling) — store
Taskreference + cancel on new input - Long operations (file upload, multi-page sync) — check
Task.isCancelledbetween steps
Pagination Pattern
For paginated / infinite scroll lists:
State
@Observable @MainActor
class ProductViewModel {
var products: [ProductModel] = []
var isLoading = false
var isLoadingMore = false
var hasMore = true
var errorMessage: String?
private var currentPage = 1
private let pageSize = 20
private let repository: ProductRepository
init(repository: ProductRepository) {
self.repository = repository
}
func fetchProducts() async {
isLoading = true
defer { isLoading = false }
currentPage = 1
let result = await repository.getProducts(page: 1, size: pageSize)
switch result {
case .success(let data):
products = data
hasMore = data.count >= pageSize
errorMessage = nil
case .failure(let error):
errorMessage = error.userMessage
}
}
func loadMore() async {
guard hasMore, !isLoadingMore else { return }
isLoadingMore = true
defer { isLoadingMore = false }
let nextPage = currentPage + 1
let result = await repository.getProducts(page: nextPage, size: pageSize)
switch result {
case .success(let data):
products.append(contentsOf: data)
currentPage = nextPage
hasMore = data.count >= pageSize
case .failure(let error):
errorMessage = error.userMessage
}
}
}
View Trigger
List(viewModel.products) { product in
ProductRow(product: product)
.onAppear {
if product.id == viewModel.products.last?.id {
Task { await viewModel.loadMore() }
}
}
}
Form Handling Pattern
Rules
- One ViewModel per form — holds field values + validation state
- Validate on submit by default. Real-time validation only for critical fields (email format, password strength)
FocusStatelives in the View — not the ViewModel- ViewModel exposes
isFormValid: Boolcomputed property for submit button state
Form ViewModel
@Observable @MainActor
class CreateProductViewModel {
// Field values
var name = ""
var description = ""
var price = ""
// Validation errors
var nameError: String?
var priceError: String?
// Submit state
var isSubmitting = false
var errorMessage: String?
var didSubmitSuccessfully = false
var isFormValid: Bool {
name.trimmed.isNotEmpty && Double(price) != nil
}
private let repository: ProductRepository
init(repository: ProductRepository) {
self.repository = repository
}
func submit() async {
// Validate
nameError = name.trimmed.isEmpty ? "Name is required" : nil
priceError = Double(price) == nil ? "Enter a valid price" : nil
guard nameError == nil, priceError == nil else { return }
// Submit
isSubmitting = true
defer { isSubmitting = false }
let product = ProductModel(
id: UUID().uuidString,
name: name.trimmed,
description: description.trimmed,
price: Double(price) ?? 0,
imageURL: nil,
category: "General",
isAvailable: true,
createdAt: .now
)
let result = await repository.createProduct(product)
switch result {
case .success:
didSubmitSuccessfully = true
case .failure(let error):
errorMessage = error.userMessage
}
}
}
Form View
struct CreateProductScreen: View {
@State private var viewModel: CreateProductViewModel
@FocusState private var focusedField: Field?
@Environment(\.dismiss) private var dismiss
enum Field { case name, description, price }
init(viewModel: CreateProductViewModel) {
_viewModel = State(initialValue: viewModel)
}
var body: some View {
Form {
Section("Details") {
TextField("Product Name", text: $viewModel.name)
.focused($focusedField, equals: .name)
if let error = viewModel.nameError {
Text(error).font(.caption).foregroundStyle(.red)
}
TextField("Description", text: $viewModel.description, axis: .vertical)
.focused($focusedField, equals: .description)
.lineLimit(3...6)
TextField("Price", text: $viewModel.price)
.focused($focusedField, equals: .price)
.keyboardType(.decimalPad)
if let error = viewModel.priceError {
Text(error).font(.caption).foregroundStyle(.red)
}
}
Section {
AppButton("Create Product", isLoading: viewModel.isSubmitting) {
focusedField = nil
Task { await viewModel.submit() }
}
.disabled(!viewModel.isFormValid)
}
}
.navigationTitle("New Product")
.onChange(of: viewModel.didSubmitSuccessfully) { _, success in
if success { dismiss() }
}
}
}
Performance Guidelines
View Performance
- Use
LazyVStack/LazyHStackfor any list with 10+ items — neverVStackwithForEachfor large collections - Extract subviews — SwiftUI diffs per-view; smaller views = fewer recomputations
- Use
@Observablegranularly — properties track individually; only views reading changed properties re-render - **Mark child views `Equa
…(truncated)