Remove the bundled SwiftUI skill which had 13+ broken references to a non-existent `../macos-apps/references/` directory. Add a CI script that validates all relative .md file references in bundled skills, preventing this class of bug from shipping again. Fix 5 additional pre-existing broken references in other skills. Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
69c0f68ac8
commit
816383a399
24 changed files with 179 additions and 11032 deletions
11
.github/workflows/ci.yml
vendored
11
.github/workflows/ci.yml
vendored
|
|
@ -27,6 +27,17 @@ jobs:
|
|||
exit 1
|
||||
fi
|
||||
|
||||
skill-references:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24'
|
||||
- name: Validate skill references
|
||||
run: node scripts/check-skill-references.mjs
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
|
|
|
|||
166
scripts/check-skill-references.mjs
Normal file
166
scripts/check-skill-references.mjs
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Validates that relative .md file references in bundled skills point to
|
||||
* files that actually exist on disk.
|
||||
*
|
||||
* Focused on catching broken cross-file references within skills:
|
||||
* - Markdown links to .md files: [text](path/to/file.md)
|
||||
* - Backtick-quoted .md paths that use relative navigation: `../foo/bar.md`
|
||||
* or skill subdirectory paths: `references/foo.md`, `workflows/bar.md`
|
||||
*
|
||||
* Deliberately ignores:
|
||||
* - URLs (http://, https://)
|
||||
* - Paths starting with ~ (home-dir references, not repo-relative)
|
||||
* - Glob patterns containing * or {}
|
||||
* - Template placeholders containing {{ or {word}
|
||||
* - Bare extensions like `.md`, `.ts`
|
||||
* - Example/placeholder paths (path/to/...)
|
||||
* - Paths that reference files outside the skills tree via ../ beyond the
|
||||
* skills root (those are cross-concern refs, not validatable here)
|
||||
*
|
||||
* Exit 0 if all references resolve. Exit 1 if any are broken.
|
||||
*/
|
||||
|
||||
import { readFileSync, readdirSync, existsSync } from "node:fs";
|
||||
import { join, resolve, dirname, extname } from "node:path";
|
||||
|
||||
const SKILLS_DIR = resolve("src/resources/skills");
|
||||
|
||||
/** Recursively collect all .md files under a directory. */
|
||||
function collectMdFiles(dir) {
|
||||
const results = [];
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
results.push(...collectMdFiles(full));
|
||||
} else if (entry.isFile() && extname(entry.name) === ".md") {
|
||||
results.push(full);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/** Return true if this reference should be validated. */
|
||||
function shouldValidate(ref) {
|
||||
// Must end with .md (we only validate markdown cross-references)
|
||||
if (!ref.endsWith(".md")) return false;
|
||||
// Skip URLs
|
||||
if (/^https?:\/\//.test(ref)) return false;
|
||||
// Skip home-dir paths
|
||||
if (ref.startsWith("~")) return false;
|
||||
// Skip glob patterns
|
||||
if (/[*{}]/.test(ref)) return false;
|
||||
// Skip template placeholders like {{foo}} or {foo}
|
||||
if (/\{[^}]+\}/.test(ref)) return false;
|
||||
// Skip bare extensions like ".md"
|
||||
if (/^\.\w+$/.test(ref)) return false;
|
||||
// Skip obvious example paths
|
||||
if (/^path\/to\//.test(ref)) return false;
|
||||
// Skip absolute paths
|
||||
if (ref.startsWith("/")) return false;
|
||||
// Only validate paths that look like structural skill references:
|
||||
// relative navigation (../ or ./) or skill subdirectories (references/, workflows/)
|
||||
if (
|
||||
!ref.startsWith("./") &&
|
||||
!ref.startsWith("../") &&
|
||||
!ref.startsWith("references/") &&
|
||||
!ref.startsWith("workflows/") &&
|
||||
!ref.startsWith("scripts/") &&
|
||||
!ref.startsWith("templates/")
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Strip trailing anchor: foo.md#section -> foo.md */
|
||||
function stripAnchor(ref) {
|
||||
const idx = ref.indexOf("#");
|
||||
return idx >= 0 ? ref.slice(0, idx) : ref;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract validatable .md references from markdown content.
|
||||
* Returns array of { ref, line }.
|
||||
*/
|
||||
function extractReferences(content) {
|
||||
const refs = [];
|
||||
const lines = content.split("\n");
|
||||
|
||||
let inCodeBlock = false;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const lineNum = i + 1;
|
||||
|
||||
// Track fenced code blocks (``` or ~~~)
|
||||
if (/^(\s*)(`{3,}|~{3,})/.test(line)) {
|
||||
inCodeBlock = !inCodeBlock;
|
||||
continue;
|
||||
}
|
||||
if (inCodeBlock) continue;
|
||||
|
||||
// Pattern 1: Markdown links [text](path.md) or [text](path.md#anchor)
|
||||
const mdLinkRe = /\[(?:[^\]]*)\]\(([^)]+)\)/g;
|
||||
let match;
|
||||
while ((match = mdLinkRe.exec(line)) !== null) {
|
||||
const raw = stripAnchor(match[1].trim());
|
||||
if (shouldValidate(raw)) {
|
||||
refs.push({ ref: raw, line: lineNum });
|
||||
}
|
||||
}
|
||||
|
||||
// Pattern 2: Backtick-quoted paths to .md files
|
||||
const backtickRe = /`([^`]+\.md(?:#[^`]*)?)`/g;
|
||||
while ((match = backtickRe.exec(line)) !== null) {
|
||||
const raw = stripAnchor(match[1].trim());
|
||||
if (shouldValidate(raw)) {
|
||||
refs.push({ ref: raw, line: lineNum });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return refs;
|
||||
}
|
||||
|
||||
// --- Main ---
|
||||
|
||||
if (!existsSync(SKILLS_DIR)) {
|
||||
console.error(`Skills directory not found: ${SKILLS_DIR}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const mdFiles = collectMdFiles(SKILLS_DIR);
|
||||
let brokenCount = 0;
|
||||
let checkedCount = 0;
|
||||
|
||||
for (const file of mdFiles) {
|
||||
const content = readFileSync(file, "utf-8");
|
||||
const refs = extractReferences(content);
|
||||
const fileDir = dirname(file);
|
||||
const displayPath = file.replace(resolve(".") + "/", "");
|
||||
|
||||
for (const { ref, line } of refs) {
|
||||
checkedCount++;
|
||||
const resolved = resolve(fileDir, ref);
|
||||
if (!existsSync(resolved)) {
|
||||
console.error(
|
||||
`ERROR: ${displayPath}:${line} references "${ref}" but file does not exist`
|
||||
);
|
||||
brokenCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (brokenCount > 0) {
|
||||
console.error(
|
||||
`\n${brokenCount} broken reference(s) found across ${mdFiles.length} skill files.`
|
||||
);
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log(
|
||||
`All references valid. Checked ${checkedCount} reference(s) across ${mdFiles.length} skill file(s).`
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
|
@ -438,4 +438,4 @@ startTransition(() => setExpensiveState(newValue));
|
|||
- [web.dev LCP](https://web.dev/articles/lcp)
|
||||
- [web.dev INP](https://web.dev/articles/inp)
|
||||
- [web.dev CLS](https://web.dev/articles/cls)
|
||||
- [Performance skill](../performance/SKILL.md)
|
||||
- [Code Optimizer skill](../code-optimizer/SKILL.md)
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ The file must `export default function(pi: ExtensionAPI) { ... }`.
|
|||
|
||||
## Step 4: Check for Common Mistakes
|
||||
|
||||
Read `references/key-rules-gotchas.md` and verify each rule against the extension code.
|
||||
Read `../references/key-rules-gotchas.md` and verify each rule against the extension code.
|
||||
|
||||
## Step 5: Add Debugging
|
||||
|
||||
|
|
|
|||
|
|
@ -88,5 +88,3 @@ EVIDENCE: [output from ci_monitor.cjs]
|
|||
## References
|
||||
|
||||
- `references/gh/SKILL.md` — gh CLI reference
|
||||
- `scripts/ci_monitor.cjs` — CI monitoring tool
|
||||
- `scripts/ci_monitor.md` — Tool usage documentation
|
||||
|
|
|
|||
|
|
@ -1,208 +0,0 @@
|
|||
---
|
||||
name: swiftui
|
||||
description: SwiftUI apps from scratch through App Store. Full lifecycle - create, debug, test, optimize, ship.
|
||||
---
|
||||
|
||||
<essential_principles>
|
||||
## How We Work
|
||||
|
||||
**The user is the product owner. Claude is the developer.**
|
||||
|
||||
The user does not write code. The user does not read code. The user describes what they want and judges whether the result is acceptable. Claude implements, verifies, and reports outcomes.
|
||||
|
||||
### 1. Prove, Don't Promise
|
||||
|
||||
Never say "this should work." Prove it:
|
||||
```bash
|
||||
xcodebuild build 2>&1 | xcsift # Build passes
|
||||
xcodebuild test # Tests pass
|
||||
open .../App.app # App launches
|
||||
```
|
||||
If you didn't run it, you don't know it works.
|
||||
|
||||
### 2. Tests for Correctness, Eyes for Quality
|
||||
|
||||
| Question | How to Answer |
|
||||
|----------|---------------|
|
||||
| Does the logic work? | Write test, see it pass |
|
||||
| Does it look right? | Launch app, user looks at it |
|
||||
| Does it feel right? | User uses it |
|
||||
| Does it crash? | Test + launch |
|
||||
| Is it fast enough? | Profiler |
|
||||
|
||||
Tests verify *correctness*. The user verifies *desirability*.
|
||||
|
||||
### 3. Report Outcomes, Not Code
|
||||
|
||||
**Bad:** "I refactored the view model to use @Observable with environment injection"
|
||||
**Good:** "Fixed the state bug. App now updates correctly when you add items. Ready for you to verify."
|
||||
|
||||
The user doesn't care what you changed. The user cares what's different.
|
||||
|
||||
### 4. Small Steps, Always Verified
|
||||
|
||||
```
|
||||
Change → Verify → Report → Next change
|
||||
```
|
||||
|
||||
Never batch up work. Never say "I made several changes." Each change is verified before the next. If something breaks, you know exactly what caused it.
|
||||
|
||||
### 5. Ask Before, Not After
|
||||
|
||||
Unclear requirement? Ask now.
|
||||
Multiple valid approaches? Ask which.
|
||||
Scope creep? Ask if wanted.
|
||||
Big refactor needed? Ask permission.
|
||||
|
||||
Wrong: Build for 30 minutes, then "is this what you wanted?"
|
||||
Right: "Before I start, does X mean Y or Z?"
|
||||
|
||||
### 6. Always Leave It Working
|
||||
|
||||
Every stopping point = working state. Tests pass, app launches, changes committed. The user can walk away anytime and come back to something that works.
|
||||
</essential_principles>
|
||||
|
||||
<swiftui_principles>
|
||||
## SwiftUI Framework Principles
|
||||
|
||||
### Declarative Mindset
|
||||
Describe what the UI should look like for a given state, not how to mutate it. Let SwiftUI manage the rendering. Never force updates - change the state and let the framework react.
|
||||
|
||||
### Single Source of Truth
|
||||
Every piece of data has one authoritative location. Use the right property wrapper: @State for view-local, @Observable for shared objects, @Environment for app-wide. Derived data should be computed, not stored.
|
||||
|
||||
### Composition Over Inheritance
|
||||
Build complex UIs by composing small, focused views. Extract reusable components when patterns emerge. Prefer many small views over few large ones.
|
||||
|
||||
### Platform-Adaptive Design
|
||||
Write once but respect platform idioms. Use native navigation patterns, respect safe areas, adapt to screen sizes. Test on all target platforms.
|
||||
</swiftui_principles>
|
||||
|
||||
<intake>
|
||||
**What would you like to do?**
|
||||
|
||||
1. Build a new SwiftUI app
|
||||
2. Debug an existing SwiftUI app
|
||||
3. Add a feature to an existing app
|
||||
4. Write/run tests
|
||||
5. Optimize performance
|
||||
6. Ship/release to App Store
|
||||
7. Something else
|
||||
|
||||
**Then read the matching workflow from `workflows/` and follow it.**
|
||||
</intake>
|
||||
|
||||
<routing>
|
||||
| Response | Workflow |
|
||||
|----------|----------|
|
||||
| 1, "new", "create", "build", "start" | `workflows/build-new-app.md` |
|
||||
| 2, "broken", "fix", "debug", "crash", "bug" | `workflows/debug-swiftui.md` |
|
||||
| 3, "add", "feature", "implement", "change" | `workflows/add-feature.md` |
|
||||
| 4, "test", "tests", "TDD", "coverage" | `workflows/write-tests.md` |
|
||||
| 5, "slow", "optimize", "performance", "fast" | `workflows/optimize-performance.md` |
|
||||
| 6, "ship", "release", "deploy", "publish", "app store" | `workflows/ship-app.md` |
|
||||
| 7, other | Clarify, then select workflow or references |
|
||||
</routing>
|
||||
|
||||
<verification_loop>
|
||||
## After Every Change
|
||||
|
||||
```bash
|
||||
# 1. Does it build?
|
||||
xcodebuild -scheme AppName build 2>&1 | xcsift
|
||||
|
||||
# 2. Do tests pass? (use Core scheme for SwiftUI apps to avoid @main hang)
|
||||
xcodebuild -scheme AppNameCore test
|
||||
|
||||
# 3. Does it launch?
|
||||
# macOS:
|
||||
open ./build/Build/Products/Debug/AppName.app
|
||||
|
||||
# iOS Simulator:
|
||||
xcrun simctl boot "iPhone 15 Pro" 2>/dev/null || true
|
||||
xcrun simctl install booted ./build/Build/Products/Debug-iphonesimulator/AppName.app
|
||||
xcrun simctl launch booted com.yourcompany.appname
|
||||
```
|
||||
|
||||
Note: If tests hang, the test target likely depends on the app target which has `@main`. Extract testable code to a framework target. See `../macos-apps/references/testing-tdd.md` for the pattern.
|
||||
|
||||
Report to the user:
|
||||
- "Build: ✓"
|
||||
- "Tests: 12 pass, 0 fail"
|
||||
- "App launches, ready for you to check [specific thing]"
|
||||
</verification_loop>
|
||||
|
||||
<cli_infrastructure>
|
||||
## CLI Workflow References
|
||||
|
||||
For building, debugging, testing, and shipping from CLI without opening Xcode, read these from `../macos-apps/references/`:
|
||||
|
||||
| Reference | Use For |
|
||||
|-----------|---------|
|
||||
| `cli-workflow.md` | Build, run, test commands; xcodebuild usage; code signing |
|
||||
| `cli-observability.md` | Log streaming, crash analysis, memory debugging, LLDB |
|
||||
| `project-scaffolding.md` | XcodeGen project.yml templates, file structure, entitlements |
|
||||
| `testing-tdd.md` | Test patterns that work from CLI, avoiding @main hangs |
|
||||
|
||||
These docs are platform-agnostic. For iOS, change destinations:
|
||||
```bash
|
||||
# iOS Simulator
|
||||
xcodebuild -scheme AppName -destination 'platform=iOS Simulator,name=iPhone 15 Pro' build
|
||||
|
||||
# macOS
|
||||
xcodebuild -scheme AppName build
|
||||
```
|
||||
</cli_infrastructure>
|
||||
|
||||
<reference_index>
|
||||
## Domain Knowledge
|
||||
|
||||
All in `references/`:
|
||||
|
||||
**Core:**
|
||||
- architecture.md - MVVM patterns, project structure, dependency injection
|
||||
- state-management.md - Property wrappers, @Observable, data flow
|
||||
- layout-system.md - Stacks, grids, GeometryReader, custom layouts
|
||||
|
||||
**Navigation & Animation:**
|
||||
- navigation.md - NavigationStack, sheets, tabs, deep linking
|
||||
- animations.md - Built-in animations, transitions, matchedGeometryEffect
|
||||
|
||||
**Data & Platform:**
|
||||
- swiftdata.md - Persistence, @Model, @Query, CloudKit sync
|
||||
- platform-integration.md - iOS/macOS/watchOS/visionOS specifics
|
||||
- uikit-appkit-interop.md - UIViewRepresentable, hosting controllers
|
||||
|
||||
**Support:**
|
||||
- networking-async.md - async/await, .task modifier, API clients
|
||||
- testing-debugging.md - Previews, unit tests, UI tests, debugging
|
||||
- performance.md - Profiling, lazy loading, view identity
|
||||
</reference_index>
|
||||
|
||||
<workflows_index>
|
||||
## Workflows
|
||||
|
||||
All in `workflows/`:
|
||||
|
||||
| Workflow | Purpose |
|
||||
|----------|---------|
|
||||
| build-new-app.md | Create new SwiftUI app from scratch |
|
||||
| debug-swiftui.md | Find and fix SwiftUI bugs |
|
||||
| add-feature.md | Add functionality to existing app |
|
||||
| write-tests.md | Write UI and unit tests |
|
||||
| optimize-performance.md | Profile and improve performance |
|
||||
| ship-app.md | App Store submission, TestFlight, distribution |
|
||||
</workflows_index>
|
||||
|
||||
<canonical_terminology>
|
||||
## Terminology
|
||||
|
||||
Use these terms consistently:
|
||||
- **view** (not: widget, component, element)
|
||||
- **@Observable** (not: ObservableObject, @Published for new iOS 17+ code)
|
||||
- **NavigationStack** (not: NavigationView - deprecated)
|
||||
- **SwiftData** (not: Core Data for new projects)
|
||||
- **@Environment** (not: @EnvironmentObject for new code)
|
||||
- **modifier** (not: method/function when describing view modifiers)
|
||||
- **body** (not: render/build when describing view body)
|
||||
</canonical_terminology>
|
||||
|
|
@ -1,921 +0,0 @@
|
|||
<overview>
|
||||
SwiftUI animations are declarative and state-driven. When state changes, SwiftUI automatically animates views from old to new values. Your role is to control timing curves, duration, and which state changes trigger animations.
|
||||
|
||||
Key insight: Animations are automatic when state changes - you control timing/curve, not the mechanics.
|
||||
|
||||
This file covers:
|
||||
- Implicit vs explicit animations
|
||||
- Spring animations (iOS 17+ duration/bounce API)
|
||||
- Transitions for appearing/disappearing views
|
||||
- matchedGeometryEffect for hero animations
|
||||
- PhaseAnimator and KeyframeAnimator (iOS 17+)
|
||||
- Gesture-driven animations
|
||||
|
||||
See also:
|
||||
- navigation.md for NavigationStack transitions
|
||||
- performance.md for animation optimization strategies
|
||||
</overview>
|
||||
|
||||
<implicit_animations>
|
||||
## Implicit Animations (.animation modifier)
|
||||
|
||||
Implicit animations apply whenever an animatable property changes on a view. Always specify which value triggers the animation using the `value:` parameter to prevent unexpected animations.
|
||||
|
||||
**Basic usage:**
|
||||
```swift
|
||||
struct ContentView: View {
|
||||
@State private var scale: CGFloat = 1.0
|
||||
|
||||
var body: some View {
|
||||
Circle()
|
||||
.fill(.blue)
|
||||
.scaleEffect(scale)
|
||||
.animation(.spring(), value: scale)
|
||||
.onTapGesture {
|
||||
scale = scale == 1.0 ? 1.5 : 1.0
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Animation types:**
|
||||
- `.default` - System default spring animation
|
||||
- `.linear(duration:)` - Constant speed from start to finish
|
||||
- `.easeIn(duration:)` - Starts slow, accelerates
|
||||
- `.easeOut(duration:)` - Starts fast, decelerates
|
||||
- `.easeInOut(duration:)` - Slow start and end, fast middle
|
||||
- `.spring()` - iOS 17+ spring with default parameters
|
||||
- `.bouncy` - Preset spring with high bounce
|
||||
- `.snappy` - Preset spring with quick, slight bounce
|
||||
- `.smooth` - Preset spring with no bounce
|
||||
|
||||
**Value-specific animation:**
|
||||
```swift
|
||||
struct MultiPropertyView: View {
|
||||
@State private var rotation: Double = 0
|
||||
@State private var scale: CGFloat = 1.0
|
||||
|
||||
var body: some View {
|
||||
Rectangle()
|
||||
.fill(.red)
|
||||
.scaleEffect(scale)
|
||||
.rotationEffect(.degrees(rotation))
|
||||
.animation(.spring(), value: rotation) // Only animate rotation
|
||||
.animation(.easeInOut, value: scale) // Different animation for scale
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Why always use value: parameter:**
|
||||
- Prevents unexpected animations on unrelated state changes
|
||||
- Device rotation won't trigger animations
|
||||
- More predictable behavior
|
||||
- Better performance (only tracks specific value)
|
||||
</implicit_animations>
|
||||
|
||||
<explicit_animations>
|
||||
## Explicit Animations (withAnimation)
|
||||
|
||||
Explicit animations only affect properties that depend on values changed inside the `withAnimation` closure. Preferred for user-triggered actions.
|
||||
|
||||
**Basic usage:**
|
||||
```swift
|
||||
struct ContentView: View {
|
||||
@State private var isExpanded = false
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
if isExpanded {
|
||||
Text("Details")
|
||||
.transition(.opacity)
|
||||
}
|
||||
|
||||
Button("Toggle") {
|
||||
withAnimation(.spring()) {
|
||||
isExpanded.toggle()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Completion handlers (iOS 17+):**
|
||||
```swift
|
||||
Button("Animate") {
|
||||
withAnimation(.easeInOut(duration: 1.0)) {
|
||||
offset.y = 200
|
||||
} completion: {
|
||||
// Animation finished - safe to perform next action
|
||||
showNextStep = true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Transaction-based:**
|
||||
```swift
|
||||
var transaction = Transaction(animation: .spring())
|
||||
transaction.disablesAnimations = true // Temporarily disable animations
|
||||
|
||||
withTransaction(transaction) {
|
||||
someState.toggle()
|
||||
}
|
||||
```
|
||||
|
||||
**Removing animations temporarily:**
|
||||
```swift
|
||||
withAnimation(nil) {
|
||||
// Changes happen immediately without animation
|
||||
resetState()
|
||||
}
|
||||
```
|
||||
</explicit_animations>
|
||||
|
||||
<spring_animations>
|
||||
## Spring Animations
|
||||
|
||||
Springs are the default animation in SwiftUI. They feel natural because they mimic real-world physics.
|
||||
|
||||
**Modern spring parameters (iOS 17+):**
|
||||
```swift
|
||||
// Duration and bounce control
|
||||
.spring(duration: 0.5, bounce: 0.3)
|
||||
|
||||
// No bounce with blend duration for smooth transitions
|
||||
.spring(duration: 0.5, bounce: 0, blendDuration: 0.2)
|
||||
|
||||
// With initial velocity for gesture-driven animations
|
||||
.spring(duration: 0.6, bounce: 0.4)
|
||||
```
|
||||
|
||||
**Bounce parameter:**
|
||||
- `-1.0` to `1.0` range
|
||||
- `0` = no bounce (critically damped)
|
||||
- `0.3` to `0.5` = natural bounce
|
||||
- `0.7` to `1.0` = exaggerated bounce
|
||||
- Negative values create "anticipation" (overshoots in opposite direction first)
|
||||
|
||||
**Presets (iOS 17+):**
|
||||
```swift
|
||||
.bouncy // High bounce - playful, attention-grabbing
|
||||
.snappy // Quick with slight bounce - feels responsive
|
||||
.smooth // No bounce - elegant, sophisticated
|
||||
```
|
||||
|
||||
**Tuning workflow:**
|
||||
1. Start with duration that feels right
|
||||
2. Adjust bounce to set character/feeling
|
||||
3. Use presets first, then customize if needed
|
||||
|
||||
**Legacy spring (still works):**
|
||||
```swift
|
||||
// For backward compatibility or precise control
|
||||
.spring(response: 0.5, dampingFraction: 0.7, blendDuration: 0)
|
||||
```
|
||||
|
||||
**When to use springs:**
|
||||
- User interactions (button presses, drags)
|
||||
- Most UI state changes
|
||||
- Default choice unless you need precise timing
|
||||
</spring_animations>
|
||||
|
||||
<transitions>
|
||||
## Transitions
|
||||
|
||||
Transitions control how views appear and disappear. Applied with `.transition()` modifier, animated by wrapping insertion/removal in `withAnimation`.
|
||||
|
||||
**Built-in transitions:**
|
||||
```swift
|
||||
struct TransitionsDemo: View {
|
||||
@State private var showDetail = false
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
if showDetail {
|
||||
Text("Detail")
|
||||
.transition(.opacity) // Fade in/out
|
||||
// .transition(.slide) // Slide from leading edge
|
||||
// .transition(.scale) // Grow/shrink from center
|
||||
// .transition(.move(edge: .bottom)) // Slide from bottom
|
||||
// .transition(.push(from: .leading)) // Push from leading (iOS 16+)
|
||||
}
|
||||
|
||||
Button("Toggle") {
|
||||
withAnimation {
|
||||
showDetail.toggle()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Combining transitions:**
|
||||
```swift
|
||||
// Both opacity and scale together
|
||||
.transition(.opacity.combined(with: .scale))
|
||||
|
||||
// Different insertion and removal
|
||||
.transition(.asymmetric(
|
||||
insertion: .move(edge: .leading).combined(with: .opacity),
|
||||
removal: .move(edge: .trailing).combined(with: .opacity)
|
||||
))
|
||||
```
|
||||
|
||||
**Custom transitions:**
|
||||
```swift
|
||||
struct RotateModifier: ViewModifier {
|
||||
let rotation: Double
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.rotationEffect(.degrees(rotation))
|
||||
.opacity(rotation == 0 ? 1 : 0)
|
||||
}
|
||||
}
|
||||
|
||||
extension AnyTransition {
|
||||
static var pivot: AnyTransition {
|
||||
.modifier(
|
||||
active: RotateModifier(rotation: -90),
|
||||
identity: RotateModifier(rotation: 0)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
Text("Pivoting in")
|
||||
.transition(.pivot)
|
||||
```
|
||||
|
||||
**Identity vs insertion/removal:**
|
||||
- `identity` = final state when view is visible
|
||||
- `active` = state during transition (appearing/disappearing)
|
||||
</transitions>
|
||||
|
||||
<matched_geometry>
|
||||
## matchedGeometryEffect
|
||||
|
||||
Synchronizes geometry between two views with the same ID, creating hero animations. Views don't need to be in the same container.
|
||||
|
||||
**Basic hero animation:**
|
||||
```swift
|
||||
struct HeroDemo: View {
|
||||
@State private var isExpanded = false
|
||||
@Namespace private var animation
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
if !isExpanded {
|
||||
// Thumbnail state
|
||||
Circle()
|
||||
.fill(.blue)
|
||||
.frame(width: 60, height: 60)
|
||||
.matchedGeometryEffect(id: "circle", in: animation)
|
||||
.onTapGesture {
|
||||
withAnimation(.spring()) {
|
||||
isExpanded = true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Expanded state
|
||||
VStack {
|
||||
Circle()
|
||||
.fill(.blue)
|
||||
.frame(width: 200, height: 200)
|
||||
.matchedGeometryEffect(id: "circle", in: animation)
|
||||
|
||||
Button("Close") {
|
||||
withAnimation(.spring()) {
|
||||
isExpanded = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Creating namespace:**
|
||||
```swift
|
||||
@Namespace private var animation // Property wrapper creates unique namespace
|
||||
```
|
||||
|
||||
**isSource parameter:**
|
||||
Controls which view provides geometry during transition.
|
||||
|
||||
```swift
|
||||
// Example: Grid to detail view
|
||||
struct ContentView: View {
|
||||
@State private var selectedItem: Item?
|
||||
@Namespace private var namespace
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// Grid view
|
||||
LazyVGrid(columns: columns) {
|
||||
ForEach(items) { item in
|
||||
ItemCard(item: item)
|
||||
.matchedGeometryEffect(
|
||||
id: item.id,
|
||||
in: namespace,
|
||||
isSource: selectedItem == nil // Source when detail not shown
|
||||
)
|
||||
.onTapGesture {
|
||||
selectedItem = item
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Detail view
|
||||
if let item = selectedItem {
|
||||
DetailView(item: item)
|
||||
.matchedGeometryEffect(
|
||||
id: item.id,
|
||||
in: namespace,
|
||||
isSource: selectedItem != nil // Source when detail shown
|
||||
)
|
||||
}
|
||||
}
|
||||
.animation(.spring(), value: selectedItem)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Properties parameter:**
|
||||
Control what gets matched.
|
||||
|
||||
```swift
|
||||
.matchedGeometryEffect(
|
||||
id: "shape",
|
||||
in: namespace,
|
||||
properties: .frame // Only match frame, not position
|
||||
)
|
||||
|
||||
// Options: .frame, .position, .size
|
||||
```
|
||||
|
||||
**Common pitfalls:**
|
||||
- **Both views must exist simultaneously** during animation - use conditional rendering carefully
|
||||
- **Same ID required** - use stable identifiers (UUIDs, database IDs)
|
||||
- **Need explicit animation** - wrap state changes in `withAnimation`
|
||||
- **ZStack coordination** - often need ZStack to ensure both views render during transition
|
||||
</matched_geometry>
|
||||
|
||||
<phased_animations>
|
||||
## Phased Animations (iOS 17+)
|
||||
|
||||
PhaseAnimator automatically cycles through animation phases. Ideal for loading indicators, attention-grabbing effects, or multi-step sequences.
|
||||
|
||||
**PhaseAnimator with continuous cycling:**
|
||||
```swift
|
||||
struct PulsingCircle: View {
|
||||
var body: some View {
|
||||
PhaseAnimator([false, true]) { isLarge in
|
||||
Circle()
|
||||
.fill(.red)
|
||||
.scaleEffect(isLarge ? 1.5 : 1.0)
|
||||
.opacity(isLarge ? 0.5 : 1.0)
|
||||
} animation: { phase in
|
||||
.easeInOut(duration: 1.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**PhaseAnimator with enum phases:**
|
||||
```swift
|
||||
enum LoadingPhase: CaseIterable {
|
||||
case initial, loading, success
|
||||
|
||||
var scale: CGFloat {
|
||||
switch self {
|
||||
case .initial: 1.0
|
||||
case .loading: 1.2
|
||||
case .success: 1.5
|
||||
}
|
||||
}
|
||||
|
||||
var color: Color {
|
||||
switch self {
|
||||
case .initial: .gray
|
||||
case .loading: .blue
|
||||
case .success: .green
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct LoadingButton: View {
|
||||
var body: some View {
|
||||
PhaseAnimator(LoadingPhase.allCases) { phase in
|
||||
Circle()
|
||||
.fill(phase.color)
|
||||
.scaleEffect(phase.scale)
|
||||
} animation: { phase in
|
||||
switch phase {
|
||||
case .initial: .easeIn(duration: 0.3)
|
||||
case .loading: .easeInOut(duration: 0.5)
|
||||
case .success: .spring(duration: 0.6, bounce: 0.4)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Trigger-based PhaseAnimator:**
|
||||
```swift
|
||||
struct TriggerDemo: View {
|
||||
@State private var triggerValue = 0
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
PhaseAnimator([0, 1, 2], trigger: triggerValue) { phase in
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(.blue)
|
||||
.frame(width: 100 + CGFloat(phase * 50), height: 100)
|
||||
.offset(x: CGFloat(phase * 20))
|
||||
}
|
||||
|
||||
Button("Animate") {
|
||||
triggerValue += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Use cases:**
|
||||
- Loading spinners and progress indicators
|
||||
- Attention-grabbing call-to-action buttons
|
||||
- Celebratory success animations
|
||||
- Idle state animations
|
||||
- Tutorial highlights
|
||||
</phased_animations>
|
||||
|
||||
<keyframe_animations>
|
||||
## Keyframe Animations (iOS 17+)
|
||||
|
||||
KeyframeAnimator provides frame-by-frame control over complex animations. More powerful than PhaseAnimator when you need precise timing and multiple simultaneous property changes.
|
||||
|
||||
**Basic KeyframeAnimator:**
|
||||
```swift
|
||||
struct AnimationValues {
|
||||
var scale = 1.0
|
||||
var rotation = 0.0
|
||||
var opacity = 1.0
|
||||
}
|
||||
|
||||
struct KeyframeDemo: View {
|
||||
@State private var trigger = false
|
||||
|
||||
var body: some View {
|
||||
KeyframeAnimator(
|
||||
initialValue: AnimationValues(),
|
||||
trigger: trigger
|
||||
) { values in
|
||||
Rectangle()
|
||||
.fill(.purple)
|
||||
.scaleEffect(values.scale)
|
||||
.rotationEffect(.degrees(values.rotation))
|
||||
.opacity(values.opacity)
|
||||
.frame(width: 100, height: 100)
|
||||
} keyframes: { _ in
|
||||
KeyframeTrack(\.scale) {
|
||||
SpringKeyframe(1.5, duration: 0.3)
|
||||
CubicKeyframe(0.8, duration: 0.2)
|
||||
CubicKeyframe(1.0, duration: 0.2)
|
||||
}
|
||||
|
||||
KeyframeTrack(\.rotation) {
|
||||
LinearKeyframe(180, duration: 0.4)
|
||||
CubicKeyframe(360, duration: 0.3)
|
||||
}
|
||||
|
||||
KeyframeTrack(\.opacity) {
|
||||
CubicKeyframe(0.5, duration: 0.3)
|
||||
CubicKeyframe(1.0, duration: 0.4)
|
||||
}
|
||||
}
|
||||
.onTapGesture {
|
||||
trigger.toggle()
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Keyframe types:**
|
||||
|
||||
```swift
|
||||
// Linear - constant speed interpolation
|
||||
LinearKeyframe(targetValue, duration: 0.5)
|
||||
|
||||
// Cubic - smooth Bezier curve
|
||||
CubicKeyframe(targetValue, duration: 0.5)
|
||||
|
||||
// Spring - physics-based bounce
|
||||
SpringKeyframe(targetValue, duration: 0.5, spring: .bouncy)
|
||||
|
||||
// Move - jump immediately to value
|
||||
MoveKeyframe(targetValue)
|
||||
```
|
||||
|
||||
**Complex multi-property animation:**
|
||||
```swift
|
||||
struct AnimationState {
|
||||
var position: CGPoint = .zero
|
||||
var color: Color = .blue
|
||||
var size: CGFloat = 50
|
||||
}
|
||||
|
||||
KeyframeAnimator(initialValue: AnimationState(), trigger: animate) { state in
|
||||
Circle()
|
||||
.fill(state.color)
|
||||
.frame(width: state.size, height: state.size)
|
||||
.position(state.position)
|
||||
} keyframes: { _ in
|
||||
KeyframeTrack(\.position) {
|
||||
CubicKeyframe(CGPoint(x: 200, y: 100), duration: 0.4)
|
||||
SpringKeyframe(CGPoint(x: 200, y: 300), duration: 0.6)
|
||||
CubicKeyframe(CGPoint(x: 0, y: 0), duration: 0.5)
|
||||
}
|
||||
|
||||
KeyframeTrack(\.color) {
|
||||
CubicKeyframe(.red, duration: 0.5)
|
||||
CubicKeyframe(.green, duration: 0.5)
|
||||
CubicKeyframe(.blue, duration: 0.5)
|
||||
}
|
||||
|
||||
KeyframeTrack(\.size) {
|
||||
SpringKeyframe(100, duration: 0.6, spring: .bouncy)
|
||||
CubicKeyframe(50, duration: 0.4)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**When to use KeyframeAnimator:**
|
||||
- Complex choreographed animations
|
||||
- Precise timing control needed
|
||||
- Multiple properties animating with different curves
|
||||
- Path-based animations
|
||||
- Recreating motion design prototypes
|
||||
</keyframe_animations>
|
||||
|
||||
<gesture_animations>
|
||||
## Gesture-Driven Animations
|
||||
|
||||
Interactive animations that respond to user input in real-time.
|
||||
|
||||
**DragGesture with spring animation:**
|
||||
```swift
|
||||
struct DraggableCard: View {
|
||||
@State private var offset: CGSize = .zero
|
||||
|
||||
var body: some View {
|
||||
RoundedRectangle(cornerRadius: 20)
|
||||
.fill(.blue)
|
||||
.frame(width: 200, height: 300)
|
||||
.offset(offset)
|
||||
.gesture(
|
||||
DragGesture()
|
||||
.onChanged { value in
|
||||
offset = value.translation
|
||||
}
|
||||
.onEnded { _ in
|
||||
withAnimation(.spring(duration: 0.5, bounce: 0.3)) {
|
||||
offset = .zero
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Interruptible animations:**
|
||||
```swift
|
||||
struct InterruptibleView: View {
|
||||
@State private var position: CGFloat = 0
|
||||
|
||||
var body: some View {
|
||||
Circle()
|
||||
.fill(.red)
|
||||
.frame(width: 60, height: 60)
|
||||
.offset(y: position)
|
||||
.animation(.spring(), value: position)
|
||||
.gesture(
|
||||
DragGesture()
|
||||
.onChanged { value in
|
||||
// Interrupts ongoing animation immediately
|
||||
position = value.translation.height
|
||||
}
|
||||
.onEnded { value in
|
||||
// Determine snap point based on velocity
|
||||
let velocity = value.predictedEndLocation.y - value.location.y
|
||||
|
||||
if abs(velocity) > 500 {
|
||||
position = velocity > 0 ? 300 : -300
|
||||
} else {
|
||||
position = 0
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**GestureState for automatic reset:**
|
||||
```swift
|
||||
struct GestureStateExample: View {
|
||||
@GestureState private var dragOffset: CGSize = .zero
|
||||
@State private var permanentOffset: CGSize = .zero
|
||||
|
||||
var body: some View {
|
||||
Rectangle()
|
||||
.fill(.purple)
|
||||
.frame(width: 100, height: 100)
|
||||
.offset(x: permanentOffset.width + dragOffset.width,
|
||||
y: permanentOffset.height + dragOffset.height)
|
||||
.gesture(
|
||||
DragGesture()
|
||||
.updating($dragOffset) { value, state, _ in
|
||||
state = value.translation
|
||||
}
|
||||
.onEnded { value in
|
||||
withAnimation(.spring()) {
|
||||
permanentOffset.width += value.translation.width
|
||||
permanentOffset.height += value.translation.height
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Combining gestures with animations:**
|
||||
```swift
|
||||
struct SwipeToDelete: View {
|
||||
@State private var offset: CGFloat = 0
|
||||
@State private var isDeleted = false
|
||||
|
||||
var body: some View {
|
||||
if !isDeleted {
|
||||
HStack {
|
||||
Text("Swipe to delete")
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
.background(.white)
|
||||
.offset(x: offset)
|
||||
.gesture(
|
||||
DragGesture()
|
||||
.onChanged { value in
|
||||
if value.translation.width < 0 {
|
||||
offset = value.translation.width
|
||||
}
|
||||
}
|
||||
.onEnded { value in
|
||||
if offset < -100 {
|
||||
withAnimation(.easeOut(duration: 0.3)) {
|
||||
offset = -500
|
||||
} completion: {
|
||||
isDeleted = true
|
||||
}
|
||||
} else {
|
||||
withAnimation(.spring()) {
|
||||
offset = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Velocity-based animations:**
|
||||
```swift
|
||||
struct VelocityDrag: View {
|
||||
@State private var offset: CGSize = .zero
|
||||
|
||||
var body: some View {
|
||||
Circle()
|
||||
.fill(.green)
|
||||
.frame(width: 80, height: 80)
|
||||
.offset(offset)
|
||||
.gesture(
|
||||
DragGesture()
|
||||
.onChanged { value in
|
||||
offset = value.translation
|
||||
}
|
||||
.onEnded { value in
|
||||
let velocity = value.velocity
|
||||
|
||||
// Use velocity magnitude to determine spring response
|
||||
let speed = sqrt(velocity.width * velocity.width +
|
||||
velocity.height * velocity.height)
|
||||
|
||||
let animation: Animation = speed > 1000
|
||||
? .spring(duration: 0.4, bounce: 0.5)
|
||||
: .spring(duration: 0.6, bounce: 0.3)
|
||||
|
||||
withAnimation(animation) {
|
||||
offset = .zero
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
</gesture_animations>
|
||||
|
||||
<decision_tree>
|
||||
## Choosing the Right Animation
|
||||
|
||||
**Simple state change:**
|
||||
- Use `.animation(.default, value: state)` for single property changes
|
||||
- Implicit animation is simplest approach
|
||||
|
||||
**User-triggered change:**
|
||||
- Use `withAnimation { }` for button taps, user actions
|
||||
- Explicit animation provides better control
|
||||
- Use completion handlers (iOS 17+) for sequential actions
|
||||
|
||||
**View appearing/disappearing:**
|
||||
- Use `.transition()` for conditional views
|
||||
- Combine with `withAnimation` to trigger
|
||||
- Consider `.asymmetric()` for different in/out animations
|
||||
|
||||
**Shared element between screens:**
|
||||
- Use `matchedGeometryEffect` for hero animations
|
||||
- Requires both views to exist during transition
|
||||
- Best with `@Namespace` and explicit animations
|
||||
|
||||
**Multi-step sequence:**
|
||||
- Use `PhaseAnimator` (iOS 17+) for simple phase-based sequences
|
||||
- Great for loading states, idle animations
|
||||
- Trigger-based for user-initiated sequences
|
||||
|
||||
**Complex keyframed motion:**
|
||||
- Use `KeyframeAnimator` (iOS 17+) for precise timing
|
||||
- Multiple properties with independent curves
|
||||
- Recreating motion design specs
|
||||
|
||||
**User-controlled motion:**
|
||||
- Use `DragGesture` + animation for interactive elements
|
||||
- `@GestureState` for automatic state reset
|
||||
- Consider velocity for natural physics
|
||||
|
||||
**Performance tips:**
|
||||
- Animate opacity, scale, offset (cheap)
|
||||
- Avoid animating frame size, padding (expensive)
|
||||
- Use `.drawingGroup()` for complex hierarchies being animated
|
||||
- Avoid animating during scroll (competes with scroll performance)
|
||||
- Profile with Instruments if animations drop frames
|
||||
</decision_tree>
|
||||
|
||||
<anti_patterns>
|
||||
## What NOT to Do
|
||||
|
||||
<anti_pattern name="Animation without value parameter">
|
||||
**Problem:**
|
||||
```swift
|
||||
.animation(.spring()) // No value parameter
|
||||
```
|
||||
|
||||
**Why it's bad:**
|
||||
Animates every property change, including device rotation, parent view updates, and unrelated state changes. Creates unexpected animations and performance issues.
|
||||
|
||||
**Instead:**
|
||||
```swift
|
||||
.animation(.spring(), value: specificState)
|
||||
```
|
||||
</anti_pattern>
|
||||
|
||||
<anti_pattern name="Animating layout-heavy properties">
|
||||
**Problem:**
|
||||
```swift
|
||||
withAnimation {
|
||||
frameWidth = 300 // Triggers layout recalculation
|
||||
padding = 20 // Triggers layout recalculation
|
||||
}
|
||||
```
|
||||
|
||||
**Why it's bad:**
|
||||
Frame size and padding changes force SwiftUI to recalculate layout, which is expensive. Can cause stuttering on complex views.
|
||||
|
||||
**Instead:**
|
||||
```swift
|
||||
withAnimation {
|
||||
scale = 1.5 // Cheap transform
|
||||
opacity = 0.5 // Cheap property
|
||||
offset = CGSize(width: 20, height: 0) // Cheap transform
|
||||
}
|
||||
```
|
||||
</anti_pattern>
|
||||
|
||||
<anti_pattern name="matchedGeometryEffect without namespace">
|
||||
**Problem:**
|
||||
```swift
|
||||
Circle()
|
||||
.matchedGeometryEffect(id: "circle", in: ???) // Forgot @Namespace
|
||||
```
|
||||
|
||||
**Why it's bad:**
|
||||
Won't compile. Namespace is required to coordinate geometry matching.
|
||||
|
||||
**Instead:**
|
||||
```swift
|
||||
@Namespace private var animation
|
||||
|
||||
Circle()
|
||||
.matchedGeometryEffect(id: "circle", in: animation)
|
||||
```
|
||||
</anti_pattern>
|
||||
|
||||
<anti_pattern name="Nested withAnimation blocks">
|
||||
**Problem:**
|
||||
```swift
|
||||
withAnimation(.easeIn) {
|
||||
withAnimation(.spring()) {
|
||||
state = newValue
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Why it's bad:**
|
||||
Inner animation is ignored. Only outer animation applies. Creates confusion about which animation runs.
|
||||
|
||||
**Instead:**
|
||||
```swift
|
||||
withAnimation(.spring()) {
|
||||
state = newValue
|
||||
}
|
||||
```
|
||||
</anti_pattern>
|
||||
|
||||
<anti_pattern name="Transition without withAnimation">
|
||||
**Problem:**
|
||||
```swift
|
||||
if showDetail {
|
||||
DetailView()
|
||||
.transition(.slide) // Transition defined but not triggered
|
||||
}
|
||||
```
|
||||
|
||||
**Why it's bad:**
|
||||
View appears/disappears instantly. Transition is never applied without animation context.
|
||||
|
||||
**Instead:**
|
||||
```swift
|
||||
Button("Toggle") {
|
||||
withAnimation {
|
||||
showDetail.toggle()
|
||||
}
|
||||
}
|
||||
```
|
||||
</anti_pattern>
|
||||
|
||||
<anti_pattern name="Animating computed properties">
|
||||
**Problem:**
|
||||
```swift
|
||||
var computedValue: Double {
|
||||
return stateA * stateB
|
||||
}
|
||||
|
||||
.animation(.spring(), value: computedValue)
|
||||
```
|
||||
|
||||
**Why it's bad:**
|
||||
Computed properties can change for many reasons. Animation triggers on any dependency change, not just intentional updates.
|
||||
|
||||
**Instead:**
|
||||
```swift
|
||||
.animation(.spring(), value: stateA)
|
||||
.animation(.spring(), value: stateB)
|
||||
```
|
||||
</anti_pattern>
|
||||
|
||||
<anti_pattern name="matchedGeometryEffect with overlapping views">
|
||||
**Problem:**
|
||||
```swift
|
||||
// Both views exist at same time with same ID
|
||||
GridItem()
|
||||
.matchedGeometryEffect(id: item.id, in: namespace)
|
||||
|
||||
DetailItem()
|
||||
.matchedGeometryEffect(id: item.id, in: namespace)
|
||||
```
|
||||
|
||||
**Why it's bad:**
|
||||
Without proper `isSource` configuration, SwiftUI doesn't know which view's geometry to use. Creates unpredictable animations.
|
||||
|
||||
**Instead:**
|
||||
```swift
|
||||
GridItem()
|
||||
.matchedGeometryEffect(id: item.id, in: namespace, isSource: selectedItem == nil)
|
||||
|
||||
DetailItem()
|
||||
.matchedGeometryEffect(id: item.id, in: namespace, isSource: selectedItem != nil)
|
||||
```
|
||||
</anti_pattern>
|
||||
</anti_patterns>
|
||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1,214 +0,0 @@
|
|||
<overview>
|
||||
SwiftUI networking in 2025 is built around Swift's structured concurrency (async/await) with the @Observable macro for state management. Combine is primarily used for specialized reactive scenarios.
|
||||
|
||||
**When to use async/await:**
|
||||
- Loading data when views appear (.task modifier)
|
||||
- Sequential API calls with dependencies
|
||||
- Error handling with do-catch
|
||||
- Any new code requiring async operations
|
||||
|
||||
**When Combine is still useful:**
|
||||
- Complex reactive pipelines (debouncing, throttling)
|
||||
- Form validation with multiple interdependent fields
|
||||
- Real-time data streams (websockets, timers)
|
||||
|
||||
**Core principle:** Use async/await by default. Add Combine only when reactive operators provide clear value.
|
||||
</overview>
|
||||
|
||||
<task_modifier>
|
||||
## The .task Modifier
|
||||
|
||||
**Basic usage:**
|
||||
```swift
|
||||
struct ArticleView: View {
|
||||
@State private var article: Article?
|
||||
let articleID: String
|
||||
|
||||
var body: some View {
|
||||
content
|
||||
.task {
|
||||
article = try? await fetchArticle(id: articleID)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**With dependency (.task(id:)):**
|
||||
```swift
|
||||
struct SearchView: View {
|
||||
@State private var query = ""
|
||||
@State private var results: [Result] = []
|
||||
|
||||
var body: some View {
|
||||
List(results) { result in Text(result.name) }
|
||||
.searchable(text: $query)
|
||||
.task(id: query) {
|
||||
guard !query.isEmpty else { return }
|
||||
try? await Task.sleep(for: .milliseconds(300))
|
||||
guard !Task.isCancelled else { return }
|
||||
results = (try? await search(query: query)) ?? []
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Key behaviors:**
|
||||
- Runs when view appears
|
||||
- Auto-cancels on view disappear
|
||||
- .task(id:) restarts when dependency changes
|
||||
</task_modifier>
|
||||
|
||||
<async_await_patterns>
|
||||
## Async/Await Patterns
|
||||
|
||||
**Loading with @Observable:**
|
||||
```swift
|
||||
@Observable
|
||||
@MainActor
|
||||
class ArticleViewModel {
|
||||
private(set) var state: LoadingState<Article> = .idle
|
||||
|
||||
func load(id: String) async {
|
||||
state = .loading
|
||||
do {
|
||||
let article = try await apiClient.fetchArticle(id: id)
|
||||
state = .loaded(article)
|
||||
} catch is CancellationError {
|
||||
// Don't update state
|
||||
} catch {
|
||||
state = .failed(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Parallel calls:**
|
||||
```swift
|
||||
func loadProfile(id: String) async throws -> Profile {
|
||||
let user = try await fetchUser(id: id)
|
||||
async let posts = fetchPosts(userID: user.id)
|
||||
async let followers = fetchFollowers(userID: user.id)
|
||||
return Profile(user: user, posts: try await posts, followers: try await followers)
|
||||
}
|
||||
```
|
||||
</async_await_patterns>
|
||||
|
||||
<api_client_design>
|
||||
## API Client Architecture
|
||||
|
||||
```swift
|
||||
protocol APIClient {
|
||||
func request<T: Decodable>(_ endpoint: Endpoint) async throws -> T
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class ProductionAPIClient: APIClient {
|
||||
private let baseURL: URL
|
||||
private let session: URLSession
|
||||
|
||||
func request<T: Decodable>(_ endpoint: Endpoint) async throws -> T {
|
||||
let request = try buildRequest(endpoint)
|
||||
let (data, response) = try await session.data(for: request)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse,
|
||||
(200...299).contains(httpResponse.statusCode) else {
|
||||
throw APIError.httpError((response as? HTTPURLResponse)?.statusCode ?? 0)
|
||||
}
|
||||
|
||||
return try JSONDecoder().decode(T.self, from: data)
|
||||
}
|
||||
}
|
||||
```
|
||||
</api_client_design>
|
||||
|
||||
<loading_states>
|
||||
## Loading States
|
||||
|
||||
```swift
|
||||
enum LoadingState<Value> {
|
||||
case idle
|
||||
case loading
|
||||
case loaded(Value)
|
||||
case failed(Error)
|
||||
|
||||
var isLoading: Bool {
|
||||
if case .loading = self { return true }
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
struct AsyncContentView<Value, Content: View>: View {
|
||||
let state: LoadingState<Value>
|
||||
let retry: () async -> Void
|
||||
@ViewBuilder let content: (Value) -> Content
|
||||
|
||||
var body: some View {
|
||||
switch state {
|
||||
case .idle: Color.clear
|
||||
case .loading: ProgressView()
|
||||
case .loaded(let value): content(value)
|
||||
case .failed(let error):
|
||||
ContentUnavailableView("Error", systemImage: "exclamationmark.triangle", description: Text(error.localizedDescription))
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</loading_states>
|
||||
|
||||
<error_handling>
|
||||
## Error Handling & Retry
|
||||
|
||||
**Basic retry:**
|
||||
```swift
|
||||
func fetchWithRetry<T>(maxRetries: Int = 3, operation: () async throws -> T) async throws -> T {
|
||||
var lastError: Error?
|
||||
for attempt in 0..<maxRetries {
|
||||
do {
|
||||
return try await operation()
|
||||
} catch {
|
||||
lastError = error
|
||||
if error is CancellationError { throw error }
|
||||
if attempt < maxRetries - 1 {
|
||||
try await Task.sleep(for: .seconds(pow(2, Double(attempt))))
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastError!
|
||||
}
|
||||
```
|
||||
</error_handling>
|
||||
|
||||
<decision_tree>
|
||||
## Choosing the Right Approach
|
||||
|
||||
**Tied to view lifecycle?** → .task or .task(id:)
|
||||
**User-triggered?** → Wrap in explicit Task {}
|
||||
**Need reactive operators?** → Combine
|
||||
**Loading data?** → Use LoadingState enum
|
||||
**Sequential calls?** → async/await naturally
|
||||
**Parallel calls?** → async let or TaskGroup
|
||||
</decision_tree>
|
||||
|
||||
<anti_patterns>
|
||||
## What NOT to Do
|
||||
|
||||
<anti_pattern name="Ignoring CancellationError">
|
||||
**Problem:** Showing error UI when task is cancelled
|
||||
**Instead:** Catch CancellationError separately, don't update state
|
||||
</anti_pattern>
|
||||
|
||||
<anti_pattern name="Task in .task">
|
||||
**Problem:** Task { await loadData() } inside .task
|
||||
**Instead:** .task already creates a Task
|
||||
</anti_pattern>
|
||||
|
||||
<anti_pattern name="Missing @MainActor">
|
||||
**Problem:** View model updates from background thread
|
||||
**Instead:** Mark @Observable view models with @MainActor
|
||||
</anti_pattern>
|
||||
|
||||
<anti_pattern name="ObservableObject for new code">
|
||||
**Problem:** Using ObservableObject/@Published
|
||||
**Instead:** Use @Observable macro (iOS 17+)
|
||||
</anti_pattern>
|
||||
</anti_patterns>
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,204 +0,0 @@
|
|||
<overview>
|
||||
SwiftUI enables true multiplatform development: write once, adapt per platform. A single codebase can target iOS, iPadOS, macOS, watchOS, tvOS, and visionOS while respecting each platform's unique conventions and capabilities.
|
||||
|
||||
**Key insight:** SwiftUI's declarative syntax works everywhere, but each platform has distinct interaction models. iOS uses touch and gestures, macOS has precise mouse input and keyboard shortcuts, watchOS centers on the Digital Crown, and visionOS introduces spatial computing with gaze and hand tracking.
|
||||
|
||||
**When to read this:**
|
||||
- Building multiplatform apps with shared logic but platform-specific UI
|
||||
- Implementing macOS menu bar utilities or Settings windows
|
||||
- Creating watchOS complications or Digital Crown interactions
|
||||
- Developing visionOS apps with immersive spaces and ornaments
|
||||
- Adapting layouts responsively across iPhone, iPad, and Mac
|
||||
</overview>
|
||||
|
||||
<platform_conditionals>
|
||||
## Platform Conditionals
|
||||
|
||||
**Compile-time platform checks:**
|
||||
```swift
|
||||
#if os(iOS)
|
||||
// iOS-only code
|
||||
#elseif os(macOS)
|
||||
// macOS-only code
|
||||
#elseif os(watchOS)
|
||||
// watchOS-only code
|
||||
#elseif os(visionOS)
|
||||
// visionOS-only code
|
||||
#endif
|
||||
```
|
||||
|
||||
**Runtime API availability:**
|
||||
```swift
|
||||
if #available(iOS 17, macOS 14, *) {
|
||||
// Use iOS 17+/macOS 14+ API
|
||||
}
|
||||
```
|
||||
|
||||
**Target environment:**
|
||||
```swift
|
||||
#if targetEnvironment(simulator)
|
||||
// Running in simulator
|
||||
#endif
|
||||
|
||||
#if canImport(UIKit)
|
||||
// UIKit available
|
||||
#endif
|
||||
```
|
||||
</platform_conditionals>
|
||||
|
||||
<ios_specifics>
|
||||
## iOS-Specific Features
|
||||
|
||||
**Navigation patterns:**
|
||||
- Tab bar at bottom
|
||||
- Full-screen covers
|
||||
- Pull-to-refresh with .refreshable
|
||||
|
||||
**System integration:**
|
||||
- Push notifications
|
||||
- Widgets and Live Activities
|
||||
- App Intents / Siri
|
||||
|
||||
**Device variations:**
|
||||
```swift
|
||||
@Environment(\.horizontalSizeClass) var horizontalSizeClass
|
||||
|
||||
if horizontalSizeClass == .regular {
|
||||
// iPad layout
|
||||
}
|
||||
```
|
||||
</ios_specifics>
|
||||
|
||||
<macos_specifics>
|
||||
## macOS-Specific Features
|
||||
|
||||
**Window management:**
|
||||
```swift
|
||||
WindowGroup("Main") { ContentView() }
|
||||
.defaultSize(width: 800, height: 600)
|
||||
|
||||
Window("Settings") { SettingsView() }
|
||||
|
||||
Settings { SettingsView() }
|
||||
```
|
||||
|
||||
**MenuBarExtra:**
|
||||
```swift
|
||||
MenuBarExtra("App Name", systemImage: "star") {
|
||||
MenuBarContentView()
|
||||
}
|
||||
.menuBarExtraStyle(.window)
|
||||
```
|
||||
|
||||
**Commands:**
|
||||
```swift
|
||||
.commands {
|
||||
CommandGroup(replacing: .newItem) {
|
||||
Button("New Document") { }
|
||||
}
|
||||
CommandMenu("Custom") {
|
||||
Button("Action") { }
|
||||
}
|
||||
}
|
||||
```
|
||||
</macos_specifics>
|
||||
|
||||
<watchos_specifics>
|
||||
## watchOS-Specific Features
|
||||
|
||||
**Digital Crown:**
|
||||
```swift
|
||||
@State private var crownValue: Double = 0.0
|
||||
|
||||
VStack { Text("\(crownValue)") }
|
||||
.focusable()
|
||||
.digitalCrownRotation($crownValue)
|
||||
```
|
||||
|
||||
**Always-on display:**
|
||||
```swift
|
||||
@Environment(\.isLuminanceReduced) var isLuminanceReduced
|
||||
```
|
||||
</watchos_specifics>
|
||||
|
||||
<visionos_specifics>
|
||||
## visionOS-Specific Features
|
||||
|
||||
**Immersive spaces:**
|
||||
```swift
|
||||
ImmersiveSpace(id: "immersive") {
|
||||
RealityView { content in
|
||||
// 3D content
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Window styles:**
|
||||
```swift
|
||||
.windowStyle(.volumetric)
|
||||
```
|
||||
|
||||
**Ornaments:**
|
||||
```swift
|
||||
.ornament(attachmentAnchor: .scene(.bottom)) {
|
||||
BottomControls()
|
||||
}
|
||||
```
|
||||
</visionos_specifics>
|
||||
|
||||
<responsive_design>
|
||||
## Responsive Design
|
||||
|
||||
**Size classes:**
|
||||
```swift
|
||||
@Environment(\.horizontalSizeClass) var horizontalSizeClass
|
||||
@Environment(\.verticalSizeClass) var verticalSizeClass
|
||||
```
|
||||
|
||||
**ViewThatFits (iOS 16+):**
|
||||
```swift
|
||||
ViewThatFits {
|
||||
WideLayout()
|
||||
CompactLayout()
|
||||
}
|
||||
```
|
||||
|
||||
**containerRelativeFrame (iOS 17+):**
|
||||
```swift
|
||||
.containerRelativeFrame(.horizontal) { length, axis in
|
||||
length * 0.8
|
||||
}
|
||||
```
|
||||
</responsive_design>
|
||||
|
||||
<decision_tree>
|
||||
## Platform Strategy
|
||||
|
||||
**Shared codebase structure:**
|
||||
- Models, ViewModels, Services: All platforms
|
||||
- Views: Platform-specific where needed
|
||||
|
||||
**When to use conditionals:**
|
||||
- Platform-exclusive APIs
|
||||
- Different navigation patterns
|
||||
- Different default sizes
|
||||
</decision_tree>
|
||||
|
||||
<anti_patterns>
|
||||
## What NOT to Do
|
||||
|
||||
<anti_pattern name="Scattered #if os() conditionals">
|
||||
**Problem:** Platform checks everywhere
|
||||
**Instead:** Extract to platform-specific files
|
||||
</anti_pattern>
|
||||
|
||||
<anti_pattern name="Ignoring platform idioms">
|
||||
**Problem:** iOS patterns on macOS
|
||||
**Instead:** Respect each platform's conventions
|
||||
</anti_pattern>
|
||||
|
||||
<anti_pattern name="Testing only in simulator">
|
||||
**Problem:** Missing real device behaviors
|
||||
**Instead:** Test on physical devices
|
||||
</anti_pattern>
|
||||
</anti_patterns>
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,297 +0,0 @@
|
|||
<overview>
|
||||
SwiftData is Apple's modern persistence framework introduced at WWDC 2023, built on Core Data but with a Swift-native API. It provides declarative data modeling, automatic persistence, and seamless SwiftUI integration with minimal boilerplate.
|
||||
|
||||
**Key insight:** SwiftData eliminates the complexity of Core Data while maintaining its power. Where Core Data requires NSManagedObject subclasses, fetch request controllers, and entity descriptions, SwiftData uses Swift macros (@Model, @Query) and modern Swift features like #Predicate for compile-time validation.
|
||||
|
||||
**Minimum deployment:** iOS 17, macOS 14, watchOS 10, tvOS 17, visionOS 1.0
|
||||
|
||||
**When to read this file:**
|
||||
- Persisting app data locally or syncing with iCloud
|
||||
- Defining data models and relationships
|
||||
- Querying and filtering stored data
|
||||
- Migrating from Core Data to SwiftData
|
||||
- Before reading: architecture.md (understand app structure), state-management.md (understand @Observable)
|
||||
- Read alongside: platform-integration.md (for CloudKit integration details)
|
||||
</overview>
|
||||
|
||||
<model_definition>
|
||||
## Defining Models
|
||||
|
||||
**@Model macro:**
|
||||
```swift
|
||||
import SwiftData
|
||||
|
||||
@Model
|
||||
class Item {
|
||||
var name: String
|
||||
var timestamp: Date
|
||||
var isCompleted: Bool
|
||||
|
||||
init(name: String) {
|
||||
self.name = name
|
||||
self.timestamp = Date()
|
||||
self.isCompleted = false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The @Model macro transforms a Swift class into a SwiftData model. SwiftData automatically persists all stored properties.
|
||||
|
||||
**Supported property types:**
|
||||
- Basic types: String, Int, Double, Bool, Date, UUID, URL, Data
|
||||
- Codable types (stored as JSON)
|
||||
- Collections: [String], [Int], etc.
|
||||
- Relationships to other @Model types
|
||||
- Optionals of any above type
|
||||
|
||||
**@Attribute options:**
|
||||
```swift
|
||||
@Model
|
||||
class User {
|
||||
@Attribute(.unique) var id: UUID
|
||||
@Attribute(.externalStorage) var profileImage: Data
|
||||
@Attribute(.spotlight) var displayName: String
|
||||
@Attribute(.allowsCloudEncryption) var sensitiveInfo: String
|
||||
|
||||
var email: String
|
||||
|
||||
init(id: UUID = UUID(), displayName: String, email: String) {
|
||||
self.id = id
|
||||
self.displayName = displayName
|
||||
self.email = email
|
||||
self.profileImage = Data()
|
||||
self.sensitiveInfo = ""
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**@Transient for non-persisted properties:**
|
||||
```swift
|
||||
@Model
|
||||
class Task {
|
||||
var title: String
|
||||
var createdAt: Date
|
||||
|
||||
@Transient var isEditing: Bool = false
|
||||
|
||||
var ageInDays: Int {
|
||||
Calendar.current.dateComponents([.day], from: createdAt, to: Date()).day ?? 0
|
||||
}
|
||||
|
||||
init(title: String) {
|
||||
self.title = title
|
||||
self.createdAt = Date()
|
||||
}
|
||||
}
|
||||
```
|
||||
</model_definition>
|
||||
|
||||
<relationships>
|
||||
## Relationships
|
||||
|
||||
**One-to-many:**
|
||||
```swift
|
||||
@Model
|
||||
class Folder {
|
||||
var name: String
|
||||
@Relationship(deleteRule: .cascade) var items: [Item] = []
|
||||
|
||||
init(name: String) {
|
||||
self.name = name
|
||||
}
|
||||
}
|
||||
|
||||
@Model
|
||||
class Item {
|
||||
var name: String
|
||||
var folder: Folder?
|
||||
|
||||
init(name: String, folder: Folder? = nil) {
|
||||
self.name = name
|
||||
self.folder = folder
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Delete rules:**
|
||||
- `.cascade` - deletes related objects
|
||||
- `.nullify` - sets relationship to nil (default)
|
||||
- `.deny` - prevents deletion if relationship exists
|
||||
- `.noAction` - does nothing (use with caution)
|
||||
|
||||
**Inverse relationships:**
|
||||
```swift
|
||||
@Model
|
||||
class Author {
|
||||
var name: String
|
||||
@Relationship(inverse: \Book.author) var books: [Book] = []
|
||||
|
||||
init(name: String) {
|
||||
self.name = name
|
||||
}
|
||||
}
|
||||
```
|
||||
</relationships>
|
||||
|
||||
<model_container>
|
||||
## ModelContainer and ModelContext
|
||||
|
||||
**Setting up container in App:**
|
||||
```swift
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
@main
|
||||
struct MyApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
}
|
||||
.modelContainer(for: [Item.self, Folder.self])
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Custom configuration:**
|
||||
```swift
|
||||
let config = ModelConfiguration(
|
||||
schema: Schema([Item.self, Folder.self]),
|
||||
url: URL.documentsDirectory.appending(path: "MyApp.store"),
|
||||
cloudKitDatabase: .automatic
|
||||
)
|
||||
|
||||
let container = try ModelContainer(
|
||||
for: Item.self,
|
||||
configurations: config
|
||||
)
|
||||
```
|
||||
|
||||
**Accessing context in views:**
|
||||
```swift
|
||||
@Environment(\.modelContext) private var context
|
||||
```
|
||||
</model_container>
|
||||
|
||||
<querying>
|
||||
## Querying Data
|
||||
|
||||
**@Query in views:**
|
||||
```swift
|
||||
@Query var items: [Item]
|
||||
|
||||
// With sorting
|
||||
@Query(sort: \Item.timestamp, order: .reverse) var items: [Item]
|
||||
|
||||
// With filtering
|
||||
@Query(filter: #Predicate<Item> { $0.isCompleted == false }) var items: [Item]
|
||||
```
|
||||
|
||||
**Dynamic queries:**
|
||||
```swift
|
||||
struct SearchableItemList: View {
|
||||
@Query var items: [Item]
|
||||
|
||||
init(searchText: String) {
|
||||
let predicate = #Predicate<Item> { item in
|
||||
searchText.isEmpty || item.name.localizedStandardContains(searchText)
|
||||
}
|
||||
_items = Query(filter: predicate)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**FetchDescriptor for context queries:**
|
||||
```swift
|
||||
let descriptor = FetchDescriptor<Item>(
|
||||
predicate: #Predicate { $0.isCompleted },
|
||||
sortBy: [SortDescriptor(\.timestamp)]
|
||||
)
|
||||
let items = try context.fetch(descriptor)
|
||||
```
|
||||
</querying>
|
||||
|
||||
<crud_operations>
|
||||
## CRUD Operations
|
||||
|
||||
**Create:**
|
||||
```swift
|
||||
let item = Item(name: "New Item")
|
||||
context.insert(item)
|
||||
```
|
||||
|
||||
**Update:**
|
||||
```swift
|
||||
item.name = "Updated Name"
|
||||
// Changes auto-save
|
||||
```
|
||||
|
||||
**Delete:**
|
||||
```swift
|
||||
context.delete(item)
|
||||
```
|
||||
|
||||
**Manual save:**
|
||||
```swift
|
||||
try context.save()
|
||||
```
|
||||
</crud_operations>
|
||||
|
||||
<cloudkit_sync>
|
||||
## CloudKit Sync
|
||||
|
||||
**Enable in container:**
|
||||
```swift
|
||||
let config = ModelConfiguration(cloudKitDatabase: .automatic)
|
||||
```
|
||||
|
||||
**CloudKit constraints:**
|
||||
- Cannot use @Attribute(.unique) with CloudKit
|
||||
- All properties need defaults or be optional
|
||||
- Relationships must be optional
|
||||
- Private database only
|
||||
</cloudkit_sync>
|
||||
|
||||
<migration>
|
||||
## Schema Migration
|
||||
|
||||
**Lightweight migration (automatic):**
|
||||
- Adding properties with defaults
|
||||
- Removing properties
|
||||
- Renaming with @Attribute(originalName:)
|
||||
|
||||
**Schema versioning:**
|
||||
```swift
|
||||
enum SchemaV1: VersionedSchema {
|
||||
static var versionIdentifier = Schema.Version(1, 0, 0)
|
||||
static var models: [any PersistentModel.Type] { [Item.self] }
|
||||
}
|
||||
```
|
||||
</migration>
|
||||
|
||||
<decision_tree>
|
||||
## Choosing Your Approach
|
||||
|
||||
**New project, iOS 17+ only:** SwiftData
|
||||
**Need iOS 16 support:** Core Data
|
||||
**Existing Core Data project:** Keep Core Data unless full migration planned
|
||||
**Need CloudKit:** SwiftData (simpler) or Core Data (more control)
|
||||
</decision_tree>
|
||||
|
||||
<anti_patterns>
|
||||
## What NOT to Do
|
||||
|
||||
<anti_pattern name="Using @Query outside SwiftUI views">
|
||||
**Problem:** @Query requires SwiftUI environment
|
||||
**Instead:** Use FetchDescriptor with explicit context in view models
|
||||
</anti_pattern>
|
||||
|
||||
<anti_pattern name="Using @Attribute(.unique) with CloudKit">
|
||||
**Problem:** Silently breaks CloudKit sync
|
||||
**Instead:** Handle uniqueness in app logic
|
||||
</anti_pattern>
|
||||
|
||||
<anti_pattern name="Transient properties in predicates">
|
||||
**Problem:** Compiles but crashes at runtime
|
||||
**Instead:** Use persisted properties for filtering
|
||||
</anti_pattern>
|
||||
</anti_patterns>
|
||||
|
|
@ -1,247 +0,0 @@
|
|||
<overview>
|
||||
Testing and debugging SwiftUI apps requires a multi-layered approach combining previews, unit tests, UI tests, and debugging tools. SwiftUI's declarative nature makes traditional debugging challenging, but modern tools provide robust solutions.
|
||||
|
||||
**Key principles:**
|
||||
- Use #Preview macros for rapid visual iteration
|
||||
- Test business logic with @Observable view models (not views directly)
|
||||
- Write focused UI tests using accessibility identifiers
|
||||
- Profile with Instruments on real devices
|
||||
|
||||
SwiftUI views cannot be unit tested directly. Test view models and use UI automation tests for interaction testing.
|
||||
</overview>
|
||||
|
||||
<previews>
|
||||
## Xcode Previews
|
||||
|
||||
**Basic #Preview:**
|
||||
```swift
|
||||
#Preview {
|
||||
ContentView()
|
||||
}
|
||||
|
||||
#Preview("Dark Mode") {
|
||||
ContentView()
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
```
|
||||
|
||||
**Multiple states:**
|
||||
```swift
|
||||
#Preview("Empty") { TaskListView(tasks: []) }
|
||||
#Preview("Loaded") { TaskListView(tasks: Task.sampleData) }
|
||||
#Preview("Error") { TaskListView(tasks: [], error: "Network unavailable") }
|
||||
```
|
||||
|
||||
**With @Binding (Xcode 16+):**
|
||||
```swift
|
||||
#Preview {
|
||||
@Previewable @State var isOn = true
|
||||
ToggleView(isOn: $isOn)
|
||||
}
|
||||
```
|
||||
|
||||
**Mock data:**
|
||||
```swift
|
||||
extension Task {
|
||||
static let sampleData: [Task] = [
|
||||
Task(title: "Review PR", isCompleted: false),
|
||||
Task(title: "Write tests", isCompleted: true)
|
||||
]
|
||||
}
|
||||
```
|
||||
</previews>
|
||||
|
||||
<unit_testing>
|
||||
## Unit Testing View Models
|
||||
|
||||
**Testing @Observable with Swift Testing:**
|
||||
```swift
|
||||
import Testing
|
||||
@testable import MyApp
|
||||
|
||||
@Test("Login validation")
|
||||
func loginValidation() {
|
||||
let viewModel = LoginViewModel()
|
||||
viewModel.email = ""
|
||||
viewModel.password = "password123"
|
||||
#expect(!viewModel.isValidInput)
|
||||
|
||||
viewModel.email = "user@example.com"
|
||||
#expect(viewModel.isValidInput)
|
||||
}
|
||||
|
||||
@Test("Async data loading")
|
||||
func dataLoading() async {
|
||||
let mockService = MockService()
|
||||
let viewModel = TaskViewModel(service: mockService)
|
||||
|
||||
await viewModel.load()
|
||||
|
||||
#expect(!viewModel.tasks.isEmpty)
|
||||
}
|
||||
```
|
||||
|
||||
**Dependency injection for testing:**
|
||||
```swift
|
||||
@Observable
|
||||
final class TaskViewModel {
|
||||
private let service: TaskServiceProtocol
|
||||
|
||||
init(service: TaskServiceProtocol = TaskService()) {
|
||||
self.service = service
|
||||
}
|
||||
}
|
||||
```
|
||||
</unit_testing>
|
||||
|
||||
<ui_testing>
|
||||
## UI Testing
|
||||
|
||||
**Setting accessibility identifiers:**
|
||||
```swift
|
||||
TextField("Email", text: $email)
|
||||
.accessibilityIdentifier("emailField")
|
||||
|
||||
Button("Login") { }
|
||||
.accessibilityIdentifier("loginButton")
|
||||
```
|
||||
|
||||
**Writing UI tests:**
|
||||
```swift
|
||||
import XCTest
|
||||
|
||||
final class LoginUITests: XCTestCase {
|
||||
var app: XCUIApplication!
|
||||
|
||||
override func setUp() {
|
||||
continueAfterFailure = false
|
||||
app = XCUIApplication()
|
||||
app.launch()
|
||||
}
|
||||
|
||||
func testLoginFlow() {
|
||||
let emailField = app.textFields["emailField"]
|
||||
let loginButton = app.buttons["loginButton"]
|
||||
|
||||
XCTAssertTrue(emailField.waitForExistence(timeout: 5))
|
||||
emailField.tap()
|
||||
emailField.typeText("user@example.com")
|
||||
|
||||
loginButton.tap()
|
||||
|
||||
let welcomeText = app.staticTexts["welcomeMessage"]
|
||||
XCTAssertTrue(welcomeText.waitForExistence(timeout: 5))
|
||||
}
|
||||
}
|
||||
```
|
||||
</ui_testing>
|
||||
|
||||
<debugging>
|
||||
## Debugging Techniques
|
||||
|
||||
**_printChanges():**
|
||||
```swift
|
||||
var body: some View {
|
||||
let _ = Self._printChanges()
|
||||
VStack { /* content */ }
|
||||
}
|
||||
```
|
||||
|
||||
**View hierarchy debugger:**
|
||||
Debug menu → View Debugging → Capture View Hierarchy
|
||||
|
||||
**Lifecycle debugging:**
|
||||
```swift
|
||||
.onAppear { print("View appeared") }
|
||||
.onDisappear { print("View disappeared") }
|
||||
.task { print("Task started") }
|
||||
```
|
||||
|
||||
**Visual debugging:**
|
||||
```swift
|
||||
.border(.red)
|
||||
.background(.yellow.opacity(0.3))
|
||||
```
|
||||
</debugging>
|
||||
|
||||
<instruments>
|
||||
## Instruments Profiling
|
||||
|
||||
**SwiftUI template (Xcode 16+):**
|
||||
- View Body: Track view creation count
|
||||
- View Properties: Monitor property changes
|
||||
- Core Animation Commits: Animation performance
|
||||
|
||||
**Time Profiler:**
|
||||
1. Product → Profile (Cmd+I)
|
||||
2. Select Time Profiler
|
||||
3. Record while using app
|
||||
4. Sort by "Self" time to find hotspots
|
||||
|
||||
**Allocations:**
|
||||
- Track memory usage
|
||||
- Filter by "Persistent" to find leaks
|
||||
|
||||
**Always profile on real devices, not simulators.**
|
||||
</instruments>
|
||||
|
||||
<common_bugs>
|
||||
## Common SwiftUI Bugs
|
||||
|
||||
**View not updating:**
|
||||
```swift
|
||||
// Problem: missing @State
|
||||
var count = 0 // Won't trigger updates
|
||||
|
||||
// Fix: use @State
|
||||
@State private var count = 0
|
||||
```
|
||||
|
||||
**ForEach crash on empty binding:**
|
||||
```swift
|
||||
// Problem: binding crashes on empty
|
||||
ForEach($items) { $item in }
|
||||
|
||||
// Fix: check for empty
|
||||
if !items.isEmpty {
|
||||
ForEach($items) { $item in }
|
||||
}
|
||||
```
|
||||
|
||||
**Animation not working:**
|
||||
```swift
|
||||
// Problem: no value parameter
|
||||
.animation(.spring())
|
||||
|
||||
// Fix: specify value
|
||||
.animation(.spring(), value: isExpanded)
|
||||
```
|
||||
</common_bugs>
|
||||
|
||||
<decision_tree>
|
||||
## Testing Strategy
|
||||
|
||||
**Preview:** Visual iteration, different states
|
||||
**Unit Test:** @Observable view models, business logic
|
||||
**UI Test:** Critical user flows, login, checkout
|
||||
**Manual Test:** Animations, accessibility, performance
|
||||
</decision_tree>
|
||||
|
||||
<anti_patterns>
|
||||
## What NOT to Do
|
||||
|
||||
<anti_pattern name="Testing view bodies">
|
||||
**Problem:** Trying to unit test SwiftUI views directly
|
||||
**Instead:** Extract logic to view models, test those
|
||||
</anti_pattern>
|
||||
|
||||
<anti_pattern name="Missing accessibility identifiers">
|
||||
**Problem:** Using text to find elements in UI tests
|
||||
**Instead:** Use .accessibilityIdentifier("stableId")
|
||||
</anti_pattern>
|
||||
|
||||
<anti_pattern name="No dependency injection">
|
||||
**Problem:** Hardcoded dependencies in view models
|
||||
**Instead:** Use protocols, inject mocks in tests
|
||||
</anti_pattern>
|
||||
</anti_patterns>
|
||||
|
|
@ -1,218 +0,0 @@
|
|||
<overview>
|
||||
SwiftUI wraps UIKit on iOS and AppKit on macOS. Interoperability enables using UIKit/AppKit features not yet available in SwiftUI, and incrementally adopting SwiftUI in existing projects.
|
||||
|
||||
**Bridging patterns:**
|
||||
- **SwiftUI → UIKit/AppKit**: UIViewRepresentable, NSViewRepresentable, UIViewControllerRepresentable
|
||||
- **UIKit/AppKit → SwiftUI**: UIHostingController, NSHostingController/NSHostingView
|
||||
- **Coordinator pattern**: Bridge delegates and target-action patterns to SwiftUI
|
||||
|
||||
**When to read this:**
|
||||
- Wrapping UIKit views not available in SwiftUI
|
||||
- Embedding SwiftUI in existing UIKit apps
|
||||
- Handling delegate-based APIs
|
||||
</overview>
|
||||
|
||||
<uiview_representable>
|
||||
## UIViewRepresentable
|
||||
|
||||
**Basic structure:**
|
||||
```swift
|
||||
struct CustomTextField: UIViewRepresentable {
|
||||
@Binding var text: String
|
||||
|
||||
func makeUIView(context: Context) -> UITextField {
|
||||
let textField = UITextField()
|
||||
textField.delegate = context.coordinator
|
||||
return textField
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: UITextField, context: Context) {
|
||||
if uiView.text != text {
|
||||
uiView.text = text
|
||||
}
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(self)
|
||||
}
|
||||
|
||||
class Coordinator: NSObject, UITextFieldDelegate {
|
||||
var parent: CustomTextField
|
||||
|
||||
init(_ parent: CustomTextField) {
|
||||
self.parent = parent
|
||||
}
|
||||
|
||||
func textFieldDidChangeSelection(_ textField: UITextField) {
|
||||
parent.text = textField.text ?? ""
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Lifecycle:**
|
||||
- `makeUIView` - called once when created
|
||||
- `updateUIView` - called when SwiftUI state changes
|
||||
- `dismantleUIView` - optional cleanup
|
||||
</uiview_representable>
|
||||
|
||||
<uiviewcontroller_representable>
|
||||
## UIViewControllerRepresentable
|
||||
|
||||
```swift
|
||||
struct ImagePicker: UIViewControllerRepresentable {
|
||||
@Binding var image: UIImage?
|
||||
@Environment(\.dismiss) var dismiss
|
||||
|
||||
func makeUIViewController(context: Context) -> UIImagePickerController {
|
||||
let picker = UIImagePickerController()
|
||||
picker.delegate = context.coordinator
|
||||
return picker
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(self)
|
||||
}
|
||||
|
||||
class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
|
||||
let parent: ImagePicker
|
||||
|
||||
init(_ parent: ImagePicker) {
|
||||
self.parent = parent
|
||||
}
|
||||
|
||||
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {
|
||||
parent.image = info[.originalImage] as? UIImage
|
||||
parent.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</uiviewcontroller_representable>
|
||||
|
||||
<nsview_representable>
|
||||
## NSViewRepresentable (macOS)
|
||||
|
||||
Same pattern as UIViewRepresentable:
|
||||
|
||||
```swift
|
||||
struct ColorWell: NSViewRepresentable {
|
||||
@Binding var color: NSColor
|
||||
|
||||
func makeNSView(context: Context) -> NSColorWell {
|
||||
let colorWell = NSColorWell()
|
||||
colorWell.target = context.coordinator
|
||||
colorWell.action = #selector(Coordinator.colorDidChange(_:))
|
||||
return colorWell
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: NSColorWell, context: Context) {
|
||||
nsView.color = color
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(self)
|
||||
}
|
||||
|
||||
class Coordinator: NSObject {
|
||||
var parent: ColorWell
|
||||
|
||||
init(_ parent: ColorWell) {
|
||||
self.parent = parent
|
||||
}
|
||||
|
||||
@objc func colorDidChange(_ sender: NSColorWell) {
|
||||
parent.color = sender.color
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</nsview_representable>
|
||||
|
||||
<hosting_controller>
|
||||
## UIHostingController
|
||||
|
||||
**Embedding SwiftUI in UIKit:**
|
||||
```swift
|
||||
class MainViewController: UIViewController {
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
let swiftUIView = MySwiftUIView()
|
||||
let hostingController = UIHostingController(rootView: swiftUIView)
|
||||
|
||||
addChild(hostingController)
|
||||
view.addSubview(hostingController.view)
|
||||
|
||||
hostingController.view.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
hostingController.view.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
hostingController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
hostingController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
hostingController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor)
|
||||
])
|
||||
|
||||
hostingController.didMove(toParent: self)
|
||||
}
|
||||
}
|
||||
```
|
||||
</hosting_controller>
|
||||
|
||||
<coordinator_pattern>
|
||||
## Coordinator Pattern
|
||||
|
||||
**When to use:**
|
||||
- Handling delegate callbacks
|
||||
- Managing target-action patterns
|
||||
- Bridging imperative events to SwiftUI
|
||||
|
||||
**Structure:**
|
||||
```swift
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(self)
|
||||
}
|
||||
|
||||
class Coordinator: NSObject, SomeDelegate {
|
||||
var parent: ParentView
|
||||
|
||||
init(_ parent: ParentView) {
|
||||
self.parent = parent
|
||||
}
|
||||
}
|
||||
```
|
||||
</coordinator_pattern>
|
||||
|
||||
<decision_tree>
|
||||
## When to Use Interop
|
||||
|
||||
**Use UIKit/AppKit when:**
|
||||
- SwiftUI lacks the feature
|
||||
- Performance critical scenarios
|
||||
- Integrating existing code
|
||||
|
||||
**Stay with pure SwiftUI when:**
|
||||
- SwiftUI has native support
|
||||
- Xcode Previews matter
|
||||
- Cross-platform code needed
|
||||
</decision_tree>
|
||||
|
||||
<anti_patterns>
|
||||
## What NOT to Do
|
||||
|
||||
<anti_pattern name="UIKit by default">
|
||||
**Problem:** Using UIViewRepresentable when SwiftUI works
|
||||
**Instead:** Check if SwiftUI added the feature
|
||||
</anti_pattern>
|
||||
|
||||
<anti_pattern name="Skipping Coordinator">
|
||||
**Problem:** Handling delegates without Coordinator
|
||||
**Instead:** Always use Coordinator for delegate patterns
|
||||
</anti_pattern>
|
||||
|
||||
<anti_pattern name="Memory leaks in hosting">
|
||||
**Problem:** Not managing child view controller properly
|
||||
**Instead:** addChild → addSubview → didMove(toParent:)
|
||||
</anti_pattern>
|
||||
</anti_patterns>
|
||||
|
|
@ -1,191 +0,0 @@
|
|||
<required_reading>
|
||||
**Read these reference files NOW before starting:**
|
||||
1. `../macos-apps/references/cli-workflow.md` - Build, run, test from CLI
|
||||
2. `references/architecture.md` - App structure, MVVM patterns
|
||||
3. `references/state-management.md` - Property wrappers, @Observable
|
||||
</required_reading>
|
||||
|
||||
<process>
|
||||
## Step 1: Understand Existing Codebase
|
||||
|
||||
```bash
|
||||
find . -name "*.swift" -type f | head -20
|
||||
```
|
||||
|
||||
**Identify:**
|
||||
- App architecture (MVVM, TCA, etc.)
|
||||
- Existing patterns and conventions
|
||||
- Navigation approach
|
||||
- Dependency injection method
|
||||
|
||||
## Step 2: Plan Feature Integration
|
||||
|
||||
**Define scope:**
|
||||
- What views needed?
|
||||
- What state must be managed?
|
||||
- Does it need persistence (SwiftData)?
|
||||
- Does it need network calls?
|
||||
- How does it connect to existing features?
|
||||
|
||||
## Step 3: Create Feature Module
|
||||
|
||||
Follow existing organization:
|
||||
```
|
||||
Features/
|
||||
YourFeature/
|
||||
Views/
|
||||
YourFeatureView.swift
|
||||
ViewModels/
|
||||
YourFeatureViewModel.swift
|
||||
Models/
|
||||
YourFeatureModel.swift
|
||||
```
|
||||
|
||||
## Step 4: Implement View Model
|
||||
|
||||
```swift
|
||||
@Observable
|
||||
final class YourFeatureViewModel {
|
||||
var items: [YourModel] = []
|
||||
var isLoading = false
|
||||
var errorMessage: String?
|
||||
|
||||
private let dataService: DataService
|
||||
|
||||
init(dataService: DataService) {
|
||||
self.dataService = dataService
|
||||
}
|
||||
|
||||
func loadData() async {
|
||||
isLoading = true
|
||||
defer { isLoading = false }
|
||||
|
||||
do {
|
||||
items = try await dataService.fetchItems()
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Step 5: Implement Views
|
||||
|
||||
```swift
|
||||
struct YourFeatureView: View {
|
||||
@State private var viewModel: YourFeatureViewModel
|
||||
|
||||
init(viewModel: YourFeatureViewModel) {
|
||||
self.viewModel = viewModel
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List(viewModel.items) { item in
|
||||
NavigationLink(value: item) {
|
||||
YourItemRow(item: item)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Feature Title")
|
||||
.navigationDestination(for: YourModel.self) { item in
|
||||
YourFeatureDetailView(item: item)
|
||||
}
|
||||
.task {
|
||||
await viewModel.loadData()
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Step 6: Wire Up Navigation
|
||||
|
||||
**NavigationStack routing:**
|
||||
```swift
|
||||
NavigationLink(value: NavigationDestination.yourFeature) {
|
||||
Text("Go to Feature")
|
||||
}
|
||||
|
||||
.navigationDestination(for: NavigationDestination.self) { destination in
|
||||
switch destination {
|
||||
case .yourFeature:
|
||||
YourFeatureView(viewModel: viewModel)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Sheet presentation:**
|
||||
```swift
|
||||
@State private var showingFeature = false
|
||||
|
||||
Button("Show") { showingFeature = true }
|
||||
.sheet(isPresented: $showingFeature) {
|
||||
NavigationStack { YourFeatureView(viewModel: viewModel) }
|
||||
}
|
||||
```
|
||||
|
||||
## Step 7: Build and Verify
|
||||
|
||||
```bash
|
||||
# 1. Build
|
||||
xcodebuild -scheme AppName build 2>&1 | xcsift
|
||||
|
||||
# 2. Run tests
|
||||
xcodebuild -scheme AppName test 2>&1 | xcsift
|
||||
|
||||
# 3. Launch and monitor
|
||||
# macOS:
|
||||
open ./build/Build/Products/Debug/AppName.app
|
||||
log stream --predicate 'subsystem == "com.yourcompany.appname"' --level debug
|
||||
|
||||
# iOS Simulator:
|
||||
xcrun simctl boot "iPhone 15 Pro" 2>/dev/null || true
|
||||
xcrun simctl install booted ./build/Build/Products/Debug-iphonesimulator/AppName.app
|
||||
xcrun simctl launch booted com.yourcompany.appname
|
||||
```
|
||||
|
||||
Report to user:
|
||||
- "Build: ✓"
|
||||
- "Tests: X pass, 0 fail"
|
||||
- "Feature added. Ready for you to test [navigation path to feature]"
|
||||
|
||||
**User verifies:**
|
||||
- Navigate to feature from all entry points
|
||||
- Test interactions
|
||||
- Check loading/error states
|
||||
- Verify light and dark mode
|
||||
</process>
|
||||
|
||||
<anti_patterns>
|
||||
## Avoid These Mistakes
|
||||
|
||||
**Not following existing patterns:**
|
||||
- Creating new navigation when project has established pattern
|
||||
- Using different naming conventions
|
||||
- Introducing new DI when project has standard
|
||||
|
||||
**Overengineering:**
|
||||
- Adding abstraction that doesn't exist elsewhere
|
||||
- Creating generic solutions for specific problems
|
||||
- Breaking single view into dozens of tiny files prematurely
|
||||
|
||||
**Tight coupling:**
|
||||
- Accessing other features' view models directly
|
||||
- Hardcoding dependencies
|
||||
- Circular dependencies between features
|
||||
|
||||
**Breaking existing functionality:**
|
||||
- Modifying shared view models without checking all callers
|
||||
- Changing navigation state structure
|
||||
- Removing @Environment values other views depend on
|
||||
</anti_patterns>
|
||||
|
||||
<success_criteria>
|
||||
This workflow is complete when:
|
||||
- [ ] Feature matches existing architecture patterns
|
||||
- [ ] Views compose with existing navigation
|
||||
- [ ] State management follows project conventions
|
||||
- [ ] Dependency injection consistent with existing code
|
||||
- [ ] All existing tests pass
|
||||
- [ ] No compiler warnings introduced
|
||||
- [ ] Error states handled gracefully
|
||||
- [ ] Code follows existing naming conventions
|
||||
</success_criteria>
|
||||
|
|
@ -1,311 +0,0 @@
|
|||
<required_reading>
|
||||
**Read these reference files NOW before starting:**
|
||||
1. `../macos-apps/references/project-scaffolding.md` - XcodeGen templates and file structure
|
||||
2. `../macos-apps/references/cli-workflow.md` - Build/run/test from CLI
|
||||
3. `references/architecture.md` - MVVM patterns and project structure
|
||||
4. `references/state-management.md` - Property wrappers
|
||||
</required_reading>
|
||||
|
||||
<process>
|
||||
## Step 1: Clarify Requirements
|
||||
|
||||
Ask the user:
|
||||
- What does the app do? (core functionality)
|
||||
- Which platform? (iOS, macOS, or both)
|
||||
- Any specific features needed? (persistence, networking, system integration)
|
||||
|
||||
## Step 2: Scaffold Project with XcodeGen
|
||||
|
||||
```bash
|
||||
# Create directory structure
|
||||
mkdir -p AppName/Sources AppName/Tests AppName/Resources
|
||||
cd AppName
|
||||
|
||||
# Create project.yml (see ../macos-apps/references/project-scaffolding.md for full template)
|
||||
cat > project.yml << 'EOF'
|
||||
name: AppName
|
||||
options:
|
||||
bundleIdPrefix: com.yourcompany
|
||||
deploymentTarget:
|
||||
iOS: "17.0"
|
||||
macOS: "14.0"
|
||||
xcodeVersion: "15.0"
|
||||
createIntermediateGroups: true
|
||||
|
||||
targets:
|
||||
AppName:
|
||||
type: application
|
||||
platform: iOS # or macOS, or [iOS, macOS] for multi-platform
|
||||
sources: [Sources]
|
||||
resources: [Resources]
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.yourcompany.appname
|
||||
DEVELOPMENT_TEAM: YOURTEAMID
|
||||
SWIFT_VERSION: "5.9"
|
||||
|
||||
AppNameTests:
|
||||
type: bundle.unit-test
|
||||
platform: iOS
|
||||
sources: [Tests]
|
||||
dependencies:
|
||||
- target: AppName
|
||||
|
||||
schemes:
|
||||
AppName:
|
||||
build:
|
||||
targets:
|
||||
AppName: all
|
||||
AppNameTests: [test]
|
||||
test:
|
||||
targets: [AppNameTests]
|
||||
EOF
|
||||
|
||||
# Generate xcodeproj
|
||||
xcodegen generate
|
||||
|
||||
# Verify
|
||||
xcodebuild -list -project AppName.xcodeproj
|
||||
```
|
||||
|
||||
## Step 3: Create Source Files
|
||||
|
||||
```
|
||||
Sources/
|
||||
├── AppNameApp.swift # App entry point
|
||||
├── ContentView.swift # Main view
|
||||
├── Models/
|
||||
├── ViewModels/
|
||||
├── Views/
|
||||
│ ├── Screens/
|
||||
│ └── Components/
|
||||
├── Services/
|
||||
└── Info.plist
|
||||
```
|
||||
|
||||
## Step 4: Configure App Entry Point
|
||||
|
||||
```swift
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct YourAppNameApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Step 5: Create Base Navigation
|
||||
|
||||
**Tab-based app:**
|
||||
```swift
|
||||
struct MainTabView: View {
|
||||
var body: some View {
|
||||
TabView {
|
||||
HomeView()
|
||||
.tabItem { Label("Home", systemImage: "house") }
|
||||
SettingsView()
|
||||
.tabItem { Label("Settings", systemImage: "gear") }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Stack-based navigation:**
|
||||
```swift
|
||||
struct RootView: View {
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
HomeView()
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Step 6: Implement First View Model
|
||||
|
||||
```swift
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
@Observable
|
||||
final class HomeViewModel {
|
||||
var items: [Item] = []
|
||||
var isLoading = false
|
||||
var errorMessage: String?
|
||||
|
||||
func loadData() async {
|
||||
isLoading = true
|
||||
defer { isLoading = false }
|
||||
|
||||
do {
|
||||
// items = try await service.fetchItems()
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Step 7: Create Main View
|
||||
|
||||
```swift
|
||||
struct HomeView: View {
|
||||
@State private var viewModel = HomeViewModel()
|
||||
|
||||
var body: some View {
|
||||
List(viewModel.items) { item in
|
||||
Text(item.name)
|
||||
}
|
||||
.navigationTitle("Home")
|
||||
.overlay {
|
||||
if viewModel.isLoading { ProgressView() }
|
||||
}
|
||||
.task {
|
||||
await viewModel.loadData()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NavigationStack { HomeView() }
|
||||
}
|
||||
```
|
||||
|
||||
## Step 8: Wire Up Dependencies
|
||||
|
||||
```swift
|
||||
@Observable
|
||||
final class AppDependencies {
|
||||
let apiService: APIService
|
||||
|
||||
static let shared = AppDependencies()
|
||||
|
||||
private init() {
|
||||
self.apiService = APIService()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Inject in App:
|
||||
```swift
|
||||
@main
|
||||
struct YourAppNameApp: App {
|
||||
@State private var dependencies = AppDependencies.shared
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.environment(dependencies)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Step 9: Build and Verify
|
||||
|
||||
```bash
|
||||
# Build with error parsing
|
||||
xcodebuild -scheme AppName -destination 'platform=iOS Simulator,name=iPhone 15 Pro' build 2>&1 | xcsift
|
||||
|
||||
# Boot simulator and install
|
||||
xcrun simctl boot "iPhone 15 Pro" 2>/dev/null || true
|
||||
xcrun simctl install booted ./build/Build/Products/Debug-iphonesimulator/AppName.app
|
||||
|
||||
# Launch and stream logs
|
||||
xcrun simctl launch booted com.yourcompany.appname
|
||||
log stream --predicate 'subsystem == "com.yourcompany.appname"' --level debug
|
||||
```
|
||||
|
||||
For macOS apps:
|
||||
```bash
|
||||
xcodebuild -scheme AppName build 2>&1 | xcsift
|
||||
open ./build/Build/Products/Debug/AppName.app
|
||||
```
|
||||
|
||||
Report to user:
|
||||
- "Build: ✓"
|
||||
- "App installed on simulator, launching now"
|
||||
- "Ready for you to check [specific functionality]"
|
||||
</process>
|
||||
|
||||
<anti_patterns>
|
||||
## Avoid These Mistakes
|
||||
|
||||
**Using NavigationView:**
|
||||
```swift
|
||||
// DON'T
|
||||
NavigationView { ContentView() }
|
||||
|
||||
// DO
|
||||
NavigationStack { ContentView() }
|
||||
```
|
||||
|
||||
**Using ObservableObject for new code:**
|
||||
```swift
|
||||
// DON'T
|
||||
class ViewModel: ObservableObject {
|
||||
@Published var data = []
|
||||
}
|
||||
|
||||
// DO
|
||||
@Observable
|
||||
final class ViewModel {
|
||||
var data = []
|
||||
}
|
||||
```
|
||||
|
||||
**Massive views:**
|
||||
```swift
|
||||
// DON'T
|
||||
struct HomeView: View {
|
||||
var body: some View {
|
||||
VStack { /* 300 lines */ }
|
||||
}
|
||||
}
|
||||
|
||||
// DO
|
||||
struct HomeView: View {
|
||||
var body: some View {
|
||||
VStack {
|
||||
HeaderComponent()
|
||||
ContentList()
|
||||
FooterActions()
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Missing previews:**
|
||||
```swift
|
||||
// Always add previews for iteration
|
||||
#Preview { HomeView() }
|
||||
```
|
||||
|
||||
**Business logic in views:**
|
||||
```swift
|
||||
// Move to view model
|
||||
struct ProductView: View {
|
||||
@State private var viewModel = ProductViewModel()
|
||||
|
||||
var body: some View {
|
||||
Button("Buy") { Task { await viewModel.purchase() } }
|
||||
}
|
||||
}
|
||||
```
|
||||
</anti_patterns>
|
||||
|
||||
<success_criteria>
|
||||
This workflow is complete when:
|
||||
- [ ] Project builds without errors
|
||||
- [ ] Folder structure matches MVVM pattern
|
||||
- [ ] Navigation set up with NavigationStack or TabView
|
||||
- [ ] At least one @Observable view model exists
|
||||
- [ ] Dependencies injected via @Environment
|
||||
- [ ] No deprecated APIs (NavigationView, ObservableObject)
|
||||
- [ ] SwiftUI previews render correctly
|
||||
- [ ] App launches without warnings
|
||||
</success_criteria>
|
||||
|
|
@ -1,192 +0,0 @@
|
|||
<required_reading>
|
||||
**Read these reference files NOW before starting:**
|
||||
1. `../macos-apps/references/cli-observability.md` - Log streaming, crash analysis, LLDB, memory debugging
|
||||
2. `references/testing-debugging.md` - SwiftUI-specific debugging techniques
|
||||
3. `references/state-management.md` - State management issues are #1 bug source
|
||||
</required_reading>
|
||||
|
||||
<process>
|
||||
## Step 1: Reproduce the Bug Consistently
|
||||
|
||||
**Isolate the issue:**
|
||||
- Create minimal reproducible example
|
||||
- Remove unrelated views and logic
|
||||
- Test in both preview and simulator/device
|
||||
|
||||
**Document:**
|
||||
- What action triggers it?
|
||||
- Every time or intermittent?
|
||||
- Which platforms/OS versions?
|
||||
|
||||
## Step 2: Identify Bug Category
|
||||
|
||||
**State Management (60% of bugs):**
|
||||
- View not updating
|
||||
- Infinite update loops
|
||||
- @State/@Binding incorrect usage
|
||||
- Missing @Observable
|
||||
|
||||
**Layout Issues:**
|
||||
- Views not appearing
|
||||
- Wrong positioning
|
||||
- ScrollView/List sizing problems
|
||||
|
||||
**Navigation Issues:**
|
||||
- Stack corruption
|
||||
- Sheets not dismissing
|
||||
- Deep linking breaking
|
||||
|
||||
**Performance Issues:**
|
||||
- UI freezing
|
||||
- Excessive redraws
|
||||
- Memory leaks
|
||||
|
||||
## Step 3: Add Observability
|
||||
|
||||
**Add _printChanges() to suspect view:**
|
||||
```swift
|
||||
var body: some View {
|
||||
let _ = Self._printChanges()
|
||||
// rest of view
|
||||
}
|
||||
```
|
||||
This prints exactly which property caused the view to redraw.
|
||||
|
||||
**Add logging for runtime visibility:**
|
||||
```swift
|
||||
import os
|
||||
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "Debug")
|
||||
|
||||
// In your code:
|
||||
logger.debug("State changed: \(self.items.count) items")
|
||||
```
|
||||
|
||||
**Stream logs from CLI:**
|
||||
```bash
|
||||
# While app is running
|
||||
log stream --predicate 'subsystem == "com.yourcompany.appname"' --level debug
|
||||
|
||||
# Search historical logs
|
||||
log show --predicate 'subsystem == "com.yourcompany.appname"' --last 1h
|
||||
```
|
||||
|
||||
## Step 4: Check Common Causes
|
||||
|
||||
**State red flags:**
|
||||
- Mutating @State from outside owning view
|
||||
- Using @StateObject when should use @Observable
|
||||
- Missing @Bindable for passing bindings
|
||||
|
||||
**View identity issues:**
|
||||
- Array index as id when order changes
|
||||
- Missing .id() when identity should reset
|
||||
- Same id for different content
|
||||
|
||||
**Environment problems:**
|
||||
- Custom @Environment not provided
|
||||
- Using deprecated @EnvironmentObject
|
||||
|
||||
## Step 5: Apply Fix
|
||||
|
||||
**State fix:**
|
||||
```swift
|
||||
// Wrong: ObservableObject
|
||||
class ViewModel: ObservableObject {
|
||||
@Published var count = 0
|
||||
}
|
||||
|
||||
// Right: @Observable
|
||||
@Observable
|
||||
class ViewModel {
|
||||
var count = 0
|
||||
}
|
||||
```
|
||||
|
||||
**View identity fix:**
|
||||
```swift
|
||||
// Wrong: index as id
|
||||
ForEach(items.indices, id: \.self) { index in }
|
||||
|
||||
// Right: stable id
|
||||
ForEach(items) { item in }
|
||||
```
|
||||
|
||||
**Navigation fix:**
|
||||
```swift
|
||||
// Wrong: NavigationView
|
||||
NavigationView { }
|
||||
|
||||
// Right: NavigationStack
|
||||
NavigationStack { }
|
||||
```
|
||||
|
||||
## Step 6: Verify Fix from CLI
|
||||
|
||||
```bash
|
||||
# 1. Rebuild
|
||||
xcodebuild -scheme AppName build 2>&1 | xcsift
|
||||
|
||||
# 2. Run tests
|
||||
xcodebuild -scheme AppName test 2>&1 | xcsift
|
||||
|
||||
# 3. Launch and monitor
|
||||
open ./build/Build/Products/Debug/AppName.app
|
||||
log stream --predicate 'subsystem == "com.yourcompany.appname"' --level debug
|
||||
|
||||
# 4. Check for memory leaks
|
||||
leaks AppName
|
||||
|
||||
# 5. If crash occurred, check crash logs
|
||||
ls ~/Library/Logs/DiagnosticReports/ | grep AppName
|
||||
cat ~/Library/Logs/DiagnosticReports/AppName_*.ips | head -100
|
||||
```
|
||||
|
||||
**For deep debugging, attach LLDB:**
|
||||
```bash
|
||||
lldb -n AppName
|
||||
(lldb) breakpoint set --file ContentView.swift --line 42
|
||||
(lldb) continue
|
||||
```
|
||||
|
||||
Report to user:
|
||||
- "Bug no longer reproduces after [specific fix]"
|
||||
- "Tests pass: X pass, 0 fail"
|
||||
- "No memory leaks detected"
|
||||
- "Ready for you to verify the fix"
|
||||
</process>
|
||||
|
||||
<anti_patterns>
|
||||
## Avoid These Mistakes
|
||||
|
||||
**Random changes:**
|
||||
- Trying property wrappers without understanding
|
||||
- Adding .id(UUID()) hoping it fixes things
|
||||
- Wrapping in DispatchQueue.main.async as band-aid
|
||||
|
||||
**Ignoring root cause:**
|
||||
- Hiding warnings instead of fixing
|
||||
- Working around instead of fixing architecture
|
||||
|
||||
**Skipping _printChanges():**
|
||||
- For state bugs, this is the fastest diagnostic
|
||||
- Running this FIRST saves hours
|
||||
|
||||
**Using deprecated APIs:**
|
||||
- Fix bugs in ObservableObject? Migrate to @Observable
|
||||
- NavigationView bugs? Switch to NavigationStack
|
||||
|
||||
**Mutating state in body:**
|
||||
- Never change @State during body computation
|
||||
- Move to .task, .onChange, or button actions
|
||||
</anti_patterns>
|
||||
|
||||
<success_criteria>
|
||||
This workflow is complete when:
|
||||
- [ ] Bug is reproducible (or documented as intermittent)
|
||||
- [ ] Root cause identified using _printChanges() or other tool
|
||||
- [ ] Fix applied following SwiftUI best practices
|
||||
- [ ] Bug no longer occurs
|
||||
- [ ] No new bugs introduced
|
||||
- [ ] Tested on all target platforms
|
||||
- [ ] Console shows no related warnings
|
||||
</success_criteria>
|
||||
|
|
@ -1,197 +0,0 @@
|
|||
<required_reading>
|
||||
**Read these reference files NOW before starting:**
|
||||
1. `../macos-apps/references/cli-observability.md` - xctrace profiling, leak detection, memory debugging
|
||||
2. `references/performance.md` - Profiling, lazy loading, view identity, optimization
|
||||
3. `references/layout-system.md` - Layout containers and GeometryReader pitfalls
|
||||
</required_reading>
|
||||
|
||||
<process>
|
||||
## Step 1: Establish Performance Baseline
|
||||
|
||||
```bash
|
||||
# Build release for accurate profiling
|
||||
xcodebuild -scheme AppName -configuration Release build 2>&1 | xcsift
|
||||
|
||||
# List available profiling templates
|
||||
xcrun xctrace list templates
|
||||
|
||||
# Time Profiler - CPU usage baseline
|
||||
xcrun xctrace record \
|
||||
--template 'Time Profiler' \
|
||||
--time-limit 30s \
|
||||
--output baseline-cpu.trace \
|
||||
--launch -- ./build/Build/Products/Release/AppName.app/Contents/MacOS/AppName
|
||||
|
||||
# SwiftUI template (if available)
|
||||
xcrun xctrace record \
|
||||
--template 'SwiftUI' \
|
||||
--time-limit 30s \
|
||||
--output baseline-swiftui.trace \
|
||||
--launch -- ./build/Build/Products/Release/AppName.app/Contents/MacOS/AppName
|
||||
|
||||
# Export trace data
|
||||
xcrun xctrace export --input baseline-cpu.trace --toc
|
||||
```
|
||||
|
||||
Document baseline: CPU usage, view update count, frame rate during slow flows.
|
||||
|
||||
## Step 2: Profile View Updates
|
||||
|
||||
Add to suspect views:
|
||||
```swift
|
||||
var body: some View {
|
||||
let _ = Self._printChanges()
|
||||
// rest of view
|
||||
}
|
||||
```
|
||||
|
||||
Check console for which properties caused invalidation.
|
||||
|
||||
## Step 3: Fix Unnecessary View Recreation
|
||||
|
||||
**Stable view identity:**
|
||||
```swift
|
||||
// Wrong: index as id
|
||||
ForEach(items.indices, id: \.self) { }
|
||||
|
||||
// Right: stable id
|
||||
ForEach(items) { item in
|
||||
ItemRow(item: item).id(item.id)
|
||||
}
|
||||
```
|
||||
|
||||
**Isolate frequently-changing state:**
|
||||
```swift
|
||||
// Before: entire list recreates
|
||||
struct SlowList: View {
|
||||
@State private var items: [Item] = []
|
||||
@State private var count: Int = 0 // Updates often
|
||||
|
||||
var body: some View {
|
||||
List(items) { item in ItemRow(item: item) }
|
||||
}
|
||||
}
|
||||
|
||||
// After: isolate count to separate view
|
||||
struct FastList: View {
|
||||
@State private var items: [Item] = []
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
CountBadge() // Only this updates
|
||||
List(items) { item in ItemRow(item: item) }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Step 4: Optimize Lists
|
||||
|
||||
```swift
|
||||
// Use lazy containers
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 8) {
|
||||
ForEach(items) { item in
|
||||
ItemRow(item: item)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Step 5: Reduce Layout Passes
|
||||
|
||||
```swift
|
||||
// Avoid GeometryReader when possible
|
||||
// Before:
|
||||
GeometryReader { geo in
|
||||
Circle().frame(width: geo.size.width * 0.8)
|
||||
}
|
||||
|
||||
// After:
|
||||
Circle()
|
||||
.frame(maxWidth: .infinity)
|
||||
.aspectRatio(1, contentMode: .fit)
|
||||
.padding(.horizontal, 20)
|
||||
```
|
||||
|
||||
## Step 6: Use @Observable
|
||||
|
||||
```swift
|
||||
// Before: ObservableObject invalidates everything
|
||||
class OldViewModel: ObservableObject {
|
||||
@Published var name = ""
|
||||
@Published var count = 0
|
||||
}
|
||||
|
||||
// After: granular updates
|
||||
@Observable
|
||||
class ViewModel {
|
||||
var name = ""
|
||||
var count = 0
|
||||
}
|
||||
```
|
||||
|
||||
## Step 7: Verify Improvements from CLI
|
||||
|
||||
```bash
|
||||
# 1. Rebuild release
|
||||
xcodebuild -scheme AppName -configuration Release build 2>&1 | xcsift
|
||||
|
||||
# 2. Profile again with same settings
|
||||
xcrun xctrace record \
|
||||
--template 'Time Profiler' \
|
||||
--time-limit 30s \
|
||||
--output optimized-cpu.trace \
|
||||
--launch -- ./build/Build/Products/Release/AppName.app/Contents/MacOS/AppName
|
||||
|
||||
# 3. Check for memory leaks
|
||||
leaks AppName
|
||||
|
||||
# 4. Run tests to ensure no regressions
|
||||
xcodebuild test -scheme AppName 2>&1 | xcsift
|
||||
|
||||
# 5. Launch for user verification
|
||||
open ./build/Build/Products/Release/AppName.app
|
||||
```
|
||||
|
||||
Report to user:
|
||||
- "CPU usage reduced from X% to Y%"
|
||||
- "View body invocations reduced by Z%"
|
||||
- "No memory leaks detected"
|
||||
- "Tests: all pass, no regressions"
|
||||
- "App launched - please verify scrolling feels smooth"
|
||||
</process>
|
||||
|
||||
<anti_patterns>
|
||||
## Avoid These Mistakes
|
||||
|
||||
**Optimizing without profiling:**
|
||||
- Always measure with Instruments first
|
||||
- Let data guide decisions
|
||||
|
||||
**Using .equatable() as first resort:**
|
||||
- Masks the issue instead of fixing it
|
||||
- Can cause stale UI
|
||||
|
||||
**Testing only in simulator:**
|
||||
- Simulator runs on Mac CPU
|
||||
- Always profile on real devices
|
||||
|
||||
**Ignoring view identity:**
|
||||
- Use explicit id() when needed
|
||||
- Ensure stable IDs in ForEach
|
||||
|
||||
**Premature view extraction:**
|
||||
- Extract when it isolates state observation
|
||||
- Not "for performance" by default
|
||||
</anti_patterns>
|
||||
|
||||
<success_criteria>
|
||||
This workflow is complete when:
|
||||
- [ ] Time Profiler shows reduced CPU usage
|
||||
- [ ] 50%+ reduction in unnecessary view body invocations
|
||||
- [ ] Scroll performance at 60fps
|
||||
- [ ] App feels responsive on oldest supported device
|
||||
- [ ] Memory usage stable, no leaks
|
||||
- [ ] _printChanges() confirms targeted updates
|
||||
</success_criteria>
|
||||
|
|
@ -1,203 +0,0 @@
|
|||
<required_reading>
|
||||
**Read these reference files NOW before starting:**
|
||||
1. `../macos-apps/references/cli-workflow.md` - Build, test, sign, notarize from CLI
|
||||
2. `../macos-apps/references/security-code-signing.md` - Code signing and notarization
|
||||
3. `references/platform-integration.md` - iOS/macOS specifics, platform requirements
|
||||
</required_reading>
|
||||
|
||||
<process>
|
||||
## Step 1: Run Tests
|
||||
|
||||
```bash
|
||||
# iOS
|
||||
xcodebuild test -scheme AppName -destination 'platform=iOS Simulator,name=iPhone 15 Pro' 2>&1 | xcsift
|
||||
|
||||
# macOS
|
||||
xcodebuild test -scheme AppName 2>&1 | xcsift
|
||||
```
|
||||
|
||||
All tests must pass before shipping.
|
||||
|
||||
## Step 2: Profile Performance from CLI
|
||||
|
||||
```bash
|
||||
# Build release for accurate profiling
|
||||
xcodebuild -scheme AppName -configuration Release build 2>&1 | xcsift
|
||||
|
||||
# Time Profiler
|
||||
xcrun xctrace record \
|
||||
--template 'Time Profiler' \
|
||||
--time-limit 30s \
|
||||
--output ship-profile.trace \
|
||||
--launch -- ./build/Build/Products/Release/AppName.app/Contents/MacOS/AppName
|
||||
|
||||
# Check for leaks
|
||||
leaks AppName
|
||||
|
||||
# Memory allocations
|
||||
xcrun xctrace record \
|
||||
--template 'Allocations' \
|
||||
--time-limit 30s \
|
||||
--output ship-allocations.trace \
|
||||
--attach $(pgrep AppName)
|
||||
```
|
||||
|
||||
Report: "No memory leaks. CPU usage acceptable. Ready to ship."
|
||||
|
||||
## Step 3: Update Version Numbers
|
||||
|
||||
```bash
|
||||
# Marketing version
|
||||
/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString 1.0.0" "YourApp/Info.plist"
|
||||
|
||||
# Build number (must increment each submission)
|
||||
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion 1" "YourApp/Info.plist"
|
||||
```
|
||||
|
||||
## Step 4: Create Privacy Manifest
|
||||
|
||||
Create `PrivacyInfo.xcprivacy` with all accessed APIs:
|
||||
- NSPrivacyAccessedAPICategoryUserDefaults
|
||||
- NSPrivacyAccessedAPICategoryFileTimestamp
|
||||
- etc.
|
||||
|
||||
Required for iOS 17+ and macOS 14+.
|
||||
|
||||
## Step 5: Verify App Icons
|
||||
|
||||
All required sizes in Assets.xcassets:
|
||||
- 1024x1024 App Store icon (required)
|
||||
- All device sizes filled
|
||||
|
||||
## Step 6: Configure Code Signing
|
||||
|
||||
Set in project.yml (XcodeGen) or verify existing settings:
|
||||
```yaml
|
||||
settings:
|
||||
base:
|
||||
CODE_SIGN_STYLE: Automatic
|
||||
DEVELOPMENT_TEAM: YOURTEAMID
|
||||
CODE_SIGN_IDENTITY: "Apple Distribution"
|
||||
```
|
||||
|
||||
Or set via xcodebuild:
|
||||
```bash
|
||||
xcodebuild -scheme AppName \
|
||||
CODE_SIGN_STYLE=Automatic \
|
||||
DEVELOPMENT_TEAM=YOURTEAMID \
|
||||
archive
|
||||
```
|
||||
|
||||
## Step 7: Create Archive
|
||||
|
||||
```bash
|
||||
xcodebuild archive \
|
||||
-scheme YourApp \
|
||||
-configuration Release \
|
||||
-archivePath ./build/YourApp.xcarchive \
|
||||
-destination 'generic/platform=iOS'
|
||||
```
|
||||
|
||||
## Step 8: Export for App Store
|
||||
|
||||
```bash
|
||||
xcodebuild -exportArchive \
|
||||
-archivePath ./build/YourApp.xcarchive \
|
||||
-exportPath ./build/Export \
|
||||
-exportOptionsPlist ExportOptions.plist
|
||||
```
|
||||
|
||||
## Step 9: Create App in App Store Connect
|
||||
|
||||
1. Visit appstoreconnect.apple.com
|
||||
2. My Apps → + → New App
|
||||
3. Fill in name, bundle ID, SKU
|
||||
|
||||
## Step 10: Upload Build from CLI
|
||||
|
||||
```bash
|
||||
# Validate before upload
|
||||
xcrun altool --validate-app -f ./build/Export/AppName.ipa -t ios --apiKey YOUR_KEY --apiIssuer YOUR_ISSUER
|
||||
|
||||
# Upload to App Store Connect
|
||||
xcrun altool --upload-app -f ./build/Export/AppName.ipa -t ios --apiKey YOUR_KEY --apiIssuer YOUR_ISSUER
|
||||
|
||||
# For macOS apps, notarize first (see ../macos-apps/references/security-code-signing.md)
|
||||
xcrun notarytool submit AppName.zip --apple-id your@email.com --team-id TEAMID --password @keychain:AC_PASSWORD --wait
|
||||
xcrun stapler staple AppName.app
|
||||
```
|
||||
|
||||
Alternative: Use Transporter app if API keys aren't set up.
|
||||
|
||||
## Step 11: Complete Metadata
|
||||
|
||||
In App Store Connect:
|
||||
- Description (4000 char max)
|
||||
- Keywords (100 char max)
|
||||
- Screenshots (at least 1 per device type)
|
||||
- Privacy Policy URL
|
||||
- Support URL
|
||||
|
||||
## Step 12: Configure TestFlight (Optional)
|
||||
|
||||
1. Wait for build processing
|
||||
2. Add internal testers (up to 100)
|
||||
3. For external testing, submit for Beta App Review
|
||||
|
||||
## Step 13: Submit for Review
|
||||
|
||||
1. Select processed build
|
||||
2. Complete App Review Information
|
||||
3. Provide demo account if login required
|
||||
4. Submit for Review
|
||||
|
||||
Review typically completes in 24-48 hours.
|
||||
|
||||
## Step 14: Handle Outcome
|
||||
|
||||
**If approved:** Release manually or automatically
|
||||
|
||||
**If rejected:**
|
||||
- Read rejection reason
|
||||
- Fix issues
|
||||
- Increment build number
|
||||
- Re-upload and resubmit
|
||||
</process>
|
||||
|
||||
<anti_patterns>
|
||||
## Avoid These Mistakes
|
||||
|
||||
**Testing only in simulator:**
|
||||
- Always test on physical devices before submission
|
||||
|
||||
**Incomplete privacy manifest:**
|
||||
- Document all accessed APIs
|
||||
- Use Xcode's Privacy Report
|
||||
|
||||
**Same build number:**
|
||||
- Must increment CFBundleVersion for each upload
|
||||
|
||||
**Debug code in release:**
|
||||
- Remove NSLog, test accounts, debug views
|
||||
- Use #if DEBUG
|
||||
|
||||
**Screenshots of splash screen:**
|
||||
- Must show app in actual use
|
||||
- Guideline 2.3.3 rejection risk
|
||||
|
||||
**Not testing exported build:**
|
||||
- Export process applies different signing
|
||||
- Apps can crash after export despite working in Xcode
|
||||
</anti_patterns>
|
||||
|
||||
<success_criteria>
|
||||
This workflow is complete when:
|
||||
- [ ] All tests pass
|
||||
- [ ] Version and build numbers updated
|
||||
- [ ] Privacy manifest complete
|
||||
- [ ] Archive created successfully
|
||||
- [ ] Build uploaded to App Store Connect
|
||||
- [ ] Metadata and screenshots complete
|
||||
- [ ] App submitted for review
|
||||
- [ ] App approved and live on App Store
|
||||
</success_criteria>
|
||||
|
|
@ -1,235 +0,0 @@
|
|||
<required_reading>
|
||||
**Read these reference files NOW before starting:**
|
||||
1. `../macos-apps/references/cli-workflow.md` - Test commands from CLI
|
||||
2. `../macos-apps/references/testing-tdd.md` - TDD patterns, avoiding @main hangs
|
||||
3. `references/testing-debugging.md` - SwiftUI-specific testing and debugging
|
||||
</required_reading>
|
||||
|
||||
<process>
|
||||
## Step 1: Identify Testing Scope
|
||||
|
||||
**Test business logic in view models, not views:**
|
||||
```swift
|
||||
// Testable view model
|
||||
@Observable
|
||||
final class LoginViewModel {
|
||||
var email = ""
|
||||
var password = ""
|
||||
var isLoading = false
|
||||
|
||||
var isValidInput: Bool {
|
||||
!email.isEmpty && password.count >= 8
|
||||
}
|
||||
}
|
||||
|
||||
// View is just presentation
|
||||
struct LoginView: View {
|
||||
let viewModel: LoginViewModel
|
||||
var body: some View {
|
||||
Form {
|
||||
TextField("Email", text: $viewModel.email)
|
||||
Button("Login") { }
|
||||
.disabled(!viewModel.isValidInput)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Step 2: Write Unit Tests
|
||||
|
||||
**Using Swift Testing (@Test):**
|
||||
```swift
|
||||
import Testing
|
||||
@testable import MyApp
|
||||
|
||||
@Test("Email validation")
|
||||
func emailValidation() {
|
||||
let viewModel = LoginViewModel()
|
||||
|
||||
viewModel.email = ""
|
||||
viewModel.password = "password123"
|
||||
#expect(!viewModel.isValidInput)
|
||||
|
||||
viewModel.email = "user@example.com"
|
||||
#expect(viewModel.isValidInput)
|
||||
}
|
||||
|
||||
@Test("Async loading")
|
||||
func asyncLoading() async {
|
||||
let mockService = MockService()
|
||||
let viewModel = TaskViewModel(service: mockService)
|
||||
|
||||
await viewModel.load()
|
||||
|
||||
#expect(!viewModel.tasks.isEmpty)
|
||||
}
|
||||
```
|
||||
|
||||
## Step 3: Add Accessibility Identifiers
|
||||
|
||||
```swift
|
||||
TextField("Email", text: $email)
|
||||
.accessibilityIdentifier("emailField")
|
||||
|
||||
SecureField("Password", text: $password)
|
||||
.accessibilityIdentifier("passwordField")
|
||||
|
||||
Button("Login") { }
|
||||
.accessibilityIdentifier("loginButton")
|
||||
```
|
||||
|
||||
## Step 4: Write UI Tests
|
||||
|
||||
```swift
|
||||
import XCTest
|
||||
|
||||
final class LoginUITests: XCTestCase {
|
||||
var app: XCUIApplication!
|
||||
|
||||
override func setUp() {
|
||||
continueAfterFailure = false
|
||||
app = XCUIApplication()
|
||||
app.launch()
|
||||
}
|
||||
|
||||
func testLoginFlow() {
|
||||
let emailField = app.textFields["emailField"]
|
||||
let passwordField = app.secureTextFields["passwordField"]
|
||||
let loginButton = app.buttons["loginButton"]
|
||||
|
||||
XCTAssertTrue(emailField.waitForExistence(timeout: 5))
|
||||
|
||||
emailField.tap()
|
||||
emailField.typeText("user@example.com")
|
||||
|
||||
passwordField.tap()
|
||||
passwordField.typeText("password123")
|
||||
|
||||
XCTAssertTrue(loginButton.isEnabled)
|
||||
loginButton.tap()
|
||||
|
||||
let welcomeText = app.staticTexts["welcomeMessage"]
|
||||
XCTAssertTrue(welcomeText.waitForExistence(timeout: 5))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Step 5: Create Previews for Visual Testing
|
||||
|
||||
```swift
|
||||
#Preview("Empty") { LoginView(viewModel: LoginViewModel()) }
|
||||
|
||||
#Preview("Filled") {
|
||||
let viewModel = LoginViewModel()
|
||||
viewModel.email = "user@example.com"
|
||||
viewModel.password = "password123"
|
||||
return LoginView(viewModel: viewModel)
|
||||
}
|
||||
|
||||
#Preview("Error") {
|
||||
let viewModel = LoginViewModel()
|
||||
viewModel.errorMessage = "Invalid credentials"
|
||||
return LoginView(viewModel: viewModel)
|
||||
}
|
||||
|
||||
#Preview("Dark Mode") {
|
||||
LoginView(viewModel: LoginViewModel())
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
```
|
||||
|
||||
## Step 6: Run Tests from CLI
|
||||
|
||||
```bash
|
||||
# Run all tests with parsed output
|
||||
xcodebuild test -scheme AppName -destination 'platform=iOS Simulator,name=iPhone 15 Pro' 2>&1 | xcsift
|
||||
|
||||
# Run only unit tests
|
||||
xcodebuild test -scheme AppName -only-testing:AppNameTests 2>&1 | xcsift
|
||||
|
||||
# Run only UI tests
|
||||
xcodebuild test -scheme AppName -only-testing:AppNameUITests 2>&1 | xcsift
|
||||
|
||||
# Run specific test class
|
||||
xcodebuild test -scheme AppName -only-testing:AppNameTests/LoginViewModelTests 2>&1 | xcsift
|
||||
|
||||
# Run specific test method
|
||||
xcodebuild test -scheme AppName -only-testing:AppNameTests/LoginViewModelTests/testEmailValidation 2>&1 | xcsift
|
||||
|
||||
# Generate test coverage
|
||||
xcodebuild test -scheme AppName -enableCodeCoverage YES -resultBundlePath TestResults.xcresult 2>&1 | xcsift
|
||||
xcrun xccov view --report TestResults.xcresult
|
||||
```
|
||||
|
||||
**If tests hang:** The test target likely depends on the app target with `@main`. Extract testable code to a Core framework target. See `../macos-apps/references/testing-tdd.md`.
|
||||
|
||||
Report to user:
|
||||
- "Tests: X pass, Y fail"
|
||||
- "Coverage: Z% of lines"
|
||||
- If failures: "Failed tests: [list]. Investigating..."
|
||||
</process>
|
||||
|
||||
<anti_patterns>
|
||||
## Avoid These Mistakes
|
||||
|
||||
**Testing view bodies:**
|
||||
```swift
|
||||
// Wrong: can't test views directly
|
||||
func testView() {
|
||||
let view = LoginView()
|
||||
// Can't inspect SwiftUI view
|
||||
}
|
||||
|
||||
// Right: test view model
|
||||
@Test func emailInput() {
|
||||
let viewModel = LoginViewModel()
|
||||
viewModel.email = "test@example.com"
|
||||
#expect(viewModel.email == "test@example.com")
|
||||
}
|
||||
```
|
||||
|
||||
**Missing accessibility identifiers:**
|
||||
```swift
|
||||
// Wrong: using text
|
||||
let button = app.buttons["Login"]
|
||||
|
||||
// Right: stable identifier
|
||||
let button = app.buttons["loginButton"]
|
||||
```
|
||||
|
||||
**No dependency injection:**
|
||||
```swift
|
||||
// Wrong: can't mock
|
||||
@Observable
|
||||
class ViewModel {
|
||||
private let service = RealService()
|
||||
}
|
||||
|
||||
// Right: injectable
|
||||
@Observable
|
||||
class ViewModel {
|
||||
private let service: ServiceProtocol
|
||||
init(service: ServiceProtocol) {
|
||||
self.service = service
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**No edge case testing:**
|
||||
```swift
|
||||
// Test empty, invalid, error states
|
||||
@Test func emptyEmail() { }
|
||||
@Test func shortPassword() { }
|
||||
@Test func networkError() { }
|
||||
```
|
||||
</anti_patterns>
|
||||
|
||||
<success_criteria>
|
||||
This workflow is complete when:
|
||||
- [ ] Unit tests verify view model business logic
|
||||
- [ ] UI tests verify user flows using accessibility identifiers
|
||||
- [ ] All tests pass: `xcodebuild test -scheme YourApp`
|
||||
- [ ] Edge cases and error states have coverage
|
||||
- [ ] Dependencies use protocols for testability
|
||||
- [ ] Previews exist for major UI states
|
||||
</success_criteria>
|
||||
|
|
@ -163,8 +163,6 @@ When performing an audit, structure findings as:
|
|||
## References
|
||||
|
||||
For detailed guidelines on specific areas:
|
||||
- [Performance Optimization](../performance/SKILL.md)
|
||||
- [Core Web Vitals](../core-web-vitals/SKILL.md)
|
||||
- [Accessibility](../accessibility/SKILL.md)
|
||||
- [SEO](../seo/SKILL.md)
|
||||
- [Best Practices](../best-practices/SKILL.md)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue