From 763644bd956223ee58f280c255b160533e5d2145 Mon Sep 17 00:00:00 2001 From: Lex Christopherson Date: Wed, 11 Mar 2026 01:07:19 -0600 Subject: [PATCH] =?UTF-8?q?feat(skills):=20add=20bundled=20skills=20?= =?UTF-8?q?=E2=80=94=20frontend-design,=20swiftui,=20debug-like-expert?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../skills/debug-like-expert/SKILL.md | 231 +++ .../references/debugging-mindset.md | 253 +++ .../references/hypothesis-testing.md | 373 ++++ .../references/investigation-techniques.md | 337 ++++ .../references/verification-patterns.md | 425 ++++ .../references/when-to-research.md | 361 ++++ src/resources/skills/frontend-design/SKILL.md | 45 + src/resources/skills/swiftui/SKILL.md | 208 ++ .../skills/swiftui/references/animations.md | 921 +++++++++ .../skills/swiftui/references/architecture.md | 1561 +++++++++++++++ .../swiftui/references/layout-system.md | 1186 ++++++++++++ .../skills/swiftui/references/navigation.md | 1492 ++++++++++++++ .../swiftui/references/networking-async.md | 214 +++ .../skills/swiftui/references/performance.md | 1706 +++++++++++++++++ .../references/platform-integration.md | 204 ++ .../swiftui/references/state-management.md | 1443 ++++++++++++++ .../skills/swiftui/references/swiftdata.md | 297 +++ .../swiftui/references/testing-debugging.md | 247 +++ .../references/uikit-appkit-interop.md | 218 +++ .../skills/swiftui/workflows/add-feature.md | 191 ++ .../skills/swiftui/workflows/build-new-app.md | 311 +++ .../skills/swiftui/workflows/debug-swiftui.md | 192 ++ .../swiftui/workflows/optimize-performance.md | 197 ++ .../skills/swiftui/workflows/ship-app.md | 203 ++ .../skills/swiftui/workflows/write-tests.md | 235 +++ 25 files changed, 13051 insertions(+) create mode 100644 src/resources/skills/debug-like-expert/SKILL.md create mode 100644 src/resources/skills/debug-like-expert/references/debugging-mindset.md create mode 100644 src/resources/skills/debug-like-expert/references/hypothesis-testing.md create mode 100644 src/resources/skills/debug-like-expert/references/investigation-techniques.md create mode 100644 src/resources/skills/debug-like-expert/references/verification-patterns.md create mode 100644 src/resources/skills/debug-like-expert/references/when-to-research.md create mode 100644 src/resources/skills/frontend-design/SKILL.md create mode 100644 src/resources/skills/swiftui/SKILL.md create mode 100644 src/resources/skills/swiftui/references/animations.md create mode 100644 src/resources/skills/swiftui/references/architecture.md create mode 100644 src/resources/skills/swiftui/references/layout-system.md create mode 100644 src/resources/skills/swiftui/references/navigation.md create mode 100644 src/resources/skills/swiftui/references/networking-async.md create mode 100644 src/resources/skills/swiftui/references/performance.md create mode 100644 src/resources/skills/swiftui/references/platform-integration.md create mode 100644 src/resources/skills/swiftui/references/state-management.md create mode 100644 src/resources/skills/swiftui/references/swiftdata.md create mode 100644 src/resources/skills/swiftui/references/testing-debugging.md create mode 100644 src/resources/skills/swiftui/references/uikit-appkit-interop.md create mode 100644 src/resources/skills/swiftui/workflows/add-feature.md create mode 100644 src/resources/skills/swiftui/workflows/build-new-app.md create mode 100644 src/resources/skills/swiftui/workflows/debug-swiftui.md create mode 100644 src/resources/skills/swiftui/workflows/optimize-performance.md create mode 100644 src/resources/skills/swiftui/workflows/ship-app.md create mode 100644 src/resources/skills/swiftui/workflows/write-tests.md diff --git a/src/resources/skills/debug-like-expert/SKILL.md b/src/resources/skills/debug-like-expert/SKILL.md new file mode 100644 index 000000000..157ab60ae --- /dev/null +++ b/src/resources/skills/debug-like-expert/SKILL.md @@ -0,0 +1,231 @@ +--- +name: debug-like-expert +description: Deep analysis debugging mode for complex issues. Activates methodical investigation protocol with evidence gathering, hypothesis testing, and rigorous verification. Use when standard troubleshooting fails or when issues require systematic root cause analysis. +--- + + +Deep analysis debugging mode for complex issues. This skill activates methodical investigation protocols with evidence gathering, hypothesis testing, and rigorous verification when standard troubleshooting has failed. + +The skill emphasizes treating code you wrote with MORE skepticism than unfamiliar code, as cognitive biases about "how it should work" can blind you to actual implementation errors. Use scientific method to systematically identify root causes rather than applying quick fixes. + + + +This skill activates when standard troubleshooting has failed. The issue requires methodical investigation, not quick fixes. You are entering the mindset of a senior engineer who debugs with scientific rigor. + +**Important**: If you wrote or modified any of the code being debugged, you have cognitive biases about how it works. Your mental model of "how it should work" may be wrong. Treat code you wrote with MORE skepticism than unfamiliar code - you're blind to your own assumptions. + + + +**VERIFY, DON'T ASSUME.** Every hypothesis must be tested. Every "fix" must be validated. No solutions without evidence. + +**ESPECIALLY**: Code you designed or implemented is guilty until proven innocent. Your intent doesn't matter - only the code's actual behavior matters. Question your own design decisions as rigorously as you'd question anyone else's. + + + +**THIS SKILL IS READ-ONLY. DO NOT MODIFY CODE.** + +The entire purpose is deep analysis and diagnosis. Making changes during investigation: +- Pollutes the evidence +- Introduces new variables +- Makes root cause harder to isolate + +You are a diagnostician, not a surgeon. Present findings, then let the user decide. + + + + + + +Before proposing any solution: + +**A. Document Current State** +- What is the EXACT error message or unexpected behavior? +- What are the EXACT steps to reproduce? +- What is the ACTUAL output vs EXPECTED output? +- When did this start working incorrectly (if known)? + +**B. Map the System** +- Trace the execution path from entry point to failure point +- Identify all components involved +- Read relevant source files completely, not just scanning +- Note dependencies, imports, configurations affecting this area + +**C. Gather External Knowledge (when needed)** +- Use MCP servers for API documentation, library details, or domain knowledge +- Use web search for error messages, framework-specific behaviors, or recent changes +- Check official docs for intended behavior vs what you observe +- Look for known issues, breaking changes, or version-specific quirks + +See [references/when-to-research.md](references/when-to-research.md) for detailed guidance on research strategy. + + + + + +**A. Form Hypotheses** + +Based on evidence, list possible causes: +1. [Hypothesis 1] - because [specific evidence] +2. [Hypothesis 2] - because [specific evidence] +3. [Hypothesis 3] - because [specific evidence] + +**B. Test Each Hypothesis** + +For each hypothesis: +- What would prove this true? +- What would prove this false? +- Design a minimal test +- Execute and document results + +See [references/hypothesis-testing.md](references/hypothesis-testing.md) for scientific method application. + +**C. Eliminate or Confirm** + +Don't move forward until you can answer: +- Which hypothesis is supported by evidence? +- What evidence contradicts other hypotheses? +- What additional information is needed? + + + + + +**Only after confirming root cause:** + +**A. Design Recommended Fix** +- What is the MINIMAL change that would address the root cause? +- What are potential side effects? +- What could this break? +- What tests should run after implementation? + +**B. Document, Don't Implement** +- Describe the fix with enough detail for implementation +- Include specific file paths, line numbers, and code snippets +- Explain WHY this addresses the root cause +- Note any prerequisites or dependencies + +**DO NOT make any code changes. Present your recommendations only.** + +See [references/verification-patterns.md](references/verification-patterns.md) for verification approaches to use after implementation. + + + + + + + +1. **NO DRIVE-BY FIXES**: If you can't explain WHY a change works, don't make it +2. **VERIFY EVERYTHING**: Test your assumptions. Read the actual code. Check the actual behavior +3. **USE ALL TOOLS**: + - MCP servers for external knowledge + - Web search for error messages, docs, known issues + - Extended thinking ("think deeply") for complex reasoning + - File reading for complete context +4. **THINK OUT LOUD**: Document your reasoning at each step +5. **ONE VARIABLE**: Change one thing at a time, verify, then proceed +6. **COMPLETE READS**: Don't skim code. Read entire relevant files +7. **CHASE DEPENDENCIES**: If the issue involves libraries, configs, or external systems, investigate those too +8. **QUESTION PREVIOUS WORK**: Maybe the earlier "fix" was wrong. Re-examine with fresh eyes + + + + + +Before completing: +- [ ] Do you understand WHY the issue occurred? +- [ ] Have you identified a root cause with evidence? +- [ ] Have you documented your reasoning? +- [ ] Can you explain the issue to someone else? +- [ ] Is your recommended fix specific and actionable? + +If you can't answer "yes" to all of these, keep investigating. + +**CRITICAL**: Present findings via decision gate. Do NOT implement changes. + + + + + +```markdown +## Issue: [Problem Description] + +### Evidence +[What you observed - exact errors, behaviors, outputs] + +### Investigation +[What you checked, what you found, what you ruled out] + +### Root Cause +[The actual underlying problem with evidence] + +### Recommended Fix +[What SHOULD be changed and WHY - specific files, lines, code] + +### Verification Plan +[How to confirm the fix works after implementation] + +### Risk Assessment +[Potential side effects, what could break, confidence level] +``` + + + + + +For deeper topics, see reference files: + +**Debugging mindset**: [references/debugging-mindset.md](references/debugging-mindset.md) +- First principles thinking applied to debugging +- Cognitive biases that lead to bad fixes +- The discipline of systematic investigation +- When to stop and restart with fresh assumptions + +**Investigation techniques**: [references/investigation-techniques.md](references/investigation-techniques.md) +- Binary search / divide and conquer +- Rubber duck debugging +- Minimal reproduction +- Working backwards from desired state +- Adding observability before changing code + +**Hypothesis testing**: [references/hypothesis-testing.md](references/hypothesis-testing.md) +- Forming falsifiable hypotheses +- Designing experiments that prove/disprove +- What makes evidence strong vs weak +- Recovering from wrong hypotheses gracefully + +**Verification patterns**: [references/verification-patterns.md](references/verification-patterns.md) +- Definition of "verified" (not just "it ran") +- Testing reproduction steps +- Regression testing adjacent functionality +- When to write tests before fixing + +**Research strategy**: [references/when-to-research.md](references/when-to-research.md) +- Signals that you need external knowledge +- What to search for vs what to reason about +- Balancing research time vs experimentation + + + + + +**After presenting findings, ALWAYS offer these options:** + +``` +───────────────────────────────────────── +ANALYSIS COMPLETE + +What would you like to do? + +1. **Fix it now** - I'll implement the recommended changes +2. **Create findings document** - Save analysis to a markdown file +3. **Explore further** - Investigate additional hypotheses +4. **Get second opinion** - Review with different assumptions +5. **Other** - Tell me what you need +───────────────────────────────────────── +``` + +**Wait for user response before taking any action.** + +This gate is MANDATORY. Never skip it. Never auto-implement. + + diff --git a/src/resources/skills/debug-like-expert/references/debugging-mindset.md b/src/resources/skills/debug-like-expert/references/debugging-mindset.md new file mode 100644 index 000000000..4501b90be --- /dev/null +++ b/src/resources/skills/debug-like-expert/references/debugging-mindset.md @@ -0,0 +1,253 @@ + +Debugging is applied epistemology. You're investigating a system to discover truth about its behavior. The difference between junior and senior debugging is not knowledge of frameworks - it's the discipline of systematic investigation. + + + +**Special challenge**: When you're debugging code you wrote or modified, you're fighting your own mental model. + +**Why this is harder**: +- You made the design decisions - they feel obviously correct +- You remember your intent, not what you actually implemented +- You see what you meant to write, not what's there +- Familiarity breeds blindness to bugs + +**The trap**: +- "I know this works because I implemented it correctly" +- "The bug must be elsewhere - I designed this part" +- "I tested this approach" +- These thoughts are red flags. Code you wrote is guilty until proven innocent. + +**The discipline**: + +**1. Treat your own code as foreign** +- Read it as if someone else wrote it +- Don't assume it does what you intended +- Verify what it actually does, not what you think it does +- Fresh eyes see bugs; familiar eyes see intent + +**2. Question your own design decisions** +- "I chose approach X because..." - Was that reasoning sound? +- "I assumed Y would..." - Have you verified Y actually does that? +- Your implementation decisions are hypotheses, not facts + +**3. Admit your mental model might be wrong** +- You built a mental model of how this works +- That model might be incomplete or incorrect +- The code's behavior is truth; your model is just a guess +- Be willing to discover you misunderstood the problem + +**4. Prioritize code you touched** +- If you modified 100 lines and something breaks +- Those 100 lines are the prime suspects +- Don't assume the bug is in the framework or existing code +- Start investigating where you made changes + + +❌ "I implemented the auth flow correctly, the bug must be in the existing user service" + +✅ "I implemented the auth flow. Let me verify each part: + - Does login actually set the token? [test it] + - Does the middleware actually validate it? [test it] + - Does logout actually clear it? [test it] + - One of these is probably wrong" + +The second approach found that logout wasn't clearing the token from localStorage, only from memory. + + +**The hardest admission**: "I implemented this wrong." + +Not "the requirements were unclear" or "the library is confusing" - YOU made an error. Whether it was 5 minutes ago or 5 days ago doesn't matter. Your code, your responsibility, your bug to find. + +This intellectual honesty is the difference between debugging for hours and finding bugs quickly. + + + +When debugging, return to foundational truths: + +**What do you know for certain?** +- What have you directly observed (not assumed)? +- What can you prove with a test right now? +- What is speculation vs evidence? + +**What are you assuming?** +- "This library should work this way" - Have you verified? +- "The docs say X" - Have you tested that X actually happens? +- "This worked before" - Can you prove when it worked and what changed? + +Strip away everything you think you know. Build understanding from observable facts. + + + +❌ "React state updates should be synchronous here" +✅ "Let me add a console.log to observe when state actually updates" + +❌ "The API must be returning bad data" +✅ "Let me log the exact response payload to see what's actually being returned" + +❌ "This database query should be fast" +✅ "Let me run EXPLAIN to see the actual execution plan" + + + + + +**The problem**: You form a hypothesis and only look for evidence that confirms it. + +**The trap**: "I think it's a race condition" → You only look for async code, missing the actual typo in a variable name. + +**The antidote**: Actively seek evidence that disproves your hypothesis. Ask "What would prove me wrong?" + + + +**The problem**: The first explanation you encounter becomes your anchor, and you adjust from there instead of considering alternatives. + +**The trap**: Error message mentions "timeout" → You assume it's a network issue, when it's actually a deadlock. + +**The antidote**: Generate multiple independent hypotheses before investigating any single one. Force yourself to list 3+ possible causes. + + + +**The problem**: You remember recent bugs and assume similar symptoms mean the same cause. + +**The trap**: "We had a caching issue last week, this must be caching too." + +**The antidote**: Treat each bug as novel until evidence suggests otherwise. Recent memory is not evidence. + + + +**The problem**: You've spent 2 hours debugging down one path, so you keep going even when evidence suggests it's wrong. + +**The trap**: "I've almost figured out this state management issue" - when the actual bug is in the API layer. + +**The antidote**: Set checkpoints. Every 30 minutes, ask: "If I started fresh right now, is this still the path I'd take?" + + + + + + + +**Why it matters**: If you change multiple things at once, you don't know which one fixed (or broke) it. + +**In practice**: +1. Make one change +2. Test +3. Observe result +4. Document +5. Repeat + +**The temptation**: "Let me also update this dependency and refactor this function and change this config..." + +**The reality**: Now you have no idea what actually mattered. + + + +**Why it matters**: Skimming code causes you to miss crucial details. You see what you expect to see, not what's there. + +**In practice**: +- Read entire functions, not just the "relevant" lines +- Read imports and dependencies +- Read configuration files completely +- Read test files to understand intended behavior + +**The shortcut**: "This function is long, I'll just read the part where the error happens" + +**The miss**: The bug is actually in how the function is called 50 lines up. + + + +**Why it matters**: Premature certainty stops investigation. "I don't know" is a position of strength. + +**In practice**: +- "I don't know why this fails" - Good. Now you can investigate. +- "It must be X" - Dangerous. You've stopped thinking. + +**The pressure**: Users want answers. Managers want ETAs. Your ego wants to look smart. + +**The truth**: "I need to investigate further" is more professional than a wrong fix. + + + + + + + +You should consider starting over when: + +1. **You've been investigating for 2+ hours with no progress** + - You're likely tunnel-visioned + - Take a break, then restart from evidence gathering + +2. **You've made 3+ "fixes" that didn't work** + - Your mental model is wrong + - Go back to first principles + +3. **You can't explain the current behavior** + - Don't add more changes on top of confusion + - First understand what's happening, then fix it + +4. **You're debugging the debugger** + - "Is my logging broken? Is the debugger lying?" + - Step back. Something fundamental is wrong. + +5. **The fix works but you don't know why** + - This isn't fixed. This is luck. + - Investigate until you understand, or revert the change + + + +When restarting: + +1. **Close all files and terminals** +2. **Write down what you know for certain** (not what you think) +3. **Write down what you've ruled out** +4. **List new hypotheses** (different from before) +5. **Begin again from Phase 1: Evidence Gathering** + +This isn't failure. This is professionalism. + + + + + +The best debuggers have deep humility about their mental models: + +**They know**: +- Their understanding of the system is incomplete +- Documentation can be wrong or outdated +- Their memory of "how this works" may be faulty +- The system's behavior is the only truth + +**They don't**: +- Trust their first instinct +- Assume anything works as designed +- Skip verification steps +- Declare victory without proof + +**They ask**: +- "What am I missing?" +- "What am I wrong about?" +- "What haven't I tested?" +- "What does the evidence actually say?" + + + +Debugging is a craft that improves with practice: + +**Novice debuggers**: +- Try random things hoping something works +- Skip reading code carefully +- Don't test their hypotheses +- Declare success too early + +**Expert debuggers**: +- Form hypotheses explicitly +- Test hypotheses systematically +- Read code like literature +- Verify fixes rigorously +- Learn from each investigation + +**The difference**: Not intelligence. Not knowledge. Discipline. + +Practice the discipline of systematic investigation, and debugging becomes a strength. + diff --git a/src/resources/skills/debug-like-expert/references/hypothesis-testing.md b/src/resources/skills/debug-like-expert/references/hypothesis-testing.md new file mode 100644 index 000000000..08be7ac75 --- /dev/null +++ b/src/resources/skills/debug-like-expert/references/hypothesis-testing.md @@ -0,0 +1,373 @@ + + +Debugging is applied scientific method. You observe a phenomenon (the bug), form hypotheses about its cause, design experiments to test those hypotheses, and revise based on evidence. This isn't metaphorical - it's literal experimental science. + + + + +A good hypothesis can be proven wrong. If you can't design an experiment that could disprove it, it's not a useful hypothesis. + +**Bad hypotheses** (unfalsifiable): +- "Something is wrong with the state" +- "The timing is off" +- "There's a race condition somewhere" +- "The library is buggy" + +**Good hypotheses** (falsifiable): +- "The user state is being reset because the component remounts when the route changes" +- "The API call completes after the component unmounts, causing the state update on unmounted component warning" +- "Two async operations are modifying the same array without locking, causing data loss" +- "The library's caching mechanism is returning stale data because our cache key doesn't include the timestamp" + +**The difference**: Specificity. Good hypotheses make specific, testable claims. + + + +**Process for forming hypotheses**: + +1. **Observe the behavior precisely** + - Not "it's broken" + - But "the counter shows 3 when clicking once, should show 1" + +2. **Ask "What could cause this?"** + - List every possible cause you can think of + - Don't judge them yet, just brainstorm + +3. **Make each hypothesis specific** + - Not "state is wrong" + - But "state is being updated twice because handleClick is called twice" + +4. **Identify what evidence would support/refute each** + - If hypothesis X is true, I should see Y + - If hypothesis X is false, I should see Z + + +**Observation**: Button click sometimes saves data, sometimes doesn't. + +**Vague hypothesis**: "The save isn't working reliably" +❌ Unfalsifiable, not specific + +**Specific hypotheses**: +1. "The save API call is timing out when network is slow" + - Testable: Check network tab for timeout errors + - Falsifiable: If all requests complete successfully, this is wrong + +2. "The save button is being double-clicked, and the second request overwrites with stale data" + - Testable: Add logging to count clicks + - Falsifiable: If only one click is registered, this is wrong + +3. "The save is successful but the UI doesn't update because the response is being ignored" + - Testable: Check if API returns success + - Falsifiable: If UI updates on successful response, this is wrong + + + + + +An experiment is a test that produces evidence supporting or refuting a hypothesis. + +**Good experiments**: +- Test one hypothesis at a time +- Have clear success/failure criteria +- Produce unambiguous results +- Are repeatable + +**Bad experiments**: +- Test multiple things at once +- Have unclear outcomes ("maybe it works better?") +- Rely on subjective judgment +- Can't be reproduced + + +For each hypothesis, design an experiment: + +**1. Prediction**: If hypothesis H is true, then I will observe X +**2. Test setup**: What do I need to do to test this? +**3. Measurement**: What exactly am I measuring? +**4. Success criteria**: What result confirms H? What result refutes H? +**5. Run the experiment**: Execute the test +**6. Observe the result**: Record what actually happened +**7. Conclude**: Does this support or refute H? + + + + +**Hypothesis**: "The component is re-rendering excessively because the parent is passing a new object reference on every render" + +**1. Prediction**: If true, the component will re-render even when the object's values haven't changed + +**2. Test setup**: + - Add console.log in component body to count renders + - Add console.log in parent to track when object is created + - Add useEffect with the object as dependency to log when it changes + +**3. Measurement**: Count of renders and object creations + +**4. Success criteria**: + - Confirms H: Component re-renders match parent renders, object reference changes each time + - Refutes H: Component only re-renders when object values actually change + +**5. Run**: Execute the code with logging + +**6. Observe**: + ``` + [Parent] Created user object + [Child] Rendering (1) + [Parent] Created user object + [Child] Rendering (2) + [Parent] Created user object + [Child] Rendering (3) + ``` + +**7. Conclude**: CONFIRMED. New object every parent render → child re-renders + + + + + +Not all evidence is equal. Learn to distinguish strong from weak evidence. + +**Strong evidence**: +- Directly observable ("I can see in the logs that X happens") +- Repeatable ("This fails every time I do Y") +- Unambiguous ("The value is definitely null, not undefined") +- Independent ("This happens even in a fresh browser with no cache") + +**Weak evidence**: +- Hearsay ("I think I saw this fail once") +- Non-repeatable ("It failed that one time but I can't reproduce it") +- Ambiguous ("Something seems off") +- Confounded ("It works after I restarted the server and cleared the cache and updated the package") + + +**Strong**: +```javascript +console.log('User ID:', userId); // Output: User ID: undefined +console.log('Type:', typeof userId); // Output: Type: undefined +``` +✅ Direct observation, unambiguous + +**Weak**: +"I think the user ID might not be set correctly sometimes" +❌ Vague, not verified, uncertain + +**Strong**: +```javascript +for (let i = 0; i < 100; i++) { + const result = processData(testData); + if (result !== expected) { + console.log('Failed on iteration', i); + } +} +// Output: Failed on iterations: 3, 7, 12, 23, 31... +``` +✅ Repeatable, shows pattern + +**Weak**: +"It usually works, but sometimes fails" +❌ Not quantified, no pattern identified + + + + + +Don't act too early (premature fix) or too late (analysis paralysis). + +**Act when you can answer YES to all**: + +1. **Do you understand the mechanism?** + - Not just "what fails" but "why it fails" + - Can you explain the chain of events that produces the bug? + +2. **Can you reproduce it reliably?** + - Either always reproduces, or you understand the conditions that trigger it + - If you can't reproduce, you don't understand it yet + +3. **Do you have evidence, not just theory?** + - You've observed the behavior directly + - You've logged the values, traced the execution + - You're not guessing + +4. **Have you ruled out alternatives?** + - You've considered other hypotheses + - Evidence contradicts the alternatives + - This is the most likely cause, not just the first idea + +**Don't act if**: +- "I think it might be X" - Too uncertain +- "This could be the issue" - Not confident enough +- "Let me try changing Y and see" - Random changes, not hypothesis-driven +- "I'll fix it and if it works, great" - Outcome-based, not understanding-based + + +**Too early** (don't act): +- Hypothesis: "Maybe the API is slow" +- Evidence: None, just a guess +- Action: Add caching +- Result: Bug persists, now you have caching to debug too + +**Right time** (act): +- Hypothesis: "API response is missing the 'status' field when user is inactive, causing the app to crash" +- Evidence: + - Logged API response for active user: has 'status' field + - Logged API response for inactive user: missing 'status' field + - Logged app behavior: crashes on accessing undefined status +- Action: Add defensive check for missing status field +- Result: Bug fixed because you understood the cause + + + + + +You will be wrong sometimes. This is normal. The skill is recovering gracefully. + +**When your hypothesis is disproven**: + +1. **Acknowledge it explicitly** + - "This hypothesis was wrong because [evidence]" + - Don't gloss over it or rationalize + - Intellectual honesty with yourself + +2. **Extract the learning** + - What did this experiment teach you? + - What did you rule out? + - What new information do you have? + +3. **Revise your understanding** + - Update your mental model + - What does the evidence actually suggest? + +4. **Form new hypotheses** + - Based on what you now know + - Avoid just moving to "second-guess" - use the evidence + +5. **Don't get attached to hypotheses** + - You're not your ideas + - Being wrong quickly is better than being wrong slowly + + +**Initial hypothesis**: "The memory leak is caused by event listeners not being cleaned up" + +**Experiment**: Check Chrome DevTools for listener counts +**Result**: Listener count stays stable, doesn't grow over time + +**Recovery**: +1. ✅ "Event listeners are NOT the cause. The count doesn't increase." +2. ✅ "I've ruled out event listeners as the culprit" +3. ✅ "But the memory profile shows objects accumulating. What objects? Let me check the heap snapshot..." +4. ✅ "New hypothesis: Large arrays are being cached and never released. Let me test by checking the heap for array sizes..." + +This is good debugging. Wrong hypothesis, quick recovery, better understanding. + + + + + +Don't fall in love with your first hypothesis. Generate multiple alternatives. + +**Strategy**: "Strong inference" - Design experiments that differentiate between competing hypotheses. + + +**Problem**: Form submission fails intermittently + +**Competing hypotheses**: +1. Network timeout +2. Validation failure +3. Race condition with auto-save +4. Server-side rate limiting + +**Design experiment that differentiates**: + +Add logging at each stage: +```javascript +try { + console.log('[1] Starting validation'); + const validation = await validate(formData); + console.log('[1] Validation passed:', validation); + + console.log('[2] Starting submission'); + const response = await api.submit(formData); + console.log('[2] Response received:', response.status); + + console.log('[3] Updating UI'); + updateUI(response); + console.log('[3] Complete'); +} catch (error) { + console.log('[ERROR] Failed at stage:', error); +} +``` + +**Observe results**: +- Fails at [2] with timeout error → Hypothesis 1 +- Fails at [1] with validation error → Hypothesis 2 +- Succeeds but [3] has wrong data → Hypothesis 3 +- Fails at [2] with 429 status → Hypothesis 4 + +**One experiment, differentiates between four hypotheses.** + + + + + +``` +1. Observe unexpected behavior + ↓ +2. Form specific hypotheses (plural) + ↓ +3. For each hypothesis: What would prove/disprove? + ↓ +4. Design experiment to test + ↓ +5. Run experiment + ↓ +6. Observe results + ↓ +7. Evaluate: Confirmed, refuted, or inconclusive? + ↓ +8a. If CONFIRMED → Design fix based on understanding +8b. If REFUTED → Return to step 2 with new hypotheses +8c. If INCONCLUSIVE → Redesign experiment or gather more data +``` + +**Key insight**: This is a loop, not a line. You'll cycle through multiple times. That's expected. + + + + + +**Pitfall: Testing multiple hypotheses at once** +- You change three things and it works +- Which one fixed it? You don't know +- Solution: Test one hypothesis at a time + +**Pitfall: Confirmation bias in experiments** +- You only look for evidence that confirms your hypothesis +- You ignore evidence that contradicts it +- Solution: Actively seek disconfirming evidence + +**Pitfall: Acting on weak evidence** +- "It seems like maybe this could be..." +- Solution: Wait for strong, unambiguous evidence + +**Pitfall: Not documenting results** +- You forget what you tested +- You repeat the same experiments +- Solution: Write down each hypothesis and its result + +**Pitfall: Giving up on the scientific method** +- Under pressure, you start making random changes +- "Let me just try this..." +- Solution: Double down on rigor when pressure increases + + + +**Great debuggers**: +- Form multiple competing hypotheses +- Design clever experiments that differentiate between them +- Follow the evidence wherever it leads +- Revise their beliefs when proven wrong +- Act only when they have strong evidence +- Understand the mechanism, not just the symptom + +This is the difference between guessing and debugging. + diff --git a/src/resources/skills/debug-like-expert/references/investigation-techniques.md b/src/resources/skills/debug-like-expert/references/investigation-techniques.md new file mode 100644 index 000000000..0078633db --- /dev/null +++ b/src/resources/skills/debug-like-expert/references/investigation-techniques.md @@ -0,0 +1,337 @@ + + +These are systematic approaches to narrowing down bugs. Each technique is a tool in your debugging toolkit. The skill is knowing which tool to use when. + + + + +**When to use**: Large codebase, long execution path, or many possible failure points. + +**How it works**: Cut the problem space in half repeatedly until you isolate the issue. + +**In practice**: + +1. **Identify the boundaries**: Where does it work? Where does it fail? +2. **Find the midpoint**: Add logging/testing at the middle of the execution path +3. **Determine which half**: Does the bug occur before or after the midpoint? +4. **Repeat**: Cut that half in half, test again +5. **Converge**: Keep halving until you find the exact line + + +Problem: API request returns wrong data + +1. Test: Does the data leave the database correctly? YES +2. Test: Does the data reach the frontend correctly? NO +3. Test: Does the data leave the API route correctly? YES +4. Test: Does the data survive serialization? NO +5. **Found it**: Bug is in the serialization layer + +You just eliminated 90% of the code in 4 tests. + + + + +**Variant**: Commenting out code to find the breaking change. + +1. Comment out the second half of a function +2. Does it work now? The bug is in the commented section +3. Uncomment half of that, repeat +4. Converge on the problematic lines + +**Warning**: Only works for code you can safely comment out. Don't use for initialization code. + + + + +**When to use**: You're stuck, confused, or your mental model doesn't match reality. + +**How it works**: Explain the problem out loud (to a rubber duck, a colleague, or in writing) in complete detail. + +**Why it works**: Articulating forces you to: +- Make assumptions explicit +- Notice gaps in your understanding +- Hear how convoluted your explanation sounds +- Realize what you haven't actually verified + +**In practice**: + +Write or say out loud: +1. "The system should do X" +2. "Instead it does Y" +3. "I think this is because Z" +4. "The code path is: A → B → C → D" +5. "I've verified that..." (List what you've actually tested) +6. "I'm assuming that..." (List assumptions) + +Often you'll spot the bug mid-explanation: "Wait, I never actually verified that B returns what I think it does." + + +"So when the user clicks the button, it calls handleClick, which dispatches an action, which... wait, does the reducer actually handle this action type? Let me check... Oh. The reducer is looking for 'UPDATE_USER' but I'm dispatching 'USER_UPDATE'." + + + + + +**When to use**: Complex system, many moving parts, unclear which part is failing. + +**How it works**: Strip away everything until you have the smallest possible code that reproduces the bug. + +**Why it works**: +- Removes distractions +- Isolates the actual issue +- Often reveals the bug during the stripping process +- Makes it easier to reason about + +**Process**: + +1. **Copy the failing code to a new file** +2. **Remove one piece** (a dependency, a function, a feature) +3. **Test**: Does it still reproduce? + - YES: Keep it removed, continue + - NO: Put it back, it's needed +4. **Repeat** until you have the bare minimum +5. **The bug is now obvious** in the stripped-down code + + +Start with: 500-line React component with 15 props, 8 hooks, 3 contexts + +End with: +```jsx +function MinimalRepro() { + const [count, setCount] = useState(0); + + useEffect(() => { + setCount(count + 1); // Bug: infinite loop, missing dependency array + }); + + return
{count}
; +} +``` + +The bug was hidden in complexity. Minimal reproduction made it obvious. +
+
+ + + +**When to use**: You know what the correct output should be, but don't know why you're not getting it. + +**How it works**: Start from the desired end state and trace backwards through the execution path. + +**Process**: + +1. **Define the desired output precisely** +2. **Ask**: What function produces this output? +3. **Test that function**: Give it the input it should receive. Does it produce correct output? + - YES: The bug is earlier (wrong input to this function) + - NO: The bug is here +4. **Repeat backwards** through the call stack +5. **Find the divergence point**: Where does expected vs actual first differ? + + +Problem: UI shows "User not found" when user exists + +Trace backwards: +1. UI displays: `user.error` → Is this the right value to display? YES +2. Component receives: `user.error = "User not found"` → Is this correct? NO, should be null +3. API returns: `{ error: "User not found" }` → Why? +4. Database query: `SELECT * FROM users WHERE id = 'undefined'` → AH! +5. **Found it**: The user ID is 'undefined' (string) instead of a number + +Working backwards revealed the bug was in how the ID was passed to the query. + + + + + +**When to use**: Something used to work and now doesn't. A feature works in one environment but not another. + +**How it works**: Compare the working vs broken states to find what's different. + +**Questions to ask**: + +**Time-based** (it worked, now it doesn't): +- What changed in the code since it worked? +- What changed in the environment? (Node version, OS, dependencies) +- What changed in the data? (Database schema, API responses) +- What changed in the configuration? + +**Environment-based** (works in dev, fails in prod): +- What's different between environments? +- Configuration values +- Environment variables +- Network conditions +- Data volume +- Third-party service behavior + +**Process**: + +1. **Make a list of differences** between working and broken +2. **Test each difference** in isolation +3. **Find the difference that causes the failure** +4. **That difference reveals the root cause** + + +Works locally, fails in CI: + +Differences: +- Node version: Same ✓ +- Environment variables: Same ✓ +- Timezone: Different! ✗ + +Test: Set local timezone to UTC (like CI) +Result: Now fails locally too + +**Found it**: Date comparison logic assumes local timezone + + + + + +**When to use**: Always. Before making any fix. + +**Why it matters**: You can't fix what you can't see. Add visibility before changing behavior. + +**Approaches**: + +**1. Strategic logging** +```javascript +// Not this (useless): +console.log('in function'); + +// This (useful): +console.log('[handleSubmit] Input:', { email, password: '***' }); +console.log('[handleSubmit] Validation result:', validationResult); +console.log('[handleSubmit] API response:', response); +``` + +**2. Assertion checks** +```javascript +function processUser(user) { + console.assert(user !== null, 'User is null!'); + console.assert(user.id !== undefined, 'User ID is undefined!'); + // ... rest of function +} +``` + +**3. Timing measurements** +```javascript +console.time('Database query'); +const result = await db.query(sql); +console.timeEnd('Database query'); +``` + +**4. Stack traces at key points** +```javascript +console.log('[updateUser] Called from:', new Error().stack); +``` + +**The workflow**: +1. **Add logging/instrumentation** at suspected points +2. **Run the code** +3. **Observe the output** +4. **Form hypothesis** based on what you see +5. **Only then** make changes + +Don't code in the dark. Light up the execution path first. + + + + +**When to use**: Many possible interactions, unclear which code is causing the issue. + +**How it works**: + +1. **Comment out everything** in a function/file +2. **Verify the bug is gone** +3. **Uncomment one piece at a time** +4. **After each uncomment, test** +5. **When the bug returns**, you found the culprit + +**Variant**: For config files, reset to defaults and add back one setting at a time. + + +Problem: Some middleware breaks requests, but you have 8 middleware functions. + +```javascript +app.use(helmet()); // Uncomment, test → works +app.use(cors()); // Uncomment, test → works +app.use(compression()); // Uncomment, test → works +app.use(bodyParser.json({ limit: '50mb' })); // Uncomment, test → BREAKS + +// Found it: Body size limit too high causes memory issues +``` + + + + + +**When to use**: Feature worked in the past, broke at some unknown commit. + +**How it works**: Binary search through git history to find the breaking commit. + +**Process**: + +```bash +git bisect start + +git bisect bad + +git bisect good abc123 + +git bisect bad + +git bisect good + +``` + +**Why it's powerful**: Turns "it broke sometime in the last 100 commits" into "it broke in commit abc123" in ~7 tests (log₂ 100 ≈ 7). + + +100 commits between working and broken +Manual testing: 100 commits to check +Git bisect: 7 commits to check + +Time saved: Massive + + + + + +**Large codebase, many files**: +→ Binary search / Divide and conquer + +**Confused about what's happening**: +→ Rubber duck debugging +→ Observability first (add logging) + +**Complex system with many interactions**: +→ Minimal reproduction + +**Know the desired output**: +→ Working backwards + +**Used to work, now doesn't**: +→ Differential debugging +→ Git bisect + +**Many possible causes**: +→ Comment out everything +→ Binary search + +**Always**: +→ Observability first before making changes + + + +Often you'll use multiple techniques together: + +1. **Differential debugging** to identify what changed +2. **Binary search** to narrow down where in the code +3. **Observability first** to add logging at that point +4. **Rubber duck** to articulate what you're seeing +5. **Minimal reproduction** to isolate just that behavior +6. **Working backwards** to find the root cause + +Techniques compose. Use as many as needed. + diff --git a/src/resources/skills/debug-like-expert/references/verification-patterns.md b/src/resources/skills/debug-like-expert/references/verification-patterns.md new file mode 100644 index 000000000..e3a75a21b --- /dev/null +++ b/src/resources/skills/debug-like-expert/references/verification-patterns.md @@ -0,0 +1,425 @@ + + +The most common debugging mistake: declaring victory too early. A fix isn't complete until it's verified. This document defines what "verified" means and provides systematic approaches to proving your fix works. + + + + +A fix is verified when: + +1. **The original issue no longer occurs** + - The exact reproduction steps now produce correct behavior + - Not "it seems better" - it definitively works + +2. **You understand why the fix works** + - You can explain the mechanism + - Not "I changed X and it worked" but "X was causing Y, and changing it prevents Y" + +3. **Related functionality still works** + - You haven't broken adjacent features + - Regression testing passes + +4. **The fix works across environments** + - Not just on your machine + - In production-like conditions + +5. **The fix is stable** + - Works consistently, not intermittently + - Not just "worked once" but "works reliably" + +**Anything less than this is not verified.** + + + +❌ **Not verified**: +- "I ran it once and it didn't crash" +- "It seems to work now" +- "The error message is gone" (but is the behavior correct?) +- "Works on my machine" + +✅ **Verified**: +- "I ran the original reproduction steps 20 times - zero failures" +- "The data now saves correctly and I can retrieve it" +- "All existing tests pass, plus I added a test for this scenario" +- "Verified in dev, staging, and production environments" + + + + +**The golden rule**: If you can't reproduce the bug, you can't verify it's fixed. + +**Process**: + +1. **Before fixing**: Document exact steps to reproduce + ```markdown + Reproduction steps: + 1. Login as admin user + 2. Navigate to /settings + 3. Click "Export Data" button + 4. Observe: Error "Cannot read property 'data' of undefined" + ``` + +2. **After fixing**: Execute the same steps exactly + ```markdown + Verification: + 1. Login as admin user ✓ + 2. Navigate to /settings ✓ + 3. Click "Export Data" button ✓ + 4. Observe: CSV downloads successfully ✓ + ``` + +3. **Test edge cases** related to the bug + ```markdown + Additional tests: + - Export with empty data set ✓ + - Export with 1000+ records ✓ + - Export while another request is pending ✓ + ``` + +**If you can't reproduce the original bug**: +- You don't know if your fix worked +- Maybe it's still broken +- Maybe your "fix" did nothing +- Maybe you fixed a different bug + +**Solution**: Revert your fix. If the bug comes back, you've verified your fix addressed it. + + + + +**The problem**: You fix one thing, break another. + +**Why it happens**: +- Your fix changed shared code +- Your fix had unintended side effects +- Your fix broke an assumption other code relied on + +**Protection strategy**: + +**1. Identify adjacent functionality** +- What else uses the code you changed? +- What features depend on this behavior? +- What workflows include this step? + +**2. Test each adjacent area** +- Manually test the happy path +- Check error handling +- Verify data integrity + +**3. Run existing tests** +- Unit tests for the module +- Integration tests for the feature +- End-to-end tests for the workflow + + +**Fix**: Changed how user sessions are stored (from memory to database) + +**Adjacent functionality to verify**: +- Login still works ✓ +- Logout still works ✓ +- Session timeout still works ✓ +- Concurrent logins are handled correctly ✓ +- Session data persists across server restarts ✓ (new capability) +- Password reset flow still works ✓ +- OAuth login still works ✓ + +If you only tested "login works", you missed 6 other things that could break. + + + + + +**Strategy**: Write a failing test that reproduces the bug, then fix until the test passes. + +**Benefits**: +- Proves you can reproduce the bug +- Provides automatic verification +- Prevents regression in the future +- Forces you to understand the bug precisely + +**Process**: + +1. **Write a test that reproduces the bug** + ```javascript + test('should handle undefined user data gracefully', () => { + const result = processUserData(undefined); + expect(result).toBe(null); // Currently throws error + }); + ``` + +2. **Verify the test fails** (confirms it reproduces the bug) + ``` + ✗ should handle undefined user data gracefully + TypeError: Cannot read property 'name' of undefined + ``` + +3. **Fix the code** + ```javascript + function processUserData(user) { + if (!user) return null; // Add defensive check + return user.name; + } + ``` + +4. **Verify the test passes** + ``` + ✓ should handle undefined user data gracefully + ``` + +5. **Test is now regression protection** + - If someone breaks this again, the test will catch it + +**When to use**: +- Clear, reproducible bugs +- Code that has test infrastructure +- Bugs that could recur + +**When not to use**: +- Exploratory debugging (you don't understand the bug yet) +- Infrastructure issues (can't easily test) +- One-off data issues + + + + +**The trap**: "Works on my machine" + +**Reality**: Production is different. + +**Differences to consider**: + +**Environment variables**: +- `NODE_ENV=development` vs `NODE_ENV=production` +- Different API keys +- Different database connections +- Different feature flags + +**Dependencies**: +- Different package versions (if not locked) +- Different system libraries +- Different Node/Python/etc versions + +**Data**: +- Volume (100 records locally, 1M in production) +- Quality (clean test data vs messy real data) +- Edge cases (nulls, special characters, extreme values) + +**Network**: +- Latency (local: 5ms, production: 200ms) +- Reliability (local: perfect, production: occasional failures) +- Firewalls, proxies, load balancers + +**Verification checklist**: +```markdown +- [ ] Works locally (dev environment) +- [ ] Works in Docker container (mimics production) +- [ ] Works in staging (production-like) +- [ ] Works in production (the real test) +``` + + +**Bug**: Batch processing fails in production but works locally + +**Investigation**: +- Local: 100 test records, completes in 2 seconds +- Production: 50,000 records, times out at 30 seconds + +**The difference**: Volume. Local testing didn't catch it. + +**Fix verification**: +- Test locally with 50,000 records +- Verify performance in staging +- Monitor first production run +- Confirm all environments work + + + + + +**The problem**: It worked once, but will it work reliably? + +**Intermittent bugs are the worst**: +- Hard to reproduce +- Hard to verify fixes +- Easy to declare fixed when they're not + +**Verification strategies**: + +**1. Repeated execution** +```bash +for i in {1..100}; do + npm test -- specific-test.js || echo "Failed on run $i" +done +``` + +If it fails even once, it's not fixed. + +**2. Stress testing** +```javascript +// Run many instances in parallel +const promises = Array(50).fill().map(() => + processData(testInput) +); + +const results = await Promise.all(promises); +// All results should be correct +``` + +**3. Soak testing** +- Run for extended period (hours, days) +- Monitor for memory leaks, performance degradation +- Ensure stability over time + +**4. Timing variations** +```javascript +// For race conditions, add random delays +async function testWithRandomTiming() { + await randomDelay(0, 100); + triggerAction1(); + await randomDelay(0, 100); + triggerAction2(); + await randomDelay(0, 100); + verifyResult(); +} + +// Run this 1000 times +``` + + +**Bug**: Race condition in file upload + +**Weak verification**: +- Upload one file +- "It worked!" +- Ship it + +**Strong verification**: +- Upload 100 files sequentially: all succeed ✓ +- Upload 20 files in parallel: all succeed ✓ +- Upload while navigating away: handles correctly ✓ +- Upload, cancel, upload again: works ✓ +- Run all tests 50 times: zero failures ✓ + +Now it's verified. + + + + + +Copy this checklist when verifying a fix: + +```markdown + +### Original Issue +- [ ] Can reproduce the original bug before the fix +- [ ] Have documented exact reproduction steps + +### Fix Validation +- [ ] Original reproduction steps now work correctly +- [ ] Can explain WHY the fix works +- [ ] Fix is minimal and targeted + +### Regression Testing +- [ ] Adjacent feature 1: [name] works +- [ ] Adjacent feature 2: [name] works +- [ ] Adjacent feature 3: [name] works +- [ ] Existing tests pass +- [ ] Added test to prevent regression + +### Environment Testing +- [ ] Works in development +- [ ] Works in staging/QA +- [ ] Works in production +- [ ] Tested with production-like data volume + +### Stability Testing +- [ ] Tested multiple times (n=__): zero failures +- [ ] Tested edge cases: [list them] +- [ ] Tested under load/stress: stable + +### Documentation +- [ ] Code comments explain the fix +- [ ] Commit message explains the root cause +- [ ] If needed, updated user-facing docs + +### Sign-off +- [ ] I understand why this bug occurred +- [ ] I understand why this fix works +- [ ] I've verified it works in all relevant environments +- [ ] I've tested for regressions +- [ ] I'm confident this won't recur +``` + +**Do not merge/deploy until all checkboxes are checked.** + + + + +Your verification might be wrong if: + +**1. You can't reproduce the original bug anymore** +- Maybe you forgot how +- Maybe the environment changed +- Maybe you're testing the wrong thing +- **Action**: Document reproduction steps FIRST, before fixing + +**2. The fix is large or complex** +- Changed 10 files, modified 200 lines +- Too many moving parts +- **Action**: Simplify the fix, then verify each piece + +**3. You're not sure why it works** +- "I changed X and the bug went away" +- But you can't explain the mechanism +- **Action**: Investigate until you understand, then verify + +**4. It only works sometimes** +- "Usually works now" +- "Seems more stable" +- **Action**: Not verified. Find and fix the remaining issue + +**5. You can't test in production-like conditions** +- Only tested locally +- Different data, different scale +- **Action**: Set up staging environment or use production data in dev + +**Red flag phrases**: +- "It seems to work" +- "I think it's fixed" +- "Looks good to me" +- "Can't reproduce anymore" (but you never could reliably) + +**Trust-building phrases**: +- "I've verified 50 times - zero failures" +- "All tests pass including new regression test" +- "Deployed to staging, tested for 3 days, no issues" +- "Root cause was X, fix addresses X directly, verified by Y" + + + + +**Assume your fix is wrong until proven otherwise.** + +This isn't pessimism - it's professionalism. + +**Questions to ask yourself**: +- "How could this fix fail?" +- "What haven't I tested?" +- "What am I assuming?" +- "Would this survive production?" + +**The cost of insufficient verification**: +- Bug returns in production +- User frustration +- Lost trust +- Emergency debugging sessions +- Rollbacks + +**The benefit of thorough verification**: +- Confidence in deployment +- Prevention of regressions +- Trust from team +- Learning from the investigation + +**Verification is not optional. It's the most important part of debugging.** + diff --git a/src/resources/skills/debug-like-expert/references/when-to-research.md b/src/resources/skills/debug-like-expert/references/when-to-research.md new file mode 100644 index 000000000..c1849c39c --- /dev/null +++ b/src/resources/skills/debug-like-expert/references/when-to-research.md @@ -0,0 +1,361 @@ + + +Debugging requires both reasoning about code and researching external knowledge. The skill is knowing when to use each. This guide helps you recognize signals that indicate you need external knowledge vs when you can reason through the problem with the code in front of you. + + + + + +**1. Error messages you don't recognize** +- Stack traces from libraries you haven't used +- Cryptic system errors +- Framework-specific error codes + +**Action**: Web search the exact error message in quotes +- Often leads to GitHub issues, Stack Overflow, or official docs +- Others have likely encountered this + + +Error: `EADDRINUSE: address already in use :::3000` + +This is a system-level error. Research it: +- Web search: "EADDRINUSE address already in use" +- Learn: Port is already occupied by another process +- Solution: Find and kill the process, or use different port + + +**2. Library/framework behavior doesn't match expectations** +- You're using a library correctly (you think) but it's not working +- Documentation seems to contradict behavior +- Version-specific quirks + +**Action**: Check official documentation and recent issues +- Use Context7 MCP for library docs +- Search GitHub issues for the library +- Check if there are breaking changes in recent versions + + +You're using `useEffect` in React but it's running on every render despite empty dependency array. + +Research needed: +- Check React docs for useEffect rules +- Search: "useEffect running on every render" +- Discover: React 18 StrictMode runs effects twice in dev mode + + +**3. Domain knowledge gaps** +- Debugging authentication: need to understand OAuth flow +- Debugging database: need to understand indexes, query optimization +- Debugging networking: need to understand HTTP caching, CORS + +**Action**: Research the domain concept, not just the specific bug +- Use MCP servers for domain knowledge +- Read official specifications +- Find authoritative guides + +**4. Platform-specific behavior** +- "Works in Chrome but not Safari" +- "Works on Mac but not Windows" +- "Works in Node 16 but not Node 18" + +**Action**: Research platform differences +- Browser compatibility tables +- Platform-specific documentation +- Known platform bugs + +**5. Recent changes in ecosystem** +- Package update broke something +- New framework version behaves differently +- Deprecated API + +**Action**: Check changelogs and migration guides +- Library CHANGELOG.md +- Migration guides +- "Breaking changes" documentation + + + + + + +**1. The bug is in YOUR code** +- Not library behavior, not system issues +- Your business logic, your data structures +- Code you or your team wrote + +**Approach**: Read the code, trace execution, add logging +- You have full access to the code +- You can modify it to add observability +- No external documentation will help + + +Bug: Shopping cart total calculates incorrectly + +This is your logic: +```javascript +function calculateTotal(items) { + return items.reduce((sum, item) => sum + item.price * item.quantity, 0); +} +``` + +Don't research "shopping cart calculation bugs" +DO reason through it: +- Log each item's price and quantity +- Log the running sum +- Trace the logic step by step + + +**2. You have all the information needed** +- The bug is reproducible +- You can read all relevant code +- No external dependencies involved + +**Approach**: Use investigation techniques +- Binary search to narrow down +- Minimal reproduction +- Working backwards +- Add observability + +**3. It's a logic error, not a knowledge gap** +- Off-by-one errors +- Wrong conditional +- State management issue +- Data transformation bug + +**Approach**: Trace the logic carefully +- Print intermediate values +- Check assumptions +- Verify each step + +**4. The answer is in the behavior, not the documentation** +- "What is this function actually doing?" +- "Why is this value null?" +- "When does this code execute?" + +**Approach**: Observe the actual behavior +- Add logging +- Use a debugger +- Test with different inputs + + + + + + +**Web Search - When and How** + +**When**: +- Error messages +- Library-specific questions +- "How to X in framework Y" +- Troubleshooting platform issues + +**How**: +- Use exact error messages in quotes: `"Cannot read property 'map' of undefined"` +- Include framework/library version: `"react 18 useEffect behavior"` +- Add "github issue" for known bugs: `"prisma connection pool github issue"` +- Add year for recent changes: `"nextjs 14 middleware 2024"` + +**Good search queries**: +- `"ECONNREFUSED" node.js postgres` +- `"Maximum update depth exceeded" react hooks` +- `typescript generic constraints examples` + +**Bad search queries**: +- `my code doesn't work` (too vague) +- `bug in react` (too broad) +- `help` (useless) + +**Context7 MCP - When and How** + +**When**: +- Need API reference +- Understanding library concepts +- Finding specific function signatures +- Learning correct usage patterns + +**How**: +``` +Use mcp__context7__resolve-library-id with library name +Then mcp__context7__get-library-docs with library ID +Ask specific questions about the library +``` + +**Good uses**: +- "How do I use Prisma transactions?" +- "What are the parameters for stripe.customers.create?" +- "How does Express middleware error handling work?" + +**Bad uses**: +- "Fix my bug" (too vague, Context7 provides docs not debugging) +- "Why isn't my code working?" (need to research specific concepts, not general debugging) + +**GitHub Issues Search** + +**When**: +- Experiencing behavior that seems like a bug +- Library not working as documented +- Looking for workarounds + +**How**: +- Search in the library's GitHub repo +- Include relevant keywords +- Check both open and closed issues +- Look for issues with "bug" or "regression" labels + +**Official Documentation** + +**When**: +- Learning how something should work +- Checking if you're using API correctly +- Understanding configuration options +- Finding migration guides + +**How**: +- Start with official docs, not blog posts +- Check version-specific docs +- Read examples and guides, not just API reference +- Look for "Common Pitfalls" or "Troubleshooting" sections + + + + + + +**The research trap**: Spending hours reading docs about topics tangential to your bug +- You think it's a caching issue, so you read all about cache invalidation +- But the actual bug is a typo in a variable name + +**The reasoning trap**: Spending hours reading code when the answer is well-documented +- You're debugging why auth doesn't work +- The docs clearly explain the setup you missed +- You could have found it in 5 minutes of reading + +**The balance**: + +1. **Start with quick research** (5-10 minutes) + - Search the error message + - Check official docs for the feature you're using + - Skim recent issues + +2. **If research doesn't yield answers, switch to reasoning** + - Add logging + - Trace execution + - Form hypotheses + +3. **If reasoning reveals knowledge gaps, research those specific gaps** + - "I need to understand how WebSocket reconnection works" + - "I need to know if this library supports transactions" + +4. **Alternate as needed** + - Research → reveals what to investigate + - Reasoning → reveals what to research + - Keep switching based on what you learn + + +**Bug**: Real-time updates stop working after 1 hour + +**Start with research** (5 min): +- Search: "websocket connection drops after 1 hour" +- Find: Common issue with load balancers having connection timeouts + +**Switch to reasoning**: +- Check if you're using a load balancer: YES +- Check load balancer timeout setting: 3600 seconds (1 hour) +- Hypothesis: Load balancer is killing the connection + +**Quick research**: +- Search: "websocket load balancer timeout fix" +- Find: Implement heartbeat/ping to keep connection alive + +**Reasoning**: +- Check if library supports heartbeat: YES +- Implement ping every 30 seconds +- Test: Connection stays alive for 3+ hours + +**Total time**: 20 minutes (research: 10 min, reasoning: 10 min) +**Success**: Found and fixed the issue + +vs + +**Wrong approach**: Spend 2 hours reading WebSocket spec +- Learned a lot about WebSocket protocol +- Didn't solve the problem (it was a config issue) + + + + + + +``` +Is this a error message I don't recognize? +├─ YES → Web search the error message +└─ NO ↓ + +Is this library/framework behavior I don't understand? +├─ YES → Check docs (Context7 or official docs) +└─ NO ↓ + +Is this code I/my team wrote? +├─ YES → Reason through it (logging, tracing, hypothesis testing) +└─ NO ↓ + +Is this a platform/environment difference? +├─ YES → Research platform-specific behavior +└─ NO ↓ + +Can I observe the behavior directly? +├─ YES → Add observability and reason through it +└─ NO → Research the domain/concept first, then reason +``` + + + + + +**You're researching too much if**: +- You've read 20 blog posts but haven't looked at your code +- You understand the theory but haven't traced your actual execution +- You're learning about edge cases that don't apply to your situation +- You've been reading for 30+ minutes without testing anything + +**You're reasoning too much if**: +- You've been staring at code for an hour without progress +- You keep finding things you don't understand and guessing +- You're debugging library internals (that's research territory) +- The error message is clearly from a library you don't know + +**You're doing it right if**: +- You alternate between research and reasoning +- Each research session answers a specific question +- Each reasoning session tests a specific hypothesis +- You're making steady progress toward understanding + + + + + + +**Good researchers ask**: +- "What specific question do I need answered?" +- "Where is the authoritative source for this?" +- "Is this a known issue or unique to my code?" +- "What version-specific information do I need?" + +**Good reasoners ask**: +- "What is actually happening in my code?" +- "What am I assuming that might be wrong?" +- "How can I observe this behavior directly?" +- "What experiment would test my hypothesis?" + +**Great debuggers do both**: +- Research to fill knowledge gaps +- Reason to understand actual behavior +- Switch fluidly based on what they learn +- Never stuck in one mode + +**The goal**: Minimum time to maximum understanding. +- Research what you don't know +- Reason through what you can observe +- Fix what you understand + diff --git a/src/resources/skills/frontend-design/SKILL.md b/src/resources/skills/frontend-design/SKILL.md new file mode 100644 index 000000000..f709fde71 --- /dev/null +++ b/src/resources/skills/frontend-design/SKILL.md @@ -0,0 +1,45 @@ +--- +name: frontend-design +description: Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, artifacts, posters, or applications (examples include websites, landing pages, dashboards, React components, HTML/CSS layouts, or when styling/beautifying any web UI). Generates creative, polished code and UI design that avoids generic AI aesthetics. +license: Complete terms in LICENSE.txt +--- + +This skill guides creation of distinctive, production-grade frontend interfaces that avoid generic "AI slop" aesthetics. Implement real working code with exceptional attention to aesthetic details and creative choices. + +The user provides frontend requirements: a component, page, application, or interface to build. They may include context about the purpose, audience, or technical constraints. + +## Design Thinking + +Before coding, understand the context and commit to a BOLD aesthetic direction: + +- **Purpose**: What problem does this interface solve? Who uses it? +- **Tone**: Pick an extreme: brutally minimal, maximalist chaos, retro-futuristic, organic/natural, luxury/refined, playful/toy-like, editorial/magazine, brutalist/raw, art deco/geometric, soft/pastel, industrial/utilitarian, etc. There are so many flavors to choose from. Use these for inspiration but design one that is true to the aesthetic direction. +- **Constraints**: Technical requirements (framework, performance, accessibility). +- **Differentiation**: What makes this UNFORGETTABLE? What's the one thing someone will remember? + +**CRITICAL**: Choose a clear conceptual direction and execute it with precision. Bold maximalism and refined minimalism both work - the key is intentionality, not intensity. + +Then implement working code (HTML/CSS/JS, React, Vue, etc.) that is: + +- Production-grade and functional +- Visually striking and memorable +- Cohesive with a clear aesthetic point-of-view +- Meticulously refined in every detail + +## Frontend Aesthetics Guidelines + +Focus on: + +- **Typography**: Choose fonts that are beautiful, unique, and interesting. Avoid generic fonts like Arial and Inter; opt instead for distinctive choices that elevate the frontend's aesthetics; unexpected, characterful font choices. Pair a distinctive display font with a refined body font. +- **Color & Theme**: Commit to a cohesive aesthetic. Use CSS variables for consistency. Dominant colors with sharp accents outperform timid, evenly-distributed palettes. +- **Motion**: Use animations for effects and micro-interactions. Prioritize CSS-only solutions for HTML. Use Motion library for React when available. Focus on high-impact moments: one well-orchestrated page load with staggered reveals (animation-delay) creates more delight than scattered micro-interactions. Use scroll-triggering and hover states that surprise. +- **Spatial Composition**: Unexpected layouts. Asymmetry. Overlap. Diagonal flow. Grid-breaking elements. Generous negative space OR controlled density. +- **Backgrounds & Visual Details**: Create atmosphere and depth rather than defaulting to solid colors. Add contextual effects and textures that match the overall aesthetic. Apply creative forms like gradient meshes, noise textures, geometric patterns, layered transparencies, dramatic shadows, decorative borders, custom cursors, and grain overlays. + +NEVER use generic AI-generated aesthetics like overused font families (Inter, Roboto, Arial, system fonts), cliched color schemes (particularly purple gradients on white backgrounds), predictable layouts and component patterns, and cookie-cutter design that lacks context-specific character. + +Interpret creatively and make unexpected choices that feel genuinely designed for the context. No design should be the same. Vary between light and dark themes, different fonts, different aesthetics. NEVER converge on common choices (Space Grotesk, for example) across generations. + +**IMPORTANT**: Match implementation complexity to the aesthetic vision. Maximalist designs need elaborate code with extensive animations and effects. Minimalist or refined designs need restraint, precision, and careful attention to spacing, typography, and subtle details. Elegance comes from executing the vision well. + +Remember: Claude is capable of extraordinary creative work. Don't hold back, show what can truly be created when thinking outside the box and committing fully to a distinctive vision. diff --git a/src/resources/skills/swiftui/SKILL.md b/src/resources/skills/swiftui/SKILL.md new file mode 100644 index 000000000..4ce6f5b00 --- /dev/null +++ b/src/resources/skills/swiftui/SKILL.md @@ -0,0 +1,208 @@ +--- +name: swiftui +description: SwiftUI apps from scratch through App Store. Full lifecycle - create, debug, test, optimize, ship. +--- + + +## 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. + + + +## 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. + + + +**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.** + + + +| 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 | + + + +## 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]" + + + +## 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 +``` + + + +## 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 + + + +## 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 | + + + +## 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) + diff --git a/src/resources/skills/swiftui/references/animations.md b/src/resources/skills/swiftui/references/animations.md new file mode 100644 index 000000000..0d2d84e8d --- /dev/null +++ b/src/resources/skills/swiftui/references/animations.md @@ -0,0 +1,921 @@ + +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 + + + +## 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) + + + +## 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() +} +``` + + + +## 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 + + + +## 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) + + + +## 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 + + + +## 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 + + + +## 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 + + + +## 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 + } + } + ) + } +} +``` + + + +## 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 + + + +## What NOT to Do + + +**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) +``` + + + +**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 +} +``` + + + +**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) +``` + + + +**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 +} +``` + + + +**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() + } +} +``` + + + +**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) +``` + + + +**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) +``` + + diff --git a/src/resources/skills/swiftui/references/architecture.md b/src/resources/skills/swiftui/references/architecture.md new file mode 100644 index 000000000..84c800559 --- /dev/null +++ b/src/resources/skills/swiftui/references/architecture.md @@ -0,0 +1,1561 @@ + +SwiftUI architecture determines how you organize code, manage dependencies, and structure your app for maintainability and scalability. This file covers architectural patterns, project organization, and design decisions. + +**Read this when:** +- Starting a new SwiftUI project +- Deciding between MVVM, TCA, or other patterns +- Structuring a growing codebase +- Setting up dependency injection +- Organizing features into modules + +**Related files:** +- state-management.md - State ownership and data flow within architectures +- navigation.md - Navigation patterns for different architectures +- networking-async.md - Async operations and where they fit architecturally +- swiftdata.md - Persistence layer integration with architecture + + + +## Available Approaches + + + + + + + + + + + +## Choosing the Right Approach + +**If building a simple app (under 10 screens, minimal business logic):** Use SwiftUI Native because architectural overhead isn't justified and SwiftUI's built-in patterns are sufficient. + +**If using SwiftData extensively:** Use SwiftUI Native or consider Clean Architecture. Avoid MVVM because @Query requires views to manage data directly, conflicting with ViewModel patterns. + +**If you need testability and moderate complexity (10-30 screens):** Use MVVM with @Observable because it provides clean separation, is industry-standard, and offers excellent testability with minimal overhead. + +**If you have complex state management, navigation, or side effects:** Consider The Composable Architecture because its unidirectional data flow and built-in effect handling excel at managing complexity. + +**If building a large app with multiple teams:** Use Clean Architecture with Feature Modules because it enforces boundaries, enables parallel development, and improves build times through modularization. + +**If team is unfamiliar with iOS architectures:** Start with MVVM because it's the most widely understood pattern and has abundant learning resources. + +**If prototyping or validating product-market fit:** Use SwiftUI Native because you can ship fastest and refactor to MVVM or Clean Architecture later when requirements stabilize. + +**Default recommendation:** MVVM with @Observable for most production apps because it balances simplicity, testability, and scalability. Migrate to Clean Architecture with modules only when team size or app complexity demands it. + +**Avoid MVVM when:** Using SwiftData heavily, or building very simple apps where the architectural overhead slows development without providing value. + +**Avoid TCA when:** Team lacks Redux experience, building simple CRUD apps, or working in large multi-team environments where TCA's scaling limitations may surface. + + + +## Common Patterns + + +**Use when:** Need compile-time safe dependency injection without manual container setup + +Factory is the current recommended DI library for Swift (2024). Import the library as "FactoryKit" to avoid naming conflicts. + +**Implementation:** +```swift +// 1. Install Factory via SPM +// https://github.com/hmlongco/Factory +// Add package, select "FactoryKit" library + +// 2. Define container with factories +import FactoryKit + +extension Container { + var apiClient: Factory { + Factory(self) { APIClient(baseURL: "https://api.example.com") } + } + + var userRepository: Factory { + Factory(self) { UserRepository(apiClient: self.apiClient()) } + } + + var userListViewModel: Factory { + Factory(self) { + UserListViewModel(userRepository: self.userRepository()) + } + .scope(.shared) // Singleton if needed + } +} + +// 3. Inject in ViewModels using @Injected +@Observable +@MainActor +class UserListViewModel { + @ObservationIgnored @Injected(\.userRepository) + private var userRepository: UserRepositoryProtocol + + var users: [User] = [] + + func loadUsers() async { + users = try await userRepository.fetchUsers() + } +} + +// 4. Inject in Views using @Injected +struct UserListView: View { + @State private var viewModel = Container.shared.userListViewModel() + + var body: some View { + List(viewModel.users) { user in + Text(user.name) + } + } +} + +// 5. Override for testing +extension Container { + var mockUserRepository: Factory { + Factory(self) { MockUserRepository() } + } +} +``` + +**Considerations:** +- Use @ObservationIgnored for @Injected properties inside @Observable classes +- Factory 2.5+ supports Swift 6 strict concurrency +- Scopes: .singleton, .shared, .cached, .graph, .unique +- Register mock factories in test targets for easy testing + + + +**Use when:** Need SwiftUI-native dependency injection without external libraries + +**Implementation:** +```swift +// 1. Define dependency key +struct UserRepositoryKey: EnvironmentKey { + static let defaultValue: UserRepositoryProtocol = UserRepository() +} + +extension EnvironmentValues { + var userRepository: UserRepositoryProtocol { + get { self[UserRepositoryKey.self] } + set { self[UserRepositoryKey.self] = newValue } + } +} + +// 2. Provide dependency at app root +@main +struct MyApp: App { + var body: some Scene { + WindowGroup { + ContentView() + .environment(\.userRepository, UserRepository()) + } + } +} + +// 3. Inject in ViewModels +@Observable +@MainActor +class UserListViewModel { + var users: [User] = [] + private let userRepository: UserRepositoryProtocol + + init(userRepository: UserRepositoryProtocol) { + self.userRepository = userRepository + } + + func loadUsers() async { + users = try await userRepository.fetchUsers() + } +} + +// 4. Access in Views +struct UserListView: View { + @Environment(\.userRepository) private var userRepository + @State private var viewModel: UserListViewModel + + init() { + // Can't access @Environment in init - use onAppear workaround + _viewModel = State(initialValue: UserListViewModel( + userRepository: UserRepository() // temporary + )) + } + + var body: some View { + List(viewModel.users) { user in + Text(user.name) + } + .onAppear { + // Replace with environment-injected dependency + viewModel = UserListViewModel(userRepository: userRepository) + Task { await viewModel.loadUsers() } + } + } +} +``` + +**Considerations:** +- SwiftUI-native approach (no external dependencies) +- Can't access @Environment in init - requires workaround +- Better for simple apps; Factory scales better for complex DI + + + +**Use when:** Abstracting data sources (API, database, cache) from business logic + +**Implementation:** +```swift +// Protocol in Domain layer +protocol UserRepositoryProtocol { + func fetchUsers() async throws -> [User] + func getUser(id: UUID) async throws -> User + func saveUser(_ user: User) async throws + func deleteUser(id: UUID) async throws +} + +// Implementation in Data layer +class UserRepository: UserRepositoryProtocol { + private let apiClient: APIClient + private let cache: CacheService + + init(apiClient: APIClient, cache: CacheService) { + self.apiClient = apiClient + self.cache = cache + } + + func fetchUsers() async throws -> [User] { + // Check cache first + if let cached: [User] = cache.get(key: "users") { + return cached + } + + // Fetch from API + let dtos: [UserDTO] = try await apiClient.get("/users") + let users = dtos.map { $0.toDomain() } + + // Update cache + cache.set(key: "users", value: users) + + return users + } + + func getUser(id: UUID) async throws -> User { + let dto: UserDTO = try await apiClient.get("/users/\(id)") + return dto.toDomain() + } + + func saveUser(_ user: User) async throws { + let dto = UserDTO(from: user) + try await apiClient.post("/users", body: dto) + + // Invalidate cache + cache.remove(key: "users") + } + + func deleteUser(id: UUID) async throws { + try await apiClient.delete("/users/\(id)") + cache.remove(key: "users") + } +} + +// DTO for API mapping +struct UserDTO: Codable { + let id: UUID + let name: String + let email: String + + func toDomain() -> User { + User(id: id, name: name, email: email) + } + + init(from user: User) { + self.id = user.id + self.name = user.name + self.email = user.email + } +} +``` + +**Considerations:** +- Repository owns caching strategy +- DTOs map between API and domain models +- Protocol enables easy mocking for tests +- Keeps networking details out of ViewModels + + + +**Use when:** Need to toggle features without app updates + +**Implementation:** +```swift +// Feature flag service +@Observable +@MainActor +class FeatureFlagService { + private(set) var flags: [String: Bool] = [:] + + func isEnabled(_ feature: FeatureFlag) -> Bool { + flags[feature.rawValue] ?? feature.defaultValue + } + + func enable(_ feature: FeatureFlag) { + flags[feature.rawValue] = true + } + + func disable(_ feature: FeatureFlag) { + flags[feature.rawValue] = false + } + + func loadRemoteFlags() async { + // Fetch from remote config service + // Update flags dictionary + } +} + +enum FeatureFlag: String { + case newUserProfile = "new_user_profile" + case darkModeToggle = "dark_mode_toggle" + case experimentalSearch = "experimental_search" + + var defaultValue: Bool { + switch self { + case .newUserProfile: return false + case .darkModeToggle: return true + case .experimentalSearch: return false + } + } +} + +// Use in views +struct ContentView: View { + @Environment(\.featureFlags) private var featureFlags + + var body: some View { + VStack { + if featureFlags.isEnabled(.newUserProfile) { + NewUserProfileView() + } else { + LegacyUserProfileView() + } + } + } +} + +// Environment setup +struct FeatureFlagKey: EnvironmentKey { + static let defaultValue = FeatureFlagService() +} + +extension EnvironmentValues { + var featureFlags: FeatureFlagService { + get { self[FeatureFlagKey.self] } + set { self[FeatureFlagKey.self] = newValue } + } +} +``` + +**Considerations:** +- Load remote flags at app startup +- Use @MainActor for thread-safe access +- Feature flags enable A/B testing and gradual rollouts + + + +**Use when:** Need centralized navigation control separate from views + +**Implementation:** +```swift +// Navigation coordinator +@Observable +@MainActor +class AppCoordinator { + var navigationPath = NavigationPath() + var presentedSheet: SheetDestination? + + func push(_ destination: Destination) { + navigationPath.append(destination) + } + + func pop() { + navigationPath.removeLast() + } + + func popToRoot() { + navigationPath = NavigationPath() + } + + func present(_ sheet: SheetDestination) { + presentedSheet = sheet + } + + func dismiss() { + presentedSheet = nil + } +} + +enum Destination: Hashable { + case userDetail(User) + case settings + case editProfile +} + +enum SheetDestination: Identifiable { + case addUser + case filter + + var id: String { + switch self { + case .addUser: return "addUser" + case .filter: return "filter" + } + } +} + +// Root view with coordinator +struct RootView: View { + @State private var coordinator = AppCoordinator() + + var body: some View { + NavigationStack(path: $coordinator.navigationPath) { + UserListView(coordinator: coordinator) + .navigationDestination(for: Destination.self) { destination in + switch destination { + case .userDetail(let user): + UserDetailView(user: user, coordinator: coordinator) + case .settings: + SettingsView(coordinator: coordinator) + case .editProfile: + EditProfileView(coordinator: coordinator) + } + } + } + .sheet(item: $coordinator.presentedSheet) { sheet in + switch sheet { + case .addUser: + AddUserView(coordinator: coordinator) + case .filter: + FilterView(coordinator: coordinator) + } + } + } +} + +// Views use coordinator for navigation +struct UserListView: View { + let coordinator: AppCoordinator + + var body: some View { + List { + ForEach(users) { user in + Button(user.name) { + coordinator.push(.userDetail(user)) + } + } + } + .toolbar { + Button("Add") { + coordinator.present(.addUser) + } + } + } +} +``` + +**Considerations:** +- Coordinator owns all navigation state +- Testable - can verify navigation logic independently +- Works well with deep linking +- See navigation.md for more patterns + + + + +## What NOT to Do + + +**Problem:** Putting all feature logic into a single ViewModel class + +```swift +// DON'T: Massive ViewModel with 1000+ lines +@Observable +@MainActor +class UserViewModel { + // User list logic + var users: [User] = [] + func loadUsers() async { } + func deleteUser() { } + + // User detail logic + var selectedUser: User? + func loadUserDetails() { } + + // Settings logic + var notificationsEnabled = false + func saveSettings() { } + + // Profile editing logic + var editedName = "" + var editedEmail = "" + func updateProfile() { } + + // Search logic + var searchQuery = "" + var searchResults: [User] = [] + func search() { } + + // ... 900 more lines +} +``` + +**Why it's bad:** +- Hard to test (must mock entireViewModel for one feature) +- Poor cohesion (unrelated concerns mixed together) +- Difficult to navigate and understand +- High merge conflict risk in teams + +**Instead:** Create feature-specific ViewModels + +```swift +// DO: Separate ViewModels per feature +@Observable +@MainActor +class UserListViewModel { + var users: [User] = [] + var isLoading = false + + func loadUsers() async { } + func deleteUser(_ user: User) { } +} + +@Observable +@MainActor +class UserDetailViewModel { + var user: User + var isEditing = false + + init(user: User) { + self.user = user + } + + func loadDetails() async { } +} + +@Observable +@MainActor +class UserSearchViewModel { + var query = "" + var results: [User] = [] + + func search() async { } +} +``` + + + +**Problem:** Still using the old ObservableObject protocol with @Published + +```swift +// DON'T: Old ObservableObject pattern (pre-iOS 17) +class UserViewModel: ObservableObject { + @Published var users: [User] = [] + @Published var isLoading = false + @Published var errorMessage: String? +} + +struct UserListView: View { + @StateObject private var viewModel = UserViewModel() + // ... +} +``` + +**Why it's bad:** +- Worse performance (all @Published changes trigger view updates) +- More boilerplate (@Published everywhere) +- Forces use of @StateObject instead of @State +- Triggers unnecessary view redraws + +**Instead:** Use @Observable macro (iOS 17+) + +```swift +// DO: Modern @Observable pattern +import Observation + +@Observable +@MainActor +class UserViewModel { + var users: [User] = [] + var isLoading = false + var errorMessage: String? +} + +struct UserListView: View { + @State private var viewModel = UserViewModel() + + var body: some View { + // Only redraws when properties accessed in body change + List(viewModel.users) { user in + Text(user.name) + } + } +} +``` + +**Migration note:** If supporting iOS 16 or earlier, ObservableObject is still required. Use @Observable for iOS 17+ only projects. + + + +**Problem:** Trying to use @Query inside a ViewModel + +```swift +// DON'T: @Query doesn't work in ViewModels +@Observable +@MainActor +class UserViewModel { + @Query var users: [User] // ERROR: @Query only works in Views +} +``` + +**Why it's bad:** +- @Query requires SwiftUI view context +- Creates compile errors +- Forces awkward workarounds + +**Instead:** Use @Query directly in views or avoid MVVM with SwiftData + +```swift +// DO: Use @Query in views directly +struct UserListView: View { + @Query(sort: \User.name) private var users: [User] + + var body: some View { + List(users) { user in + Text(user.name) + } + } +} + +// OR: Use ModelContext directly in ViewModel if you need MVVM +@Observable +@MainActor +class UserViewModel { + private let modelContext: ModelContext + var users: [User] = [] + + init(modelContext: ModelContext) { + self.modelContext = modelContext + } + + func loadUsers() { + let descriptor = FetchDescriptor( + sortBy: [SortDescriptor(\User.name)] + ) + users = (try? modelContext.fetch(descriptor)) ?? [] + } +} +``` + +**Best approach:** If using SwiftData heavily, prefer SwiftUI Native architecture over MVVM. + + + +**Problem:** Creating new ViewModel instance on every view redraw + +```swift +// DON'T: Creates new instance every redraw +struct UserListView: View { + @State private var viewModel = UserListViewModel() // Wrong! + + var body: some View { + List(viewModel.users) { user in + Text(user.name) + } + } +} +``` + +**Why it seems wrong:** Looks like it creates new instance on every redraw + +**Why it's actually correct:** @State caches the instance across redraws. The initializer only runs on first render. + +**Key insight:** With @Observable and @State, the apparent anti-pattern is actually the correct pattern. Unlike @StateObject (which requires @escaping closure workaround), @State with @Observable caches the value automatically. + +**Still avoid:** +```swift +// DON'T: Creating without @State +struct UserListView: View { + private let viewModel = UserListViewModel() // Wrong - recreated every time +} + +// DON'T: Using @StateObject with @Observable +struct UserListView: View { + @StateObject private var viewModel = UserListViewModel() // Wrong - use @State +} +``` + +**Do:** +```swift +// DO: Use @State with @Observable +struct UserListView: View { + @State private var viewModel = UserListViewModel() +} + +// DO: Or inject from parent +struct UserListView: View { + let viewModel: UserListViewModel +} +``` + + + +**Problem:** Trying to access @Environment values in view initializer + +```swift +// DON'T: Can't access @Environment in init +struct UserListView: View { + @Environment(\.userRepository) private var userRepository + @State private var viewModel: UserListViewModel + + init() { + // ERROR: Can't access userRepository here + _viewModel = State(initialValue: UserListViewModel( + userRepository: userRepository + )) + } +} +``` + +**Why it's bad:** +- @Environment not available until view is in the hierarchy +- Causes compile errors or runtime crashes +- Requires awkward workarounds + +**Instead:** Use Factory for DI or pass dependencies explicitly + +```swift +// DO: Use Factory for clean DI +import FactoryKit + +extension Container { + var userRepository: Factory { + Factory(self) { UserRepository() } + } +} + +@Observable +@MainActor +class UserListViewModel { + @ObservationIgnored @Injected(\.userRepository) + private var userRepository +} + +struct UserListView: View { + @State private var viewModel = UserListViewModel() + // userRepository injected automatically by Factory +} + +// OR: Pass from parent that has access +struct ParentView: View { + @Environment(\.userRepository) private var userRepository + + var body: some View { + UserListView( + viewModel: UserListViewModel(userRepository: userRepository) + ) + } +} +``` + + + +**Problem:** Creating one massive dependency container that knows about everything + +```swift +// DON'T: God object with all dependencies +class AppDependencies { + let apiClient: APIClient + let userRepository: UserRepository + let postRepository: PostRepository + let authService: AuthService + let cacheService: CacheService + let analyticsService: AnalyticsService + let pushService: PushService + let locationService: LocationService + // ... 50 more dependencies + + init() { + // Complex initialization graph + } +} + +// Passed everywhere +struct UserListView: View { + let dependencies: AppDependencies +} +``` + +**Why it's bad:** +- Views depend on entire app graph (not just what they need) +- Hard to test (must construct entire AppDependencies) +- Poor encapsulation +- Merge conflicts on AppDependencies class + +**Instead:** Use Factory with containers or inject specific dependencies + +```swift +// DO: Factory with automatic resolution +extension Container { + var userRepository: Factory { + Factory(self) { UserRepository(apiClient: self.apiClient()) } + } +} + +@Observable +@MainActor +class UserListViewModel { + @ObservationIgnored @Injected(\.userRepository) + private var userRepository // Only knows about userRepository +} + +// Or inject specific dependencies +struct UserListView: View { + @State private var viewModel: UserListViewModel + + init(userRepository: UserRepositoryProtocol) { + _viewModel = State(initialValue: UserListViewModel( + userRepository: userRepository + )) + } +} +``` + + + + +## Recommended Project Structure + +### Small Apps (5-15 screens, single developer) + +``` +MyApp/ +├── MyApp.swift # @main App entry point +├── Models/ +│ ├── User.swift +│ ├── Post.swift +│ └── Comment.swift +├── Views/ +│ ├── UserList/ +│ │ ├── UserListView.swift +│ │ └── UserRowView.swift +│ ├── UserDetail/ +│ │ └── UserDetailView.swift +│ └── Settings/ +│ └── SettingsView.swift +├── ViewModels/ # If using MVVM +│ ├── UserListViewModel.swift +│ └── UserDetailViewModel.swift +├── Services/ +│ ├── APIClient.swift +│ └── CacheService.swift +├── Utilities/ +│ ├── Extensions.swift +│ └── Constants.swift +└── Resources/ + ├── Assets.xcassets + └── Localizable.strings +``` + +**Key principles:** +- Flat structure with minimal nesting +- Group by feature (UserList, UserDetail) not layer +- ViewModels folder only if using MVVM +- Services for shared business logic + +### Medium Apps (15-50 screens, 2-5 developers) + +``` +MyApp/ +├── MyApp.swift +├── App/ +│ ├── DependencyContainer.swift +│ └── AppCoordinator.swift +├── Features/ +│ ├── Authentication/ +│ │ ├── Views/ +│ │ │ ├── LoginView.swift +│ │ │ └── SignupView.swift +│ │ ├── ViewModels/ +│ │ │ └── AuthViewModel.swift +│ │ └── Models/ +│ │ └── AuthState.swift +│ ├── UserList/ +│ │ ├── Views/ +│ │ │ ├── UserListView.swift +│ │ │ └── UserRowView.swift +│ │ ├── ViewModels/ +│ │ │ └── UserListViewModel.swift +│ │ └── Models/ +│ │ └── User.swift +│ ├── UserDetail/ +│ │ ├── Views/ +│ │ ├── ViewModels/ +│ │ └── Models/ +│ └── Settings/ +│ ├── Views/ +│ └── ViewModels/ +├── Core/ +│ ├── Networking/ +│ │ ├── APIClient.swift +│ │ ├── Endpoint.swift +│ │ └── NetworkError.swift +│ ├── Persistence/ +│ │ └── CacheService.swift +│ ├── Extensions/ +│ │ ├── View+Extensions.swift +│ │ └── String+Extensions.swift +│ └── UI/ +│ ├── LoadingView.swift +│ └── ErrorView.swift +└── Resources/ + ├── Assets.xcassets + └── Localizable.strings +``` + +**Key principles:** +- Features folder with clear feature modules +- Each feature has Views/ViewModels/Models subfolders +- Core folder for shared infrastructure +- App folder for composition root +- Still single-target Xcode project + +### Large Apps (50+ screens, 5+ developers, multi-platform) + +Use Swift Package Manager with modular architecture: + +``` +MyApp/ +├── App/ +│ ├── MyApp/ +│ │ ├── MyApp.swift +│ │ ├── DependencyContainer.swift +│ │ └── AppCoordinator.swift +│ └── MyApp.xcodeproj +├── Packages/ +│ ├── Domain/ +│ │ ├── Package.swift +│ │ └── Sources/Domain/ +│ │ ├── Models/ +│ │ │ ├── User.swift +│ │ │ └── Post.swift +│ │ └── Repositories/ +│ │ ├── UserRepositoryProtocol.swift +│ │ └── PostRepositoryProtocol.swift +│ ├── Data/ +│ │ ├── Package.swift +│ │ └── Sources/Data/ +│ │ ├── Repositories/ +│ │ │ ├── UserRepository.swift +│ │ │ └── PostRepository.swift +│ │ ├── Networking/ +│ │ │ ├── APIClient.swift +│ │ │ └── DTOs/ +│ │ └── Persistence/ +│ │ └── CoreDataStack.swift +│ ├── FeatureUserList/ +│ │ ├── Package.swift +│ │ └── Sources/FeatureUserList/ +│ │ ├── Views/ +│ │ │ ├── UserListView.swift +│ │ │ └── UserRowView.swift +│ │ └── ViewModels/ +│ │ └── UserListViewModel.swift +│ ├── FeatureUserDetail/ +│ │ ├── Package.swift +│ │ └── Sources/FeatureUserDetail/ +│ │ └── ... +│ ├── FeatureSettings/ +│ │ ├── Package.swift +│ │ └── Sources/FeatureSettings/ +│ │ └── ... +│ ├── CoreUI/ +│ │ ├── Package.swift +│ │ └── Sources/CoreUI/ +│ │ ├── Components/ +│ │ │ ├── LoadingView.swift +│ │ │ └── ErrorView.swift +│ │ ├── Extensions/ +│ │ └── Theme/ +│ └── CoreUtilities/ +│ ├── Package.swift +│ └── Sources/CoreUtilities/ +│ ├── Extensions/ +│ └── Logging/ +└── Tests/ + ├── DomainTests/ + ├── DataTests/ + ├── FeatureUserListTests/ + └── ... +``` + +**Package.swift example (FeatureUserList):** +```swift +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "FeatureUserList", + platforms: [.iOS(.v17), .macOS(.v14)], + products: [ + .library( + name: "FeatureUserList", + targets: ["FeatureUserList"] + ), + ], + dependencies: [ + .package(path: "../Domain"), + .package(path: "../CoreUI"), + ], + targets: [ + .target( + name: "FeatureUserList", + dependencies: [ + "Domain", + "CoreUI", + ] + ), + .testTarget( + name: "FeatureUserListTests", + dependencies: ["FeatureUserList"] + ), + ] +) +``` + +**Dependency hierarchy (bottom to top):** +``` +App (top level - depends on everything) +├── Feature modules (depend on Domain, CoreUI, CoreUtilities) +├── Data (depends on Domain) +├── Domain (no dependencies - pure Swift) +├── CoreUI (depends on CoreUtilities) +└── CoreUtilities (no dependencies - pure Swift) +``` + +**Key principles:** +- Features are isolated SPM packages +- Each package can be opened and worked on independently +- Faster builds (only changed packages rebuild) +- Domain layer has no framework dependencies (pure Swift) +- Data layer implements Domain protocols +- Features depend only on Domain (not on each other) +- App layer composes everything + +**Benefits:** +- Teams work on separate packages with minimal conflicts +- Removing features = delete package folder +- Faster SwiftUI previews (don't build unrelated code) +- Enforced architectural boundaries via dependencies +- Testable in isolation + +**When to modularize:** +- More than 50 screens +- More than 5 developers +- Multiple platforms (iOS, macOS, watchOS) +- When build times exceed 2-3 minutes + + +## Sources + +- [Hacking with Swift: MVVM in SwiftUI](https://www.hackingwithswift.com/books/ios-swiftui/introducing-mvvm-into-your-swiftui-project) +- [Medium: Modern MVVM in SwiftUI 2025](https://medium.com/@minalkewat/modern-mvvm-in-swiftui-2025-the-clean-architecture-youve-been-waiting-for-72a7d576648e) +- [SwiftLee: MVVM Architectural Pattern](https://www.avanderlee.com/swiftui/mvvm-architectural-coding-pattern-to-structure-views/) +- [Medium: SwiftUI in 2025 - Forget MVVM](https://dimillian.medium.com/swiftui-in-2025-forget-mvvm-262ff2bbd2ed) +- [Alexey Naumov: Clean Architecture for SwiftUI](https://nalexn.github.io/clean-architecture-swiftui/) +- [Medium: 2025's Best SwiftUI Architecture](https://medium.com/@minalkewat/2025s-best-swiftui-architecture-mvvm-clean-feature-modules-3a369a22858c) +- [GitHub: Clean Architecture SwiftUI](https://github.com/nalexn/clean-architecture-swiftui) +- [Medium: MVVM with Organized Folder Structures](https://medium.com/@rogeriocpires_128/implementing-mvvm-in-swiftui-with-organized-folder-structures-bc86845eead8) +- [mokacoding: Dependency Injection in SwiftUI](https://mokacoding.com/blog/swiftui-dependency-injection/) +- [Lucas van Dongen: Managing Dependencies in SwiftUI](https://lucasvandongen.dev/dependency_injection_swift_swiftui.php) +- [GitHub: Factory - Swift Dependency Injection](https://github.com/hmlongco/Factory) +- [Jesse Squires: @Observable Macro Deep Dive](https://www.jessesquires.com/blog/2024/09/09/swift-observable-macro/) +- [SwiftLee: @Observable Performance](https://www.avanderlee.com/swiftui/observable-macro-performance-increase-observableobject/) +- [Apple: Migrating to @Observable](https://developer.apple.com/documentation/swiftui/migrating-from-the-observable-object-protocol-to-the-observable-macro) +- [Donny Wals: @Observable Explained](https://www.donnywals.com/observable-in-swiftui-explained/) +- [Nimble: Modularizing iOS Apps with SwiftUI and SPM](https://nimblehq.co/blog/modern-approach-modularize-ios-swiftui-spm) +- [Medium: Building Large-Scale Apps with SwiftUI](https://azamsharp.medium.com/building-large-scale-apps-with-swiftui-a-guide-to-modular-architecture-9c967be13001) +- [Medium: Modular SwiftUI Architecture](https://medium.com/@pavel-holec/swiftui-modular-architecture-9bb1647b70b8) +- [Better Programming: Factory Dependency Injection](https://betterprogramming.pub/factory-swift-dependency-injection-14da9b2b5d09) +- [Lucas van Dongen: DI Frameworks Compared](https://lucasvandongen.dev/di_frameworks_compared.php) +- [Medium: App vs Scene Protocol](https://medium.com/@ksjadhav2699/swiftui-app-vs-scene-protocol-1022e655a1fc) +- [Swift with Majid: Managing App in SwiftUI](https://swiftwithmajid.com/2020/08/19/managing-app-in-swiftui/) +- [GitHub: Swift Composable Architecture](https://github.com/pointfreeco/swift-composable-architecture) +- [InfoQ: Swift Composable Architecture](https://www.infoq.com/news/2024/08/swift-composable-architecture/) +- [Rod Schmidt: TCA 3 Year Experience](https://rodschmidt.com/posts/composable-architecture-experience/) diff --git a/src/resources/skills/swiftui/references/layout-system.md b/src/resources/skills/swiftui/references/layout-system.md new file mode 100644 index 000000000..369f19b24 --- /dev/null +++ b/src/resources/skills/swiftui/references/layout-system.md @@ -0,0 +1,1186 @@ + +SwiftUI's layout system operates fundamentally differently from UIKit/Auto Layout. Instead of constraints, SwiftUI uses a **propose-measure-place** model: + +1. **Propose**: Parent offers child a size +2. **Measure**: Child chooses its own size (parent must respect this) +3. **Place**: Parent positions child in its coordinate space + +This creates a declarative, predictable layout system where conflicts are impossible. SwiftUI always produces a valid layout. + +**Read this file when:** +- Choosing between layout containers (HStack, VStack, Grid, etc.) +- Dealing with complex positioning requirements +- Performance tuning layouts with large datasets +- Understanding GeometryReader usage and alternatives + +**See also:** +- `performance.md` for layout performance optimization strategies +- `architecture.md` for structuring complex view hierarchies + + + +## Layout Containers + + +**Purpose:** Horizontal arrangement of views from left to right (or right to left in RTL languages) + +**Behavior:** Proposes equal width to all children, then distributes remaining space based on flexibility. Children choose their own heights. + +**Alignment:** Default is `.center` vertically. Options: `.top`, `.center`, `.bottom`, `.firstTextBaseline`, `.lastTextBaseline` + +**Spacing:** Default is system-defined (typically 8pt). Override with `spacing:` parameter or `.none` for zero spacing. + +```swift +// Common usage with custom spacing and alignment +HStack(alignment: .top, spacing: 12) { + Image(systemName: "person.circle") + .font(.largeTitle) + + VStack(alignment: .leading, spacing: 4) { + Text("Username") + .font(.headline) + Text("Online") + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() // Pushes content to leading edge + + Button("Follow") { } +} +.padding() +``` + +**Performance:** Lightweight. All children are created immediately (not lazy). + + + +**Purpose:** Vertical arrangement of views from top to bottom + +**Behavior:** Proposes equal height to all children, then distributes remaining space. Children choose their own widths. + +**Alignment:** Default is `.center` horizontally. Options: `.leading`, `.center`, `.trailing` + +**Spacing:** Default is system-defined. Override with `spacing:` parameter. + +```swift +// Card layout with multiple sections +VStack(alignment: .leading, spacing: 16) { + Text("Title") + .font(.headline) + + Text("Body text that can span multiple lines and will wrap naturally within the available width.") + .font(.body) + .foregroundStyle(.secondary) + + HStack { + Spacer() + Button("Action") { } + } +} +.padding() +.background(.background.secondary) +.clipShape(RoundedRectangle(cornerRadius: 12)) +``` + +**Performance:** Lightweight. All children are created immediately. + + + +**Purpose:** Layering views on the Z-axis (depth), drawing from back to front + +**Behavior:** Proposes full available size to all children. Final size is the union of all child sizes. Later views draw on top of earlier views. + +**Alignment:** Default is `.center` both horizontally and vertically. Options include `.topLeading`, `.bottomTrailing`, etc. + +```swift +// Profile picture with badge +ZStack(alignment: .bottomTrailing) { + AsyncImage(url: profileURL) { image in + image + .resizable() + .scaledToFill() + } placeholder: { + Color.gray + } + .frame(width: 100, height: 100) + .clipShape(Circle()) + + // Notification badge + Circle() + .fill(.red) + .frame(width: 24, height: 24) + .overlay { + Text("3") + .font(.caption2.bold()) + .foregroundStyle(.white) + } + .offset(x: 4, y: 4) +} +``` + +**Performance:** Lightweight. Avoid excessive layering for complex effects (use `.overlay()` or `.background()` instead when appropriate). + + + +**Purpose:** Vertical stack with deferred view creation - only creates views when they scroll into view + +**When to use:** +- Lists with hundreds or thousands of items +- Items with expensive initialization (images, complex views) +- Memory-constrained scenarios + +**Difference from VStack:** +- VStack creates all children immediately +- LazyVStack creates children on-demand as they appear +- LazyVStack requires a ScrollView parent +- LazyVStack calculates layout incrementally + +```swift +ScrollView { + LazyVStack(spacing: 12, pinnedViews: [.sectionHeaders]) { + Section { + ForEach(items) { item in + ItemRow(item: item) + .frame(height: 60) + } + } header: { + Text("Section Header") + .font(.headline) + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + .background(.ultraThinMaterial) + } + } +} +``` + +**Performance:** +- Superior for long lists (only renders visible views) +- Slight overhead per view creation +- Use `.id()` modifier to control view identity/reuse + +**See also:** LazyHStack for horizontal lazy loading + + + +**Purpose:** Grid layouts with lazy loading - creates cells only when visible + +**GridItem types:** +- `.fixed(width)`: Exactly the specified width +- `.flexible(minimum:maximum:)`: Grows to fill space within bounds +- `.adaptive(minimum:maximum:)`: Creates as many columns as fit + +```swift +// Photo grid with adaptive columns +ScrollView { + LazyVGrid( + columns: [GridItem(.adaptive(minimum: 120, maximum: 200))], + spacing: 12 + ) { + ForEach(photos) { photo in + AsyncImage(url: photo.url) { image in + image + .resizable() + .scaledToFill() + } placeholder: { + Color.gray.opacity(0.3) + } + .frame(height: 120) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + } + .padding() +} + +// Fixed column layout (3 columns) +let columns = [ + GridItem(.flexible()), + GridItem(.flexible()), + GridItem(.flexible()) +] + +LazyVGrid(columns: columns, spacing: 16) { + ForEach(items) { item in + ItemCard(item: item) + } +} +``` + +**Performance:** +- Only creates visible cells (significant memory savings) +- Best for image galleries, product catalogs, large datasets +- On macOS, performance may drop below UIKit CollectionView for very large datasets (see performance.md) +- Consider pagination for datasets over 1000 items + +**When NOT to use:** Small grids (< 20 items) - use Grid instead for simpler code + + + +**Purpose:** Non-lazy grid with explicit row/column control and advanced alignment + +**When to use:** +- Small datasets where all items can be in memory +- Need precise row/column control +- Need GridRow for custom row styling +- Need alignment across cells in different rows + +**Difference from LazyVGrid:** +- Grid creates all cells immediately +- Grid gives more layout control (GridRow, cell spanning) +- Grid supports baseline alignment across rows +- Grid is simpler for static content + +```swift +// Form-like layout with alignment +Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) { + GridRow { + Text("Name:") + .gridColumnAlignment(.trailing) + TextField("Enter name", text: $name) + } + + GridRow { + Text("Email:") + .gridColumnAlignment(.trailing) + TextField("Enter email", text: $email) + } + + GridRow { + Color.clear + .gridCellUnsizedAxes(.horizontal) + + Button("Submit") { } + .gridCellColumns(1) + } +} +.padding() + +// Spanning cells +Grid { + GridRow { + Text("Header") + .gridCellColumns(3) // Spans 3 columns + .font(.headline) + } + + GridRow { + ForEach(1...3, id: \.self) { num in + Text("Cell \(num)") + } + } +} +``` + +**Performance:** All cells created immediately. Keep under 100 items or use LazyVGrid. + + + + +## GeometryReader + +**Purpose:** Access parent's proposed size and safe area insets for custom layout calculations + +**When to use:** +- Custom drawing or graphics that need exact dimensions +- Complex animations requiring precise positioning +- Creating custom layout effects not possible with standard containers +- Reading coordinate spaces for gesture calculations + +**When NOT to use:** +- Simple relative sizing → use `.frame(maxWidth: .infinity)` or `Spacer()` +- Container-relative frames → use `containerRelativeFrame()` (iOS 17+) +- Adaptive layouts → use `ViewThatFits` (iOS 16+) +- Safe area queries → use safe area modifiers directly + +```swift +// Correct usage: Custom circular progress +GeometryReader { geometry in + ZStack { + Circle() + .stroke(Color.gray.opacity(0.3), lineWidth: 10) + + Circle() + .trim(from: 0, to: progress) + .stroke(Color.blue, style: StrokeStyle(lineWidth: 10, lineCap: .round)) + .rotationEffect(.degrees(-90)) + .animation(.easeInOut, value: progress) + } + .frame(width: geometry.size.width, height: geometry.size.width) + .frame(maxWidth: .infinity, maxHeight: .infinity) +} +.aspectRatio(1, contentMode: .fit) + +// Correct usage: Reading coordinate spaces +struct DragView: View { + @State private var location: CGPoint = .zero + + var body: some View { + GeometryReader { geometry in + Circle() + .fill(.blue) + .frame(width: 50, height: 50) + .position(location) + .gesture( + DragGesture(coordinateSpace: .named("container")) + .onChanged { value in + location = value.location + } + ) + } + .coordinateSpace(name: "container") + } +} +``` + +**Pitfalls:** + +1. **Expands to fill all available space**: GeometryReader acts like `Color.clear.frame(maxWidth: .infinity, maxHeight: .infinity)`, which breaks layouts in ScrollViews and can cause infinite height calculations + +2. **Breaks ScrollView behavior**: Inside ScrollView, GeometryReader tries to fill infinite space, causing layout loops and broken scrolling + +3. **Overused for simple tasks**: Most GeometryReader usage can be replaced with simpler solutions + +4. **Deep nesting causes unpredictable behavior**: Nested GeometryReaders compound layout issues + +5. **Performance overhead**: Rebuilds view on every geometry change + +**Alternatives to GeometryReader:** + +```swift +// ❌ BAD: Using GeometryReader for container-relative sizing +GeometryReader { geometry in + Rectangle() + .frame(width: geometry.size.width * 0.5) +} + +// ✅ GOOD: Use containerRelativeFrame (iOS 17+) +Rectangle() + .containerRelativeFrame(.horizontal) { width, _ in + width * 0.5 + } + +// ✅ GOOD: Use frame modifiers +Rectangle() + .frame(maxWidth: .infinity) + .padding(.horizontal, .infinity) // Creates 50% width + +// ❌ BAD: GeometryReader for adaptive layouts +GeometryReader { geometry in + if geometry.size.width > 600 { + HStack { content } + } else { + VStack { content } + } +} + +// ✅ GOOD: Use ViewThatFits (iOS 16+) +ViewThatFits { + HStack { content } + VStack { content } +} +``` + +**Using GeometryReader safely:** + +```swift +// Use in .background() or .overlay() to avoid affecting layout +Text("Hello") + .background( + GeometryReader { geometry in + Color.clear + .onAppear { + print("Size: \(geometry.size)") + } + } + ) +``` + +**See also:** `performance.md` for GeometryReader performance considerations + + + +## Custom Layout Protocol (iOS 16+) + +**When to use:** +- Standard containers cannot achieve the desired layout +- Flow/tag layouts (wrapping items like a text paragraph) +- Radial/circular arrangements +- Custom grid behaviors (masonry, Pinterest-style) +- Complex alignment requirements across multiple views + +**Protocol requirements:** +1. `sizeThatFits(proposal:subviews:cache:)`: Calculate and return container size +2. `placeSubviews(in:proposal:subviews:cache:)`: Position each subview + +**Optional:** +- `makeCache(subviews:)`: Create shared computation cache +- `updateCache(_:subviews:)`: Update cache when subviews change +- `explicitAlignment(of:in:proposal:subviews:cache:)`: Define custom alignment guides + +```swift +// Complete FlowLayout example (tag cloud, wrapping items) +struct FlowLayout: Layout { + var spacing: CGFloat = 8 + + func sizeThatFits( + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout Cache + ) -> CGSize { + let rows = computeRows(proposal: proposal, subviews: subviews) + + let width = proposal.replacingUnspecifiedDimensions().width + let height = rows.reduce(0) { $0 + $1.height + spacing } - spacing + + return CGSize(width: width, height: height) + } + + func placeSubviews( + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout Cache + ) { + let rows = computeRows(proposal: proposal, subviews: subviews) + + var y = bounds.minY + for row in rows { + var x = bounds.minX + + for index in row.subviewIndices { + let subview = subviews[index] + let size = subview.sizeThatFits(.unspecified) + + subview.place( + at: CGPoint(x: x, y: y), + proposal: ProposedViewSize(size) + ) + + x += size.width + spacing + } + + y += row.height + spacing + } + } + + // Cache structure for performance + struct Cache { + var rows: [Row] = [] + } + + struct Row { + var subviewIndices: [Int] + var height: CGFloat + } + + private func computeRows( + proposal: ProposedViewSize, + subviews: Subviews + ) -> [Row] { + let width = proposal.replacingUnspecifiedDimensions().width + var rows: [Row] = [] + var currentRow: [Int] = [] + var currentX: CGFloat = 0 + var currentHeight: CGFloat = 0 + + for (index, subview) in subviews.enumerated() { + let size = subview.sizeThatFits(.unspecified) + + if currentX + size.width > width && !currentRow.isEmpty { + // Start new row + rows.append(Row(subviewIndices: currentRow, height: currentHeight)) + currentRow = [] + currentX = 0 + currentHeight = 0 + } + + currentRow.append(index) + currentX += size.width + spacing + currentHeight = max(currentHeight, size.height) + } + + if !currentRow.isEmpty { + rows.append(Row(subviewIndices: currentRow, height: currentHeight)) + } + + return rows + } +} + +// Usage +FlowLayout(spacing: 12) { + ForEach(tags, id: \.self) { tag in + Text(tag) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(.blue.opacity(0.2)) + .clipShape(Capsule()) + } +} + +// Radial layout example +struct RadialLayout: Layout { + func sizeThatFits( + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout () + ) -> CGSize { + proposal.replacingUnspecifiedDimensions() + } + + func placeSubviews( + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout () + ) { + let radius = min(bounds.width, bounds.height) / 2.5 + let center = CGPoint(x: bounds.midX, y: bounds.midY) + let angle = (2 * .pi) / Double(subviews.count) + + for (index, subview) in subviews.enumerated() { + let theta = angle * Double(index) - .pi / 2 + let x = center.x + radius * cos(theta) + let y = center.y + radius * sin(theta) + + subview.place( + at: CGPoint(x: x, y: y), + anchor: .center, + proposal: .unspecified + ) + } + } +} +``` + +**Use cases:** +- **Flow/tag layout**: Wrapping items like tags, badges, or chips +- **Radial layout**: Circular menu, dial controls +- **Masonry grid**: Pinterest-style uneven grid +- **Custom calendar**: Week views with variable heights +- **Waterfall layout**: Staggered grid with varying item heights + +**See also:** +- Apple's official documentation: [Composing custom layouts with SwiftUI](https://developer.apple.com/documentation/swiftui/composing_custom_layouts_with_swiftui) +- `performance.md` for Layout protocol caching strategies + + + +## Alignment and Alignment Guides + +**Built-in alignments:** +- **Vertical**: `.leading`, `.center`, `.trailing` +- **Horizontal**: `.top`, `.center`, `.bottom`, `.firstTextBaseline`, `.lastTextBaseline` + +**How alignment works:** When containers like HStack or VStack align children, they use alignment guides. Each view exposes alignment guide values, and the container aligns those values across children. + +**Custom alignment guides:** + +```swift +// Define custom vertical alignment +extension VerticalAlignment { + private struct MidAccountAndName: AlignmentID { + static func defaultValue(in context: ViewDimensions) -> CGFloat { + context[VerticalAlignment.center] + } + } + + static let midAccountAndName = VerticalAlignment(MidAccountAndName.self) +} + +// Use custom alignment +HStack(alignment: .midAccountAndName) { + VStack(alignment: .trailing) { + Text("Full Name:") + Text("Address:") + Text("Account Number:") + .alignmentGuide(.midAccountAndName) { d in + d[VerticalAlignment.center] + } + } + + VStack(alignment: .leading) { + Text("John Doe") + Text("123 Main St") + Text("98765-4321") + .alignmentGuide(.midAccountAndName) { d in + d[VerticalAlignment.center] + } + } +} + +// Adjusting alignment dynamically +Image(systemName: "arrow.up") + .alignmentGuide(.leading) { d in + d[.leading] - 50 // Shift left by 50 points + } +``` + +**Preference keys for custom alignment:** + +Preference keys allow child views to communicate layout information up the hierarchy. + +```swift +// Define preference key for collecting bounds +struct BoundsPreferenceKey: PreferenceKey { + static var defaultValue: [String: Anchor] = [:] + + static func reduce( + value: inout [String: Anchor], + nextValue: () -> [String: Anchor + + +## Safe Area Handling + +SwiftUI respects safe areas by default (avoiding notches, home indicator, status bar). + +**Three key modifiers:** + +### `ignoresSafeArea(_:edges:)` +**When to use:** Extend backgrounds or images edge-to-edge while keeping content safe + +```swift +// Background that extends to edges +ZStack { + Color.blue + .ignoresSafeArea() // Goes edge-to-edge + + VStack { + Text("Content") + Spacer() + } + .padding() // Content stays in safe area +} + +// Ignore only specific edges +ScrollView { + content +} +.ignoresSafeArea(.container, edges: .bottom) + +// Ignore keyboard safe area +TextField("Message", text: $message) + .ignoresSafeArea(.keyboard) +``` + +**Regions:** +- `.container`: Device edges, status bar, home indicator +- `.keyboard`: Soft keyboard area +- `.all`: Both container and keyboard + +**Edges:** `.top`, `.bottom`, `.leading`, `.trailing`, `.horizontal`, `.vertical`, `.all` + +### `safeAreaInset(edge:alignment:spacing:content:)` +**When to use:** Add custom bars (toolbars, tab bars) that shrink the safe area for other content + +```swift +// Custom bottom toolbar +ScrollView { + ForEach(items) { item in + ItemRow(item: item) + } +} +.safeAreaInset(edge: .bottom, spacing: 0) { + HStack { + Button("Action 1") { } + Spacer() + Button("Action 2") { } + } + .padding() + .background(.ultraThinMaterial) +} + +// Multiple insets stack +ScrollView { + content +} +.safeAreaInset(edge: .top) { + SearchBar() +} +.safeAreaInset(edge: .bottom) { + BottomBar() +} + +// Inset with custom alignment +List(messages) { message in + MessageRow(message: message) +} +.safeAreaInset(edge: .bottom, alignment: .trailing) { + Button(action: compose) { + Image(systemName: "plus.circle.fill") + .font(.largeTitle) + } + .padding() +} +``` + +**Key behavior:** Unlike `ignoresSafeArea`, this **shrinks** the safe area so other views avoid it. + +### `safeAreaPadding(_:_:)` (iOS 17+) +**When to use:** Extend safe area by a fixed amount without providing a view + +```swift +// Add padding to safe area +ScrollView { + content +} +.safeAreaPadding(.horizontal, 20) +.safeAreaPadding(.bottom, 60) + +// Equivalent to safeAreaInset but cleaner when you don't need a view +``` + +**Difference from `.padding()`:** +- `.padding()` adds space but doesn't affect safe area calculations +- `.safeAreaPadding()` extends the safe area itself + +### Accessing safe area values + +```swift +// Read safe area insets +GeometryReader { geometry in + let safeArea = geometry.safeAreaInsets + + VStack { + Text("Top: \(safeArea.top)") + Text("Bottom: \(safeArea.bottom)") + } +} +``` + +**See also:** [Managing safe area in SwiftUI](https://swiftwithmajid.com/2021/11/03/managing-safe-area-in-swiftui/) + + + +## Choosing the Right Layout + +**Simple horizontal arrangement:** +- Use `HStack` with alignment and spacing parameters +- Use `Spacer()` to push content to edges + +**Simple vertical arrangement:** +- Use `VStack` with alignment and spacing parameters +- Consider `LazyVStack` if list exceeds 50+ items + +**Overlapping views:** +- Use `ZStack` for basic layering +- Use `.overlay()` or `.background()` for single overlay/underlay +- Consider Custom Layout for complex Z-ordering logic + +**Long scrolling list:** +- Use `LazyVStack` inside `ScrollView` for variable content +- Use `List` for standard iOS list appearance with built-in features (swipe actions, separators) +- Vertical scrolling: `ScrollView { LazyVStack { } }` +- Horizontal scrolling: `ScrollView(.horizontal) { LazyHStack { } }` + +**Grid of items:** +- **Small grid (< 20 items):** Use `Grid` for full control +- **Large grid:** Use `LazyVGrid` or `LazyHGrid` +- **Fixed columns:** `LazyVGrid(columns: [GridItem(.flexible()), ...])` +- **Adaptive columns:** `LazyVGrid(columns: [GridItem(.adaptive(minimum: 120))])` + +**Need parent size:** +- **iOS 17+:** Use `containerRelativeFrame()` for size relative to container +- **iOS 16+:** Use `ViewThatFits` for adaptive layouts +- **Custom drawing/gestures:** Use `GeometryReader` sparingly +- **Simple fills:** Use `.frame(maxWidth: .infinity)` + +**Adaptive layout (changes based on space):** +- Use `ViewThatFits` (iOS 16+) to switch between layouts +- Use size classes with `@Environment(\.horizontalSizeClass)` + +**Complex custom layout:** +- Implement Custom Layout protocol (iOS 16+) +- Use for: flow layouts, radial layouts, masonry grids +- Provides full control over sizing and positioning + +**Performance considerations:** + +| Scenario | Recommendation | Reason | +|----------|---------------|--------| +| Static grid < 20 items | Grid | Simpler, all layout upfront | +| Dynamic list 50+ items | LazyVStack | Only renders visible | +| Photo gallery 100+ items | LazyVGrid | Memory efficient | +| Constantly changing list | LazyVStack with `.id()` | Controls view identity | +| macOS high FPS requirement | UIKit/AppKit wrapper | SwiftUI grids cap at ~90fps | +| Complex nesting 5+ levels | Custom Layout | Better control, fewer containers | + +**See also:** `performance.md` for detailed performance tuning strategies + + + +## What NOT to Do + + +**Problem:** Using GeometryReader when simpler solutions exist + +**Example:** +```swift +// ❌ Overcomplicated +GeometryReader { geometry in + Rectangle() + .frame(width: geometry.size.width * 0.8) +} + +// ✅ Simple and correct +Rectangle() + .frame(maxWidth: .infinity) + .padding(.horizontal, 40) // Creates inset +``` + +**Why it's bad:** +- GeometryReader expands to fill all space, breaking layouts +- Causes performance overhead +- Makes code harder to understand +- Often causes issues in ScrollViews + +**Instead:** +- Use `.frame(maxWidth: .infinity)` for full width +- Use `containerRelativeFrame()` (iOS 17+) for proportional sizing +- Use `ViewThatFits` (iOS 16+) for adaptive layouts +- Reserve GeometryReader for actual coordinate-space needs + + + +**Problem:** Excessive nesting of HStack/VStack creating deep hierarchies + +**Example:** +```swift +// ❌ Too many nested stacks +VStack { + HStack { + VStack { + HStack { + VStack { + Text("Title") + Text("Subtitle") + } + } + } + } +} + +// ✅ Flattened with proper modifiers +VStack(alignment: .leading, spacing: 4) { + Text("Title") + .font(.headline) + Text("Subtitle") + .font(.subheadline) + .foregroundStyle(.secondary) +} +``` + +**Why it's bad:** +- Harder to read and maintain +- Unnecessary view hierarchy depth +- Can impact performance with many views +- Makes alignment more complex + +**Instead:** +- Use alignment and spacing parameters instead of wrapper stacks +- Extract complex views into separate components +- Use Grid for form-like layouts +- Consider Custom Layout for truly complex arrangements + + + +**Problem:** Using LazyVStack outside a ScrollView + +**Example:** +```swift +// ❌ LazyVStack needs a scrollable container +LazyVStack { + ForEach(items) { item in + Text(item.name) + } +} + +// ✅ Correct usage +ScrollView { + LazyVStack { + ForEach(items) { item in + Text(item.name) + } + } +} + +// ✅ Or just use VStack if not scrolling +VStack { + ForEach(items) { item in + Text(item.name) + } +} +``` + +**Why it's bad:** +- LazyVStack requires a scrollable parent to know when to load views +- Without scrolling, there's no benefit to lazy loading +- Can cause unexpected layout behavior + +**Instead:** +- Always wrap LazyVStack/LazyHStack in ScrollView +- If not scrolling, use regular VStack/HStack + + + +**Problem:** Using `.fixed()` GridItem when flexible sizing would work better + +**Example:** +```swift +// ❌ Fixed sizes break on different screen sizes +LazyVGrid(columns: [ + GridItem(.fixed(150)), + GridItem(.fixed(150)) +]) { + ForEach(items) { item in + ItemView(item: item) + } +} + +// ✅ Adaptive sizing +LazyVGrid(columns: [ + GridItem(.adaptive(minimum: 150, maximum: 200)) +]) { + ForEach(items) { item in + ItemView(item: item) + } +} + +// ✅ Flexible columns +LazyVGrid(columns: [ + GridItem(.flexible()), + GridItem(.flexible()) +]) { + ForEach(items) { item in + ItemView(item: item) + } +} +``` + +**Why it's bad:** +- Doesn't adapt to different screen sizes (iPhone SE vs iPad) +- Creates horizontal scrolling or cut-off content +- Not responsive to orientation changes + +**Instead:** +- Use `.flexible()` to let items share space proportionally +- Use `.adaptive()` to fit as many items as possible +- Reserve `.fixed()` for specific design requirements (icons, avatars) + + + +**Problem:** Using multiple Spacers when alignment parameters would be clearer + +**Example:** +```swift +// ❌ Confusing spacer usage +HStack { + Spacer() + Text("Centered?") + Spacer() + Spacer() +} + +// ✅ Clear alignment +HStack { + Spacer() + Text("Centered") + Spacer() +} + +// ✅ Even better - use alignment +HStack { + Text("Centered") +} +.frame(maxWidth: .infinity) + +// ✅ For trailing alignment +HStack { + Spacer() + Text("Trailing") +} +``` + +**Why it's bad:** +- Multiple spacers create ambiguous spacing +- Harder to reason about layout +- Can cause unexpected behavior with different content sizes + +**Instead:** +- Use single Spacer() for clear intent +- Use frame modifiers with alignment +- Use stack alignment parameters + + + +**Problem:** Using LazyVStack for small lists or VStack for huge lists + +**Example:** +```swift +// ❌ Lazy overhead for tiny list +ScrollView { + LazyVStack { + ForEach(0..<5) { i in + Text("Item \(i)") + } + } +} + +// ✅ Just use VStack +ScrollView { + VStack { + ForEach(0..<5) { i in + Text("Item \(i)") + } + } +} + +// ❌ Regular stack for huge list +VStack { + ForEach(0..<1000) { i in + ExpensiveView(index: i) + } +} + +// ✅ Lazy for performance +ScrollView { + LazyVStack { + ForEach(0..<1000) { i in + ExpensiveView(index: i) + } + } +} +``` + +**Why it's bad:** +- Lazy containers add overhead for small datasets +- Non-lazy containers create all views upfront (memory/performance hit) + +**Instead:** +- **< 20 simple items:** Use VStack/HStack +- **20-50 items:** Test both; likely VStack is fine +- **> 50 items or complex views:** Use LazyVStack/LazyHStack +- **Large images/media:** Always use lazy + + + +**Problem:** Providing fixed frames to ViewThatFits children, defeating its purpose + +**Example:** +```swift +// ❌ Fixed frames prevent ViewThatFits from working +ViewThatFits { + HStack { + content + } + .frame(width: 600) // Prevents fitting logic + + VStack { + content + } + .frame(width: 300) +} + +// ✅ Let views size naturally +ViewThatFits { + HStack { + content + } + + VStack { + content + } +} +``` + +**Why it's bad:** +- ViewThatFits needs to measure ideal sizes to choose the right view +- Fixed frames override this measurement +- Defeats the entire purpose of adaptive layout + +**Instead:** +- Let child views size themselves naturally +- Use maxWidth/maxHeight if needed, not fixed sizes +- Trust ViewThatFits to pick the right layout + + + + +**Sources:** +Research for this reference included: +- [SwiftUI Layout System (kean.blog)](https://kean.blog/post/swiftui-layout-system) +- [Custom Layouts in SwiftUI (Medium)](https://medium.com/@wesleymatlock/custom-layouts-in-swiftui-a-deep-dive-into-the-layout-protocol-5edc691cd4fb) +- [A guide to the SwiftUI layout system (Swift by Sundell)](https://www.swiftbysundell.com/articles/swiftui-layout-system-guide-part-1/) +- [Creating custom layouts with Layout protocol (Hacking with Swift)](https://www.hackingwithswift.com/quick-start/swiftui/how-to-create-a-custom-layout-using-the-layout-protocol) +- [Apple Developer: Composing custom layouts with SwiftUI](https://developer.apple.com/documentation/swiftui/composing_custom_layouts_with_swiftui) +- [Custom Layout in SwiftUI (Sarunw)](https://sarunw.com/posts/swiftui-custom-layout/) +- [GeometryReader - Blessing or Curse? (fatbobman)](https://fatbobman.com/en/posts/geometryreader-blessing-or-curse/) +- [Mastering GeometryReader in SwiftUI (DEV Community)](https://dev.to/qmshahzad/mastering-geometryreader-in-swiftui-from-basics-to-advanced-layout-control-5akk) +- [SwiftUI Grid, LazyVGrid, LazyHGrid (avanderlee)](https://www.avanderlee.com/swiftui/grid-lazyvgrid-lazyhgrid-gridviews/) +- [Tuning Lazy Stacks and Grids Performance Guide (Medium)](https://medium.com/@wesleymatlock/tuning-lazy-stacks-and-grids-in-swiftui-a-performance-guide-2fb10786f76a) +- [containerRelativeFrame Modifier (fatbobman)](https://fatbobman.com/en/posts/mastering-the-containerrelativeframe-modifier-in-swiftui/) +- [ViewThatFits adaptive layout (Hacking with Swift)](https://www.hackingwithswift.com/quick-start/swiftui/how-to-create-an-adaptive-layout-with-viewthatfits) +- [Mastering ViewThatFits (fatbobman)](https://fatbobman.com/en/posts/mastering-viewthatfits/) +- [Alignment Guides in SwiftUI (The SwiftUI Lab)](https://swiftui-lab.com/alignment-guides/) +- [Managing safe area in SwiftUI (Swift with Majid)](https://swiftwithmajid.com/2021/11/03/managing-safe-area-in-swiftui/) +- [Mastering Safe Area in SwiftUI (fatbobman)](https://fatbobman.com/en/posts/safearea/) diff --git a/src/resources/skills/swiftui/references/navigation.md b/src/resources/skills/swiftui/references/navigation.md new file mode 100644 index 000000000..8bb98b0ac --- /dev/null +++ b/src/resources/skills/swiftui/references/navigation.md @@ -0,0 +1,1492 @@ + +SwiftUI navigation has evolved significantly with NavigationStack (iOS 16+) replacing the deprecated NavigationView. The modern navigation model provides type-safe, programmatic control while supporting both user-driven and code-driven navigation patterns. + +**Key insight:** NavigationStack with NavigationPath provides a stack-based navigation system where you can programmatically manipulate the navigation hierarchy while SwiftUI keeps the UI in sync automatically. + +**Read this file when:** Building multi-screen apps, implementing deep linking, managing programmatic navigation, presenting sheets and modals, or setting up tab-based navigation. + +**Related files:** +- architecture.md - Coordinator pattern for complex navigation flows +- state-management.md - Managing navigation state with @Observable +- platform-integration.md - Platform-specific navigation differences (iOS vs macOS) + + + +## NavigationStack + +NavigationStack manages a stack of views with type-safe routing. It replaces the deprecated NavigationView and provides better programmatic control. + +**Basic usage with NavigationLink:** +```swift +struct ContentView: View { + var body: some View { + NavigationStack { + List { + NavigationLink("Details", value: "details") + NavigationLink("Settings", value: "settings") + } + .navigationTitle("Home") + .navigationDestination(for: String.self) { value in + Text("Showing: \(value)") + } + } + } +} +``` + +**With NavigationPath for programmatic navigation:** +```swift +struct ContentView: View { + @State private var path = NavigationPath() + + var body: some View { + NavigationStack(path: $path) { + VStack { + Button("Go to Details") { + path.append("details") + } + + Button("Go Deep (3 levels)") { + path.append("level1") + path.append("level2") + path.append("level3") + } + } + .navigationTitle("Home") + .navigationDestination(for: String.self) { value in + DetailView(value: value, path: $path) + } + } + } +} + +struct DetailView: View { + let value: String + @Binding var path: NavigationPath + + var body: some View { + VStack { + Text("Showing: \(value)") + + Button("Pop to Root") { + path = NavigationPath() + } + } + } +} +``` + +**navigationDestination modifier:** + +The navigationDestination modifier enables type-based routing. You can register multiple destination handlers for different data types: + +```swift +struct MultiTypeNavigation: View { + @State private var path = NavigationPath() + + var body: some View { + NavigationStack(path: $path) { + List { + Button("Show User") { + path.append(User(id: 1, name: "Alice")) + } + + Button("Show Product") { + path.append(Product(id: 100, title: "iPhone")) + } + } + .navigationDestination(for: User.self) { user in + UserDetailView(user: user) + } + .navigationDestination(for: Product.self) { product in + ProductDetailView(product: product) + } + } + } +} + +struct User: Hashable, Codable { + let id: Int + let name: String +} + +struct Product: Hashable, Codable { + let id: Int + let title: String +} +``` + +**Key rules:** +- Place navigationDestination inside NavigationStack, not on child views +- Don't place navigationDestination on lazy containers (List, ScrollView, LazyVStack) +- Top-level navigationDestination always overrides lower ones for the same type +- Each destination type must be Hashable + +**Navigation state in @Observable:** +```swift +import Observation + +@Observable +class NavigationManager { + var path = NavigationPath() + + func push(_ destination: Destination) { + path.append(destination) + } + + func pop() { + guard !path.isEmpty else { return } + path.removeLast() + } + + func popToRoot() { + path = NavigationPath() + } +} + +enum Destination: Hashable { + case detail(id: Int) + case settings + case profile +} + +struct AppView: View { + @State private var navigation = NavigationManager() + + var body: some View { + @Bindable var nav = navigation + + NavigationStack(path: $nav.path) { + List { + Button("Details") { + navigation.push(.detail(id: 1)) + } + + Button("Settings") { + navigation.push(.settings) + } + } + .navigationDestination(for: Destination.self) { destination in + switch destination { + case .detail(let id): + DetailView(id: id, navigation: navigation) + case .settings: + SettingsView(navigation: navigation) + case .profile: + ProfileView(navigation: navigation) + } + } + } + } +} +``` + + + +## Programmatic Navigation + +NavigationPath provides programmatic control over the navigation stack without requiring NavigationLink user interaction. + +**Push to path:** +```swift +// Push single destination +path.append(DetailDestination.item(id: 123)) + +// Push multiple levels at once +path.append(contentsOf: [screen1, screen2, screen3]) +``` + +**Pop operations:** +```swift +// Pop one level +path.removeLast() + +// Pop multiple levels +path.removeLast(2) + +// Pop to root (clear entire stack) +path = NavigationPath() + +// Conditional pop +if path.count > 0 { + path.removeLast() +} +``` + +**Deep navigation example:** +```swift +@Observable +class Router { + var path = NavigationPath() + + func navigateToUserPosts(userId: Int, postId: Int) { + // Navigate through multiple screens + path.append(Route.userDetail(userId)) + path.append(Route.userPosts(userId)) + path.append(Route.postDetail(postId)) + } + + func popToUserDetail() { + // Remove specific number of levels + if path.count >= 2 { + path.removeLast(2) + } + } +} + +enum Route: Hashable { + case userDetail(Int) + case userPosts(Int) + case postDetail(Int) +} +``` + +**NavigationPath count and inspection:** +```swift +struct NavigationDebugView: View { + @State private var path = NavigationPath() + + var body: some View { + NavigationStack(path: $path) { + VStack { + Text("Stack depth: \(path.count)") + + Button("Push") { + path.append("Level \(path.count + 1)") + } + + Button("Pop") { + if !path.isEmpty { + path.removeLast() + } + } + + Button("Pop to Root") { + path = NavigationPath() + } + } + .navigationDestination(for: String.self) { value in + Text(value) + } + } + } +} +``` + + + +## Sheet and FullScreenCover + +Sheets present modal content on top of the current view. They are not part of the NavigationStack hierarchy. + +**Basic sheet with boolean:** +```swift +struct SheetExample: View { + @State private var showingSheet = false + + var body: some View { + Button("Show Sheet") { + showingSheet = true + } + .sheet(isPresented: $showingSheet) { + SheetContentView() + } + } +} + +struct SheetContentView: View { + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + VStack { + Text("Sheet Content") + Button("Close") { + dismiss() + } + } + .navigationTitle("Modal") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + dismiss() + } + } + } + } + } +} +``` + +**Item-based presentation (type-safe, recommended):** +```swift +struct ItemSheet: View { + @State private var selectedUser: User? + + var body: some View { + List(users) { user in + Button(user.name) { + selectedUser = user + } + } + .sheet(item: $selectedUser) { user in + UserDetailSheet(user: user) + } + } +} + +struct User: Identifiable { + let id: UUID + let name: String +} + +struct UserDetailSheet: View { + let user: User + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + VStack { + Text("User: \(user.name)") + } + .navigationTitle(user.name) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { + dismiss() + } + } + } + } + } +} +``` + +**FullScreenCover:** +```swift +struct FullScreenExample: View { + @State private var showingFullScreen = false + + var body: some View { + Button("Show Full Screen") { + showingFullScreen = true + } + .fullScreenCover(isPresented: $showingFullScreen) { + FullScreenContentView() + } + } +} + +struct FullScreenContentView: View { + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + VStack { + Text("Full Screen Content") + .font(.largeTitle) + } + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Close") { + dismiss() + } + } + } + } + } +} +``` + +**Presentation detents (iOS 16+):** +```swift +struct DetentSheet: View { + @State private var showingSheet = false + @State private var selectedDetent: PresentationDetent = .medium + + var body: some View { + Button("Show Customizable Sheet") { + showingSheet = true + } + .sheet(isPresented: $showingSheet) { + SheetWithDetents(selectedDetent: $selectedDetent) + .presentationDetents( + [.medium, .large, .fraction(0.25), .height(200)], + selection: $selectedDetent + ) + .presentationDragIndicator(.visible) + .presentationBackgroundInteraction(.enabled(upThrough: .medium)) + } + } +} + +struct SheetWithDetents: View { + @Binding var selectedDetent: PresentationDetent + + var body: some View { + VStack { + Text("Drag to resize") + .font(.headline) + + Text("Current detent: \(detentDescription)") + .font(.caption) + } + .padding() + } + + var detentDescription: String { + if selectedDetent == .medium { return "Medium" } + if selectedDetent == .large { return "Large" } + return "Custom" + } +} +``` + +**Dismiss from presented view:** +```swift +struct DismissExample: View { + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack { + Text("Modal Content") + + Button("Dismiss") { + dismiss() + } + } + } +} +``` + + + +## TabView + +TabView presents multiple independent navigation hierarchies. Each tab typically contains its own NavigationStack. + +**Basic TabView:** +```swift +struct TabExample: View { + var body: some View { + TabView { + HomeView() + .tabItem { + Label("Home", systemImage: "house") + } + + SearchView() + .tabItem { + Label("Search", systemImage: "magnifyingglass") + } + + ProfileView() + .tabItem { + Label("Profile", systemImage: "person") + } + } + } +} + +struct HomeView: View { + var body: some View { + NavigationStack { + List { + Text("Home Content") + } + .navigationTitle("Home") + } + } +} +``` + +**Programmatic tab selection:** +```swift +struct ProgrammaticTabView: View { + @State private var selectedTab = Tab.home + + var body: some View { + TabView(selection: $selectedTab) { + HomeView() + .tabItem { + Label("Home", systemImage: "house") + } + .tag(Tab.home) + + SearchView() + .tabItem { + Label("Search", systemImage: "magnifyingglass") + } + .tag(Tab.search) + + ProfileView() + .tabItem { + Label("Profile", systemImage: "person") + } + .tag(Tab.profile) + } + .onChange(of: selectedTab) { oldValue, newValue in + print("Tab changed from \(oldValue) to \(newValue)") + } + } +} + +enum Tab { + case home + case search + case profile +} +``` + +**Each tab with independent NavigationStack:** +```swift +struct IndependentTabStacks: View { + @State private var homeNavPath = NavigationPath() + @State private var searchNavPath = NavigationPath() + + var body: some View { + TabView { + NavigationStack(path: $homeNavPath) { + HomeRootView() + .navigationDestination(for: HomeDestination.self) { destination in + // Home-specific destinations + Text("Home destination") + } + } + .tabItem { + Label("Home", systemImage: "house") + } + + NavigationStack(path: $searchNavPath) { + SearchRootView() + .navigationDestination(for: SearchDestination.self) { destination in + // Search-specific destinations + Text("Search destination") + } + } + .tabItem { + Label("Search", systemImage: "magnifyingglass") + } + } + } +} + +enum HomeDestination: Hashable { + case detail(Int) +} + +enum SearchDestination: Hashable { + case results(String) +} +``` + +**iOS 18 Tab API:** +```swift +// iOS 18 introduces new Tab syntax with better customization +@available(iOS 18.0, *) +struct ModernTabView: View { + @State private var selectedTab: TabIdentifier = .home + + var body: some View { + TabView(selection: $selectedTab) { + Tab("Home", systemImage: "house", value: .home) { + NavigationStack { + HomeView() + } + } + + Tab("Search", systemImage: "magnifyingglass", value: .search) { + NavigationStack { + SearchView() + } + } + .badge(5) // Badge support + + Tab("Profile", systemImage: "person", value: .profile) { + NavigationStack { + ProfileView() + } + } + .customizationID("profile") // Enables tab customization + } + .tabViewStyle(.sidebarAdaptable) // Sidebar on iPad + } +} + +enum TabIdentifier: Hashable { + case home + case search + case profile +} +``` + +**Tab badges:** +```swift +struct BadgedTabs: View { + @State private var unreadCount = 3 + + var body: some View { + TabView { + HomeView() + .tabItem { + Label("Home", systemImage: "house") + } + + MessagesView() + .tabItem { + Label("Messages", systemImage: "message") + } + .badge(unreadCount) + + ProfileView() + .tabItem { + Label("Profile", systemImage: "person") + } + } + } +} +``` + +**Platform differences:** +- **iOS:** Bottom tabs with up to 5 visible items (more creates "More" tab) +- **macOS:** Top tabs or sidebar style +- **iPadOS:** Can transform to sidebar with .tabViewStyle(.sidebarAdaptable) +- **watchOS:** PageTabViewStyle (swipeable pages) + + + +## Deep Linking + +Deep linking enables opening your app to specific screens via URLs, supporting both custom URL schemes and Universal Links. + +**URL handling with onOpenURL:** +```swift +@main +struct MyApp: App { + @State private var router = Router() + + var body: some Scene { + WindowGroup { + ContentView() + .environment(router) + .onOpenURL { url in + handleDeepLink(url) + } + } + } + + private func handleDeepLink(_ url: URL) { + router.handleDeepLink(url) + } +} + +@Observable +class Router { + var path = NavigationPath() + + func handleDeepLink(_ url: URL) { + // Parse URL and update navigation + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true) else { + return + } + + // myapp://user/123 + if components.scheme == "myapp", + components.host == "user", + let userId = components.path.split(separator: "/").first, + let id = Int(userId) { + navigateToUser(id: id) + } + + // myapp://product/456/reviews + if components.scheme == "myapp", + components.host == "product" { + let pathComponents = components.path.split(separator: "/") + if let productId = pathComponents.first, + let id = Int(productId) { + navigateToProduct(id: id, showReviews: pathComponents.contains("reviews")) + } + } + } + + func navigateToUser(id: Int) { + path = NavigationPath() // Reset to root + path.append(Route.userDetail(id)) + } + + func navigateToProduct(id: Int, showReviews: Bool) { + path = NavigationPath() + path.append(Route.productDetail(id)) + if showReviews { + path.append(Route.productReviews(id)) + } + } +} + +enum Route: Hashable { + case userDetail(Int) + case productDetail(Int) + case productReviews(Int) +} +``` + +**Parsing URLs into navigation state:** +```swift +@Observable +class DeepLinkRouter { + var path = NavigationPath() + var selectedTab: AppTab = .home + + func handleDeepLink(_ url: URL) { + // Parse URL: myapp://tab/search?query=SwiftUI&filter=recent + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true) else { + return + } + + // Handle tab switching + if components.host == "tab", + let tabName = components.path.split(separator: "/").first { + switchTab(String(tabName)) + } + + // Handle query parameters + let queryItems = components.queryItems ?? [] + if let query = queryItems.first(where: { $0.name == "query" })?.value { + navigateToSearch(query: query) + } + } + + private func switchTab(_ tab: String) { + switch tab { + case "home": selectedTab = .home + case "search": selectedTab = .search + case "profile": selectedTab = .profile + default: break + } + } + + private func navigateToSearch(query: String) { + selectedTab = .search + path = NavigationPath() + path.append(SearchRoute.results(query)) + } +} + +enum AppTab { + case home + case search + case profile +} + +enum SearchRoute: Hashable { + case results(String) +} +``` + +**Universal Links setup:** + +1. **Associated Domains entitlement:** Add in Xcode project capabilities + - `applinks:example.com` + +2. **apple-app-site-association file:** Host at `https://example.com/.well-known/apple-app-site-association` +```json +{ + "applinks": { + "apps": [], + "details": [ + { + "appID": "TEAM_ID.com.example.myapp", + "paths": [ + "/user/*", + "/product/*" + ] + } + ] + } +} +``` + +3. **Handle in app:** +```swift +@main +struct MyApp: App { + @State private var router = Router() + + var body: some Scene { + WindowGroup { + ContentView() + .environment(router) + .onOpenURL { url in + router.handleUniversalLink(url) + } + } + } +} +``` + +**Custom URL schemes:** + +1. **Register scheme in Info.plist:** +```xml +CFBundleURLTypes + + + CFBundleURLSchemes + + myapp + + CFBundleURLName + com.example.myapp + + +``` + +2. **Handle custom scheme:** +```swift +struct ContentView: View { + @Environment(Router.self) private var router + + var body: some View { + @Bindable var router = router + + NavigationStack(path: $router.path) { + HomeView() + .navigationDestination(for: Route.self) { route in + destinationView(for: route) + } + } + } + + @ViewBuilder + func destinationView(for route: Route) -> some View { + switch route { + case .userDetail(let id): + UserDetailView(userId: id) + case .productDetail(let id): + ProductDetailView(productId: id) + case .productReviews(let id): + ProductReviewsView(productId: id) + } + } +} +``` + +**Security considerations:** +- Validate all incoming URLs +- Sanitize parameters before using them +- Don't expose sensitive functionality via deep links +- Use Universal Links over custom URL schemes for production (more secure, unique) + + + +## Coordinator Pattern (Optional) + +The Coordinator pattern centralizes navigation logic, decoupling it from views. Use when navigation becomes complex enough to justify the abstraction. + +**When to use:** +- Complex navigation flows with many paths +- Testable navigation logic separated from views +- Multiple entry points to the same flow +- Deep linking with complex routing + +**Implementation with @Observable:** +```swift +import Observation + +@Observable +class AppCoordinator { + var path = NavigationPath() + var sheet: Sheet? + var fullScreenCover: Cover? + + // MARK: - Navigation + + func push(_ destination: Destination) { + path.append(destination) + } + + func pop() { + guard !path.isEmpty else { return } + path.removeLast() + } + + func popToRoot() { + path = NavigationPath() + } + + // MARK: - Sheets + + func presentSheet(_ sheet: Sheet) { + self.sheet = sheet + } + + func dismissSheet() { + self.sheet = nil + } + + // MARK: - Full Screen + + func presentFullScreen(_ cover: Cover) { + self.fullScreenCover = cover + } + + func dismissFullScreen() { + self.fullScreenCover = nil + } + + // MARK: - Deep Linking + + func handleDeepLink(_ url: URL) { + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true) else { + return + } + + if components.path.contains("/user/"), + let userId = extractId(from: components.path) { + popToRoot() + push(.userDetail(userId)) + } + } + + private func extractId(from path: String) -> Int? { + let components = path.split(separator: "/") + return components.last.flatMap { Int($0) } + } +} + +enum Destination: Hashable { + case userDetail(Int) + case settings + case editProfile +} + +enum Sheet: Identifiable { + case addUser + case filter + + var id: String { + switch self { + case .addUser: return "addUser" + case .filter: return "filter" + } + } +} + +enum Cover: Identifiable { + case onboarding + case camera + + var id: String { + switch self { + case .onboarding: return "onboarding" + case .camera: return "camera" + } + } +} + +// MARK: - Root View + +struct RootView: View { + @State private var coordinator = AppCoordinator() + + var body: some View { + @Bindable var coordinator = coordinator + + NavigationStack(path: $coordinator.path) { + UserListView() + .navigationDestination(for: Destination.self) { destination in + destinationView(for: destination) + } + } + .sheet(item: $coordinator.sheet) { sheet in + sheetView(for: sheet) + } + .fullScreenCover(item: $coordinator.fullScreenCover) { cover in + coverView(for: cover) + } + .environment(coordinator) + .onOpenURL { url in + coordinator.handleDeepLink(url) + } + } + + @ViewBuilder + func destinationView(for destination: Destination) -> some View { + switch destination { + case .userDetail(let id): + UserDetailView(userId: id) + case .settings: + SettingsView() + case .editProfile: + EditProfileView() + } + } + + @ViewBuilder + func sheetView(for sheet: Sheet) -> some View { + switch sheet { + case .addUser: + AddUserView() + case .filter: + FilterView() + } + } + + @ViewBuilder + func coverView(for cover: Cover) -> some View { + switch cover { + case .onboarding: + OnboardingView() + case .camera: + CameraView() + } + } +} + +// MARK: - Views using coordinator + +struct UserListView: View { + @Environment(AppCoordinator.self) private var coordinator + + var body: some View { + List { + ForEach(users) { user in + Button(user.name) { + coordinator.push(.userDetail(user.id)) + } + } + } + .navigationTitle("Users") + .toolbar { + Button("Add") { + coordinator.presentSheet(.addUser) + } + } + } + + let users = [ + User(id: 1, name: "Alice"), + User(id: 2, name: "Bob") + ] +} + +struct UserDetailView: View { + let userId: Int + @Environment(AppCoordinator.self) private var coordinator + + var body: some View { + VStack { + Text("User \(userId)") + + Button("Edit Profile") { + coordinator.push(.editProfile) + } + + Button("Pop to Root") { + coordinator.popToRoot() + } + } + .navigationTitle("User Detail") + } +} +``` + +**Trade-offs:** +- **Pros:** Testable navigation logic, centralized flow control, easier deep linking, decoupled views +- **Cons:** Additional abstraction layer, more code to maintain, can be overkill for simple apps + +**When NOT to use:** Simple apps with linear navigation, apps with fewer than 10 screens, prototypes + + + +## Navigation State Persistence + +Enable state restoration so users return to where they left off when reopening your app. + +**Codable NavigationPath:** +```swift +struct PersistentNavigation: View { + @State private var path = NavigationPath() + @AppStorage("navigationPath") private var navigationPathData: Data? + + var body: some View { + NavigationStack(path: $path) { + List { + NavigationLink("Details", value: Route.details) + NavigationLink("Settings", value: Route.settings) + } + .navigationTitle("Home") + .navigationDestination(for: Route.self) { route in + routeView(for: route) + } + } + .onAppear { + restorePath() + } + .onChange(of: path) { oldPath, newPath in + savePath() + } + } + + @ViewBuilder + func routeView(for route: Route) -> some View { + switch route { + case .details: + Text("Details") + case .settings: + Text("Settings") + } + } + + func savePath() { + guard let representation = path.codable else { return } + + do { + let data = try JSONEncoder().encode(representation) + navigationPathData = data + } catch { + print("Failed to save path: \(error)") + } + } + + func restorePath() { + guard let data = navigationPathData else { return } + + do { + let representation = try JSONDecoder().decode( + NavigationPath.CodableRepresentation.self, + from: data + ) + path = NavigationPath(representation) + } catch { + print("Failed to restore path: \(error)") + } + } +} + +enum Route: Hashable, Codable { + case details + case settings +} +``` + +**@SceneStorage for restoration:** +```swift +struct SceneStorageNavigation: View { + @SceneStorage("navigationPath") private var pathData: Data? + @State private var path = NavigationPath() + + var body: some View { + NavigationStack(path: $path) { + List { + NavigationLink("Item 1", value: 1) + NavigationLink("Item 2", value: 2) + } + .navigationDestination(for: Int.self) { value in + DetailView(value: value) + } + } + .task { + if let data = pathData, + let representation = try? JSONDecoder().decode( + NavigationPath.CodableRepresentation.self, + from: data + ) { + path = NavigationPath(representation) + } + } + .onChange(of: path) { _, newPath in + if let representation = newPath.codable, + let data = try? JSONEncoder().encode(representation) { + pathData = data + } + } + } +} +``` + +**Important notes:** +- Only works if all types in NavigationPath are Codable +- @SceneStorage cleared when user force-quits app +- @AppStorage persists across launches but not recommended for large data +- Test restoration thoroughly (background app, force quit, etc.) + + + +## Choosing the Right Approach + +**Simple app with few screens:** NavigationStack with NavigationLink (user-driven navigation is sufficient) + +**Need programmatic navigation:** NavigationStack + NavigationPath in @Observable class stored in @State + +**Modal content (settings, forms, detail overlays):** .sheet() for dismissible modals, .fullScreenCover() for immersive content + +**Multiple independent sections:** TabView with separate NavigationStack per tab + +**Deep linking required:** onOpenURL + NavigationPath (parse URL and manipulate path programmatically) + +**Complex navigation flows (10+ screens, multiple entry points):** Coordinator pattern with @Observable coordinator managing NavigationPath and sheet/cover state + +**State restoration needed:** NavigationPath.codable with @SceneStorage or @AppStorage + +**Platform differences matter:** Check platform in architecture.md, use NavigationSplitView for iPad/macOS multi-column layouts + + + +## What NOT to Do + + +**Problem:** NavigationView is deprecated in iOS 16+ + +**Why it's bad:** +- Missing modern features (programmatic navigation, type-safe routing) +- Deprecated API that may be removed +- NavigationStack is more performant and flexible + +**Instead:** Use NavigationStack +```swift +// WRONG +NavigationView { + List { } +} + +// RIGHT +NavigationStack { + List { } +} +``` + + + +**Problem:** Using @State var showDetail = false for each destination + +**Why it's bad:** +- Doesn't scale beyond 2-3 screens +- Loses type safety (what data does the destination need?) +- Can't programmatically navigate deep +- No navigation history + +**Instead:** Use navigationDestination with typed values +```swift +// WRONG +@State private var showUserDetail = false +@State private var showSettings = false +@State private var showProfile = false + +// RIGHT +@State private var path = NavigationPath() + +NavigationStack(path: $path) { + Button("Show User") { + path.append(Route.userDetail(id: 1)) + } + .navigationDestination(for: Route.self) { route in + // Handle route + } +} +``` + + + +**Problem:** Storing NavigationPath in child views that need to access it + +**Why it's bad:** +- Child views can't access parent's NavigationPath +- Forces passing bindings through many levels +- Breaks encapsulation + +**Instead:** Store in @Observable, pass via @Environment +```swift +// WRONG +struct ChildView: View { + @State private var path = NavigationPath() // Can't access parent's path +} + +// RIGHT +@Observable +class Router { + var path = NavigationPath() +} + +@main +struct App: App { + @State private var router = Router() + + var body: some Scene { + WindowGroup { + ContentView() + .environment(router) + } + } +} + +struct ChildView: View { + @Environment(Router.self) private var router + + var body: some View { + Button("Navigate") { + router.path.append(destination) + } + } +} +``` + + + +**Problem:** Putting navigationDestination inside List, ScrollView, LazyVStack + +**Why it's bad:** +- Destination closures may not be called +- Lazy loading means modifiers aren't registered +- Apple explicitly warns against this + +**Instead:** Place navigationDestination on NavigationStack or its immediate child +```swift +// WRONG +NavigationStack { + List { + ForEach(items) { item in + NavigationLink(item.name, value: item) + } + .navigationDestination(for: Item.self) { item in // ❌ Inside List + DetailView(item: item) + } + } +} + +// RIGHT +NavigationStack { + List { + ForEach(items) { item in + NavigationLink(item.name, value: item) + } + } + .navigationDestination(for: Item.self) { item in // ✅ Outside List + DetailView(item: item) + } +} +``` + + + +**Problem:** Using sheets for multi-step flows that should be pushed + +**Why it's bad:** +- Sheets are for modal content, not hierarchical navigation +- Can't use back button (must dismiss) +- Breaks user expectations +- No navigation history + +**Instead:** Use NavigationStack for flows, sheets for modals +```swift +// WRONG - using sheets for sequential steps +.sheet(isPresented: $showStep2) { + Step2View() + .sheet(isPresented: $showStep3) { + Step3View() // Nested sheets + } +} + +// RIGHT - NavigationStack for flows +NavigationStack(path: $path) { + Step1View() + .navigationDestination(for: Step.self) { step in + switch step { + case .step2: Step2View() + case .step3: Step3View() + } + } +} + +// RIGHT - Sheets for modals +.sheet(isPresented: $showSettings) { + SettingsView() // Self-contained modal +} +``` + + + +**Problem:** Forgetting to conform to Hashable for navigationDestination types + +**Why it's bad:** +- Compiler error: navigationDestination requires Hashable +- NavigationPath can't store non-Hashable types + +**Instead:** Always make route types Hashable (and Codable for persistence) +```swift +// WRONG +struct Route { + let id: Int +} + +// RIGHT +struct Route: Hashable { + let id: Int +} + +// EVEN BETTER - also Codable for persistence +enum Route: Hashable, Codable { + case detail(id: Int) + case settings +} +``` + + + +**Problem:** Sharing NavigationPath between tabs + +**Why it's bad:** +- Tabs should have independent navigation stacks +- Switching tabs loses navigation context +- Breaks expected tab behavior + +**Instead:** Each tab gets its own NavigationStack and path +```swift +// WRONG +@State private var path = NavigationPath() + +TabView { + NavigationStack(path: $path) { HomeView() } + .tabItem { Label("Home", systemImage: "house") } + + NavigationStack(path: $path) { SearchView() } // ❌ Shared path + .tabItem { Label("Search", systemImage: "magnifyingglass") } +} + +// RIGHT +@State private var homePath = NavigationPath() +@State private var searchPath = NavigationPath() + +TabView { + NavigationStack(path: $homePath) { HomeView() } + .tabItem { Label("Home", systemImage: "house") } + + NavigationStack(path: $searchPath) { SearchView() } // ✅ Independent + .tabItem { Label("Search", systemImage: "magnifyingglass") } +} +``` + + + +## Sources + +- [Hacking with Swift: Programmatic navigation with NavigationStack](https://www.hackingwithswift.com/books/ios-swiftui/programmatic-navigation-with-navigationstack) +- [AzamSharp: Navigation Patterns in SwiftUI](https://azamsharp.com/2024/07/29/navigation-patterns-in-swiftui.html) +- [tanaschita: How to use NavigationPath for routing in SwiftUI](https://tanaschita.com/swiftui-navigationpath/) +- [Swift with Majid: Mastering NavigationStack in SwiftUI. Navigator Pattern](https://swiftwithmajid.com/2022/06/15/mastering-navigationstack-in-swiftui-navigator-pattern/) +- [Medium: Mastering Navigation in SwiftUI: The 2025 Guide](https://medium.com/@dinaga119/mastering-navigation-in-swiftui-the-2025-guide-to-clean-scalable-routing-bbcb6dbce929) +- [Swift Anytime: How to use Coordinator Pattern in SwiftUI](https://www.swiftanytime.com/blog/coordinator-pattern-in-swiftui) +- [SwiftLee: Deeplink URL handling in SwiftUI](https://www.avanderlee.com/swiftui/deeplink-url-handling/) +- [Michael Long: Advanced Deep Linking in SwiftUI](https://michaellong.medium.com/advanced-deep-linking-in-swiftui-c0085be83e7c) +- [Swift with Majid: Deep linking for local notifications in SwiftUI](https://swiftwithmajid.com/2024/04/09/deep-linking-for-local-notifications-in-swiftui/) +- [Sarunw: Bottom Sheet in SwiftUI on iOS 16 with presentationDetents](https://sarunw.com/posts/swiftui-bottom-sheet/) +- [Apple Developer: presentationDetents(_:)](https://developer.apple.com/documentation/swiftui/view/presentationdetents(_:)) +- [Hacking with Swift: What's new in SwiftUI for iOS 18](https://www.hackingwithswift.com/articles/270/whats-new-in-swiftui-for-ios-18) +- [iOS Coffee Break: Using SwiftUI's Improved TabView with Sidebar on iOS 18](https://www.ioscoffeebreak.com/issue/issue34) +- [AppCoda: What's New in SwiftUI for iOS 18](https://www.appcoda.com/swiftui-ios-18/) +- [Medium: Getting Started with the Improved TabView in iOS 18](https://medium.com/@jpmtech/getting-started-with-the-improved-tabview-in-ios-18-111974b70db9) +- [Apple Developer: Enhancing your app's content with tab navigation](https://developer.apple.com/documentation/swiftui/enhancing-your-app-content-with-tab-navigation) +- [Apple Developer: NavigationPath](https://developer.apple.com/documentation/swiftui/navigationpath) +- [DEV Community: Modern Navigation in SwiftUI](https://dev.to/sebastienlato/modern-navigation-in-swiftui-1c8g) +- [Medium: Mastering Navigation in SwiftUI Using Coordinator Pattern](https://medium.com/@dikidwid0/mastering-navigation-in-swiftui-using-coordinator-pattern-833396c67db5) +- [QuickBird Studios: How to Use the Coordinator Pattern in SwiftUI](https://quickbirdstudios.com/blog/coordinator-pattern-in-swiftui/) diff --git a/src/resources/skills/swiftui/references/networking-async.md b/src/resources/skills/swiftui/references/networking-async.md new file mode 100644 index 000000000..8a3601cd2 --- /dev/null +++ b/src/resources/skills/swiftui/references/networking-async.md @@ -0,0 +1,214 @@ + +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. + + + +## 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 + + + +## Async/Await Patterns + +**Loading with @Observable:** +```swift +@Observable +@MainActor +class ArticleViewModel { + private(set) var state: LoadingState
= .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) +} +``` + + + +## API Client Architecture + +```swift +protocol APIClient { + func request(_ endpoint: Endpoint) async throws -> T +} + +@MainActor +final class ProductionAPIClient: APIClient { + private let baseURL: URL + private let session: URLSession + + func request(_ 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) + } +} +``` + + + +## Loading States + +```swift +enum LoadingState { + case idle + case loading + case loaded(Value) + case failed(Error) + + var isLoading: Bool { + if case .loading = self { return true } + return false + } +} + +struct AsyncContentView: View { + let state: LoadingState + 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)) + } + } +} +``` + + + +## Error Handling & Retry + +**Basic retry:** +```swift +func fetchWithRetry(maxRetries: Int = 3, operation: () async throws -> T) async throws -> T { + var lastError: Error? + for attempt in 0.. + + +## 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 + + + +## What NOT to Do + + +**Problem:** Showing error UI when task is cancelled +**Instead:** Catch CancellationError separately, don't update state + + + +**Problem:** Task { await loadData() } inside .task +**Instead:** .task already creates a Task + + + +**Problem:** View model updates from background thread +**Instead:** Mark @Observable view models with @MainActor + + + +**Problem:** Using ObservableObject/@Published +**Instead:** Use @Observable macro (iOS 17+) + + diff --git a/src/resources/skills/swiftui/references/performance.md b/src/resources/skills/swiftui/references/performance.md new file mode 100644 index 000000000..0f3049732 --- /dev/null +++ b/src/resources/skills/swiftui/references/performance.md @@ -0,0 +1,1706 @@ +# SwiftUI Performance Reference + + +SwiftUI's declarative, data-driven architecture provides automatic UI updates, but this comes with performance implications. Understanding the update cycle, view identity, and optimization strategies enables building responsive apps. + +**Core Performance Model:** + +1. **State Change**: A property wrapped with @State, @Observable, or similar changes +2. **Body Recomputation**: SwiftUI evaluates the view's body property +3. **Diffing**: SwiftUI compares new view hierarchy against previous +4. **Minimal Updates**: Only changed parts render to screen via Core Animation + +**Key Principle**: SwiftUI only recomputes body when dependency values change. Mastering what triggers recomputation and how to minimize it is essential for performance. + +**Performance Philosophy**: Profile before optimizing. SwiftUI includes automatic optimizations. Only intervene when profiling identifies actual bottlenecks. Premature optimization adds complexity without benefit. + + + +**View Identity** determines how SwiftUI tracks views across updates. Identity affects state preservation, transitions, and performance. + +## Two Types of Identity + +### Structural Identity + +SwiftUI identifies views by their position in the view hierarchy. Most common form of identity. + +```swift +// Structural identity - views identified by position +VStack { + Text("First") // Identity: VStack > position 0 + Text("Second") // Identity: VStack > position 1 +} + +// Problematic: branches change structural identity +if isLoggedIn { + ProfileView() // Identity: if branch > ProfileView +} else { + LoginView() // Identity: else branch > LoginView +} +``` + +**Best Practice**: Prefer conditional modifiers over branches to preserve identity: + +```swift +// Bad - changes structural identity, loses state +if isExpanded { + DetailView(expanded: true) +} else { + DetailView(expanded: false) +} + +// Good - preserves structural identity +DetailView(expanded: isExpanded) +``` + +### Explicit Identity + +Use the `.id()` modifier to explicitly control identity. SwiftUI treats views with different IDs as completely distinct. + +```swift +// Force view recreation by changing ID +ScrollView { + ContentView() + .id(selectedCategory) // New ID = destroy and recreate +} + +// List items use Identifiable for explicit identity +struct Item: Identifiable { + let id: UUID + let name: String +} + +List(items) { item in + Text(item.name) // SwiftUI tracks by item.id +} +``` + +**When to Use .id()**: + +- Reset view state (form after submission, scroll position) +- Force view recreation when data fundamentally changes +- Ensure transitions work correctly + +**Performance Impact**: Changing a view's ID destroys the old view and creates a new one, discarding all state. Expensive operation - use judiciously. + +## Identity and State Preservation + +SwiftUI maintains @State values as long as view identity remains stable: + +```swift +struct CounterView: View { + @State private var count = 0 // Preserved while identity stable + + var body: some View { + VStack { + Text("Count: \(count)") + Button("Increment") { count += 1 } + } + } +} + +// Branching destroys identity and @State +if showCounter { + CounterView() // count resets to 0 when toggled +} + +// Better: preserve identity with opacity/hidden +CounterView() + .opacity(showCounter ? 1 : 0) // State preserved +``` + +## Debugging Identity + +Use `Self._printChanges()` to see what triggers body recomputation: + +```swift +var body: some View { + let _ = Self._printChanges() // Xcode console shows changed properties + + VStack { + Text("Content") + } +} +``` + + + +**Lazy containers** create views on-demand as they scroll into view, rather than creating all views upfront. + +## Lazy Stack Types + +```swift +// LazyVStack - vertical scrolling +ScrollView { + LazyVStack(spacing: 16) { + ForEach(items) { item in + ItemRow(item: item) // Created only when visible + } + } +} + +// LazyHStack - horizontal scrolling +ScrollView(.horizontal) { + LazyHStack(spacing: 16) { + ForEach(items) { item in + ItemCard(item: item) + } + } +} + +// LazyVGrid - grid layout +ScrollView { + LazyVGrid(columns: [ + GridItem(.adaptive(minimum: 150)) + ], spacing: 16) { + ForEach(items) { item in + ItemCard(item: item) + } + } +} + +// LazyHGrid - horizontal grid +ScrollView(.horizontal) { + LazyHGrid(rows: [ + GridItem(.fixed(100)), + GridItem(.fixed(100)) + ], spacing: 16) { + ForEach(items) { item in + ItemCard(item: item) + } + } +} +``` + +## Performance Characteristics + +**Benefits**: +- **Reduced Memory**: 80-90% less memory than non-lazy equivalents for large lists +- **Faster Load**: Milliseconds vs seconds for initial render +- **Smooth Scrolling**: Maintains 60fps even with hundreds of items + +**Tradeoffs**: +- Views created lazily incur small bookkeeping overhead +- Once created, views stay in memory (not recycled like UITableView) +- For very large datasets (thousands of items), List provides view recycling + +```swift +// Memory comparison for 200 items: +// VStack: ~300MB, 2-3 second load +// LazyVStack: ~40MB, <100ms load +// List: ~40MB with view recycling (better for 1000+ items) +``` + +## When to Use Lazy Containers + +**Use LazyVStack/LazyHStack when**: +- Scrolling list with dozens to hundreds of items +- Items contain images, videos, or heavy views +- Custom animations and transitions required +- ScrollView directly wraps the stack + +**Use List when**: +- Thousands of items (view recycling needed) +- Standard list appearance acceptable +- Platform-native behavior desired + +**Avoid Lazy when**: +- Small number of items (< 20) +- All views fit on screen without scrolling +- Lazy overhead exceeds benefit (profile first) + +```swift +// Decision example +struct ContentView: View { + let items: [Item] + + var body: some View { + ScrollView { + if items.count > 50 { + // Use lazy for large lists + LazyVStack { + ForEach(items) { ItemRow(item: $0) } + } + } else { + // Regular stack fine for small lists + VStack { + ForEach(items) { ItemRow(item: $0) } + } + } + } + } +} +``` + +## Lazy Container Best Practices + +**Design Lightweight Views**: Lazy loading doesn't eliminate cost of heavy views. + +```swift +// Bad - heavy view defeats lazy loading benefits +struct ItemRow: View { + let item: Item + + var body: some View { + VStack { + AsyncImage(url: item.imageURL) { image in + image.resizable() // No size limit - uses full resolution + } placeholder: { + ProgressView() + } + Text(item.longDescription) // Renders all text upfront + } + } +} + +// Good - lightweight view +struct ItemRow: View { + let item: Item + + var body: some View { + VStack { + AsyncImage(url: item.imageURL) { image in + image + .resizable() + .aspectRatio(contentMode: .fill) + .frame(height: 200) // Limit size + .clipped() + } placeholder: { + Color.gray.frame(height: 200) + } + Text(item.shortDescription) // Just what's visible + } + } +} +``` + +**Pinned Views**: Use for sticky headers/footers. + +```swift +LazyVStack(pinnedViews: [.sectionHeaders]) { + ForEach(sections) { section in + Section { + ForEach(section.items) { item in + ItemRow(item: item) + } + } header: { + Text(section.title) + .font(.headline) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.gray.opacity(0.2)) + } + } +} +``` + + + +Understanding what triggers body recomputation and how to minimize it is critical for performance. + +## What Triggers Body Evaluation + +SwiftUI evaluates body when: + +1. **@State property changes**: View owns the state +2. **@Binding updates**: Parent changed bound value +3. **@Observable property accessed in body changes**: Fine-grained observation +4. **ObservableObject publishes change**: Any @Published property (not fine-grained) +5. **@Environment value changes**: Environment changed +6. **Parent view recreates child**: Parent's body evaluated with different child value + +```swift +struct ProfileView: View { + @State private var name = "User" // Change triggers body + @State private var age = 25 // Change triggers body + let id: UUID // Never changes - no trigger + + var body: some View { + let _ = Self._printChanges() // Debug what changed + + VStack { + Text("Name: \(name)") // Depends on name + Text("Age: \(age)") // Depends on age + } + } +} +``` + +## Minimizing Recomputation + +### Extract Subviews + +Move stable content into separate views to prevent recomputation: + +```swift +// Bad - entire body recomputes on count change +struct ContentView: View { + @State private var count = 0 + + var body: some View { + VStack { + ExpensiveHeaderView() // Recomputes unnecessarily + Text("Count: \(count)") + Button("Increment") { count += 1 } + ExpensiveFooterView() // Recomputes unnecessarily + } + } +} + +// Good - isolate expensive views +struct ContentView: View { + @State private var count = 0 + + var body: some View { + VStack { + StaticHeader() // Separate view - stable identity + CounterDisplay(count: count) // Only this recomputes + StaticFooter() // Separate view - stable identity + } + } +} + +struct StaticHeader: View { + var body: some View { + ExpensiveHeaderView() // Body only called once + } +} +``` + +### Avoid Expensive Computations in Body + +```swift +// Bad - recalculates on every body evaluation +struct ListView: View { + let items: [Item] + + var body: some View { + let sortedItems = items.sorted { $0.date > $1.date } // Expensive! + List(sortedItems) { item in + Text(item.name) + } + } +} + +// Good - compute once, cache result +struct ListView: View { + let items: [Item] + + private var sortedItems: [Item] { + items.sorted { $0.date > $1.date } + } + + var body: some View { + List(sortedItems) { item in + Text(item.name) + } + } +} + +// Better - compute outside view if possible +struct ListView: View { + let sortedItems: [Item] // Parent sorted once + + var body: some View { + List(sortedItems) { item in + Text(item.name) + } + } +} +``` + +### Use Equatable for Custom Comparison + +Tell SwiftUI exactly when to recompute by conforming to Equatable: + +```swift +struct ItemDetailView: View, Equatable { + let item: Item + let metadata: Metadata // Large, rarely changes + + static func == (lhs: ItemDetailView, rhs: ItemDetailView) -> Bool { + lhs.item.id == rhs.item.id // Only recompute if item ID changes + // Ignores metadata changes + } + + var body: some View { + VStack { + Text(item.name) + Text(metadata.description) + } + } +} + +// Use with .equatable() modifier +ParentView { + ItemDetailView(item: item, metadata: metadata) + .equatable() // Uses custom == for comparison +} +``` + +### Scope Data Sources Appropriately + +```swift +// Bad - entire hierarchy recomputes +struct AppView: View { + @State private var settings = AppSettings() // Top-level state + + var body: some View { + NavigationStack { + ContentView() // Recomputes when settings change + .environment(settings) + } + } +} + +// Good - state lives close to where it's used +struct SettingsButton: View { + @State private var showSettings = false // Local state + + var body: some View { + Button("Settings") { showSettings = true } + .sheet(isPresented: $showSettings) { + SettingsView() + } + } +} +``` + +## Understanding Body Evaluation vs Rendering + +**Critical distinction**: Body evaluation ≠ rendering to screen. + +```swift +// Body evaluated frequently... +struct CounterView: View { + @State private var count = 0 + + var body: some View { + // This code runs on every evaluation + VStack { + Text("Count: \(count)") // ...but SwiftUI only renders if text changed + Button("Increment") { count += 1 } + } + } +} +``` + +SwiftUI evaluates body, then diffs the result. If nothing changed, no rendering occurs. This is why expensive computations hurt even if output is identical. + + + +**@Observable** (iOS 17+) provides superior performance compared to ObservableObject through fine-grained change tracking. + +## ObservableObject Limitations + +```swift +// ObservableObject - coarse-grained updates +class UserSettings: ObservableObject { + @Published var username = "User" + @Published var theme = "Light" + @Published var notifications = true +} + +struct ProfileView: View { + @ObservedObject var settings: UserSettings + + var body: some View { + VStack { + Text(settings.username) // Only reads username... + } + // ...but body recomputes when theme or notifications change! + } +} +``` + +**Problem**: If ANY @Published property changes, ALL views observing the object recompute, regardless of which properties they actually read. + +## @Observable Solution + +```swift +// @Observable - fine-grained updates +@Observable +class UserSettings { + var username = "User" + var theme = "Light" + var notifications = true +} + +struct ProfileView: View { + @State var settings: UserSettings + + var body: some View { + VStack { + Text(settings.username) // Only reads username... + } + // ...body only recomputes when username changes! + } +} +``` + +**Benefit**: Body only evaluates when properties actually accessed in body change. Automatic, compiler-generated tracking. + +## Performance Impact + +Real-world measurements: + +- **80-90% fewer body evaluations** for views reading subset of properties +- **No Combine overhead**: @Observable uses Swift's observation system, not Combine +- **Automatic optimization**: No manual effort to minimize updates + +```swift +// Performance comparison +@Observable +class DataStore { + var items: [Item] = [] // Changes frequently + var settings: Settings = .default // Changes rarely +} + +struct ItemListView: View { + @State var store: DataStore + + var body: some View { + // With ObservableObject: recomputes on settings change (unnecessary) + // With @Observable: only recomputes on items change (correct) + List(store.items) { item in + ItemRow(item: item) + } + } +} +``` + +## Migration Guidelines + +**Use @Observable for new code**. It's simpler and faster: + +```swift +// Old pattern - remove +class ViewModel: ObservableObject { + @Published var name = "" + @Published var count = 0 +} + +struct OldView: View { + @StateObject private var viewModel = ViewModel() // ObservableObject +} + +// New pattern - use +@Observable +class ViewModel { + var name = "" + var count = 0 +} + +struct NewView: View { + @State private var viewModel = ViewModel() // @Observable +} +``` + +**Key differences**: + +1. No ObservableObject conformance +2. No @Published wrapper +3. Use @State (not @StateObject) for ownership +4. Use @Bindable for bindings + +```swift +@Observable +class FormData { + var name = "" + var email = "" +} + +struct FormView: View { + @State private var formData = FormData() + + var body: some View { + Form { + // Need @Bindable for bindings + TextField("Name", text: $formData.name) + TextField("Email", text: $formData.email) + } + } +} + +// Alternative: @Bindable parameter +struct FormFields: View { + @Bindable var formData: FormData + + var body: some View { + Form { + TextField("Name", text: $formData.name) + TextField("Email", text: $formData.email) + } + } +} +``` + +## Important: @State Behavior Difference + +Critical difference between @StateObject and @State: + +```swift +// ObservableObject with @StateObject +class OldModel: ObservableObject { + init() { print("OldModel init") } +} + +struct OldView: View { + @StateObject private var model = OldModel() + // Prints "OldModel init" ONCE - @StateObject preserves across view recreations +} + +// @Observable with @State +@Observable +class NewModel { + init() { print("NewModel init") } +} + +struct NewView: View { + @State private var model = NewModel() + // Prints "NewModel init" on EVERY view recreation! + // SwiftUI preserves the instance, but re-runs initializer +} +``` + +**Best practice**: Only use @State for @Observable at the view that creates the instance. Pass to child views without @State: + +```swift +struct ParentView: View { + @State private var model = DataModel() // Owner uses @State + + var body: some View { + ChildView(model: model) // Child receives plain reference + } +} + +struct ChildView: View { + let model: DataModel // NOT @State + + var body: some View { + Text(model.name) // Still reactive - automatic observation + } +} +``` + + + +Image loading and rendering are common performance bottlenecks. SwiftUI provides AsyncImage for remote images, but requires careful optimization. + +## AsyncImage Basics + +```swift +// Basic AsyncImage +AsyncImage(url: URL(string: "https://example.com/image.jpg")) { image in + image + .resizable() + .aspectRatio(contentMode: .fill) +} placeholder: { + ProgressView() +} +``` + +## Critical Issue: AsyncImage Does Not Cache + +**Important**: AsyncImage does NOT cache images between screen loads. Scrolling an image off-screen and back may trigger a new network request. + +```swift +// Problem: re-downloads on scroll +ScrollView { + LazyVStack { + ForEach(items) { item in + AsyncImage(url: item.imageURL) { image in + image.resizable() + } placeholder: { + ProgressView() + } + // Scrolls off screen -> image released + // Scrolls back on screen -> downloads again! + } + } +} +``` + +## Solution 1: Configure URLCache + +AsyncImage uses URLSession.shared, which respects URLCache. Configure cache size: + +```swift +// In @main App init +@main +struct MyApp: App { + init() { + // Configure URLCache for AsyncImage + URLCache.shared.memoryCapacity = 50_000_000 // 50 MB memory + URLCache.shared.diskCapacity = 1_000_000_000 // 1 GB disk + } + + var body: some Scene { + WindowGroup { + ContentView() + } + } +} +``` + +**Limitation**: URLCache respects HTTP cache headers. If server doesn't provide appropriate headers, caching may not work as expected. + +## Solution 2: Build Custom Cached AsyncImage + +Use NSCache for in-memory caching with custom control: + +```swift +// Image cache manager +@Observable +class ImageCache { + static let shared = ImageCache() + private var cache = NSCache() + + func get(url: String) -> UIImage? { + cache.object(forKey: url as NSString) + } + + func set(url: String, image: UIImage) { + cache.setObject(image, forKey: url as NSString) + } +} + +// Cached AsyncImage view +struct CachedAsyncImage: View { + let url: URL? + let content: (Image) -> Content + let placeholder: () -> Placeholder + + @State private var image: UIImage? + + var body: some View { + Group { + if let image { + content(Image(uiImage: image)) + } else { + placeholder() + .task { + await loadImage() + } + } + } + } + + private func loadImage() async { + guard let url else { return } + + // Check cache first + if let cached = ImageCache.shared.get(url: url.absoluteString) { + image = cached + return + } + + // Download if not cached + do { + let (data, _) = try await URLSession.shared.data(from: url) + if let downloaded = UIImage(data: data) { + ImageCache.shared.set(url: url.absoluteString, image: downloaded) + image = downloaded + } + } catch { + print("Failed to load image: \(error)") + } + } +} +``` + +## Solution 3: Use Third-Party Libraries + +For production apps, consider mature image loading libraries: + +- **Nuke**: High-performance image loading with aggressive caching +- **Kingfisher**: Feature-rich with SwiftUI support +- **SDWebImage**: Battle-tested, widely used + +```swift +// Example with third-party library +import Nuke +import NukeUI + +LazyImage(url: item.imageURL) { state in + if let image = state.image { + image.resizable().aspectRatio(contentMode: .fill) + } else { + ProgressView() + } +} +``` + +## Image Sizing Best Practices + +**Always specify image dimensions** to prevent SwiftUI from using full resolution: + +```swift +// Bad - loads full resolution image +AsyncImage(url: imageURL) { image in + image.resizable() // Loads 4K image for 100x100 display +} + +// Good - constrains size +AsyncImage(url: imageURL) { image in + image + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 100, height: 100) // Limits memory usage + .clipped() +} + +// Better - serve appropriately sized images +// Use CDN or server-side resizing to deliver thumbnails, not full resolution +AsyncImage(url: item.thumbnailURL) { image in // 200x200 version + image + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 100, height: 100) +} +``` + +## Prefetching for Scrolling + +Prefetch images just before they become visible: + +```swift +struct OptimizedImageList: View { + let items: [Item] + + var body: some View { + ScrollView { + LazyVStack { + ForEach(items) { item in + CachedAsyncImage(url: item.imageURL) { image in + image.resizable() + } placeholder: { + Color.gray + } + .onAppear { + // Prefetch next items + prefetchNextImages(after: item) + } + } + } + } + } + + private func prefetchNextImages(after item: Item) { + guard let index = items.firstIndex(where: { $0.id == item.id }) else { return } + let nextItems = items.dropFirst(index + 1).prefix(3) + + Task { + for nextItem in nextItems { + // Start download without displaying + _ = try? await URLSession.shared.data(from: nextItem.imageURL) + } + } + } +} +``` + + + +Xcode's Instruments app provides powerful profiling for SwiftUI performance analysis. + +## Starting a Profile Session + +1. Build in Release mode: Product > Profile (Cmd+I) +2. Select **SwiftUI** template (Xcode 16+) or **Time Profiler** (earlier versions) +3. **Always profile on real device**, never simulator + +```bash +# Release mode optimizations match production +# Simulator performance doesn't reflect real device +``` + +## SwiftUI Instruments Template (Xcode 16+) + +The SwiftUI template includes specialized tracks: + +### 1. Update Groups Lane + +Shows when SwiftUI is performing update work. If CPU spikes when this lane is empty, the bottleneck is outside SwiftUI (networking, data processing, etc.). + +### 2. View Body Lane + +Tracks how often view body properties are evaluated. + +**Key metrics**: +- **Count**: Number of times body evaluated +- **Avg Duration**: Average time per evaluation +- **Total Duration**: Cumulative time + +**What to look for**: +- Views with high count but low duration: Unnecessary evaluations (fix with Equatable, extract subviews) +- Views with high duration: Expensive computations in body (move outside body) + +### 3. View Properties Lane + +Shows every view property change. Property updates are more frequent than body updates (SwiftUI batches multiple property changes into single body update). + +**Use to identify**: +- Properties updating more frequently than expected +- Cascading updates from parent to children + +### 4. Core Animation Commits Lane + +Shows when SwiftUI commits changes to Core Animation for rendering. Expensive commits indicate actual pixel changes on screen. + +**Correlation**: +- Many body evaluations + few commits = good (SwiftUI diffing working) +- Many commits = actual rendering work (investigate why so many pixel changes) + +### 5. Time Profiler Lane + +Shows CPU usage by function. Reveals which code is running and how long. + +**How to use**: +1. Record profile session +2. Stop after representative user interaction +3. Look for heavy call stacks +4. Drill into SwiftUI view types to find bottlenecks + +## Analyzing Body Evaluations + +After profiling, Instruments shows "All Updates Summary": + +```swift +// Example summary +ViewType Count Avg Duration Total Duration +------------------------------------------------------------------ +ProductListView 456 2.3ms 1,048ms +ProductCard 2,340 0.8ms 1,872ms +HeaderView 1 0.2ms 0.2ms +``` + +**Interpretation**: +- ProductListView: 456 evaluations in one session is suspicious - should be much fewer +- ProductCard: High count expected (many instances), but 0.8ms average is acceptable +- HeaderView: 1 evaluation is ideal for static content + +## Finding Excessive Updates + +Use Cmd+1 or select "Summary: All Updates" from jump bar: + +```swift +// Views updating the most appear at top +// Click view name -> see what triggered updates +``` + +Look for: +- Static views updating repeatedly (should be 1-2 times) +- Views updating when dependencies haven't changed +- Cascading updates (parent change triggers all children) + +## Debugging with _printChanges() + +Combine Instruments with runtime debugging: + +```swift +struct ProblematicView: View { + @State private var count = 0 + @State private var name = "Test" + + var body: some View { + let _ = Self._printChanges() // Prints to Xcode console + + VStack { + Text("Count: \(count)") + Text("Name: \(name)") + } + } +} + +// Console output when count changes: +// ProblematicView: @self, @identity, _count changed. +``` + +## Common Findings and Solutions + +| Finding | Cause | Solution | +|---------|-------|----------| +| Header view updates 100+ times | Parent state change | Extract to separate view | +| Image view high duration | Full resolution loading | Constrain frame size | +| List scrolling causes body storm | Expensive row computations | Move computation outside body | +| State changes cause app-wide updates | Top-level state | Move state closer to usage | + +## Weekly Profiling Practice + +Profile incrementally to catch performance regressions early: + +```swift +// Profiling routine +1. Profile baseline before changes +2. Implement feature +3. Profile again +4. Compare metrics +5. Fix regressions before merging +``` + +Small, consistent profiling catches issues when they're easy to fix, rather than debugging performance problems across large changesets. + + + +Specific techniques for optimizing SwiftUI performance. + +## 1. Task Prioritization with Priority + +Control async task priority to keep UI responsive: + +```swift +struct DataLoadingView: View { + @State private var essentialData: [Item] = [] + @State private var optionalData: [Detail] = [] + + var body: some View { + VStack { + List(essentialData) { item in + ItemRow(item: item) + } + } + .task(priority: .high) { + // Load critical data first + essentialData = await dataStore.loadEssentialData() + } + .task(priority: .low) { + // Load nice-to-have data later + optionalData = await dataStore.loadOptionalData() + } + } +} +``` + +## 2. Pagination for Large Datasets + +Load data in chunks as user scrolls: + +```swift +struct PaginatedListView: View { + @State private var items: [Item] = [] + @State private var page = 1 + @State private var isLoading = false + + var body: some View { + ScrollView { + LazyVStack { + ForEach(items) { item in + ItemRow(item: item) + .onAppear { + loadMoreIfNeeded(currentItem: item) + } + } + + if isLoading { + ProgressView() + } + } + } + .task { + await loadPage() + } + } + + private func loadMoreIfNeeded(currentItem: Item) { + guard let index = items.firstIndex(where: { $0.id == currentItem.id }) else { return } + + // Load next page when reaching last 5 items + if index >= items.count - 5 && !isLoading { + Task { + await loadPage() + } + } + } + + private func loadPage() async { + guard !isLoading else { return } + isLoading = true + + let newItems = await dataStore.loadItems(page: page) + items.append(contentsOf: newItems) + page += 1 + + isLoading = false + } +} +``` + +## 3. Debouncing Expensive Updates + +Delay expensive operations while user is typing: + +```swift +struct SearchView: View { + @State private var searchText = "" + @State private var searchResults: [Item] = [] + @State private var searchTask: Task? + + var body: some View { + VStack { + TextField("Search", text: $searchText) + .onChange(of: searchText) { oldValue, newValue in + // Cancel previous search + searchTask?.cancel() + + // Debounce: wait 300ms before searching + searchTask = Task { + try? await Task.sleep(for: .milliseconds(300)) + guard !Task.isCancelled else { return } + await performSearch(query: newValue) + } + } + + List(searchResults) { result in + Text(result.name) + } + } + } + + private func performSearch(query: String) async { + searchResults = await searchService.search(query) + } +} +``` + +## 4. Drawing Performance with Canvas + +For complex custom drawing, use Canvas instead of GeometryReader and Path: + +```swift +// Slow - triggers relayout frequently +struct SlowGraph: View { + let data: [Double] + + var body: some View { + GeometryReader { geometry in + Path { path in + // Complex path drawing + for (index, value) in data.enumerated() { + let x = CGFloat(index) * geometry.size.width / CGFloat(data.count) + let y = geometry.size.height * (1 - CGFloat(value)) + if index == 0 { + path.move(to: CGPoint(x: x, y: y)) + } else { + path.addLine(to: CGPoint(x: x, y: y)) + } + } + } + .stroke(Color.blue, lineWidth: 2) + } + } +} + +// Fast - optimized drawing +struct FastGraph: View { + let data: [Double] + + var body: some View { + Canvas { context, size in + var path = Path() + for (index, value) in data.enumerated() { + let x = CGFloat(index) * size.width / CGFloat(data.count) + let y = size.height * (1 - CGFloat(value)) + if index == 0 { + path.move(to: CGPoint(x: x, y: y)) + } else { + path.addLine(to: CGPoint(x: x, y: y)) + } + } + context.stroke(path, with: .color(.blue), lineWidth: 2) + } + } +} +``` + +## 5. Reduce Modifier Overhead + +Combine modifiers when possible: + +```swift +// Multiple modifier evaluations +Text("Hello") + .foregroundStyle(.blue) + .font(.headline) + .padding() + .background(.gray) + .cornerRadius(8) + +// Combined where possible - no performance gain in most cases, +// but clearer code. SwiftUI optimizes modifier chains automatically. +// Real optimization: avoid conditional modifiers if value doesn't change. + +// Inefficient - creates new modifier on every body evaluation +.opacity(isVisible ? 1.0 : 1.0) // Condition always results in same value + +// Efficient - only apply when needed +.opacity(isVisible ? 1.0 : 0.0) +``` + +## 6. PreferenceKey for Bottom-Up Communication + +Use PreferenceKey instead of @Binding for child-to-parent data flow when performance matters: + +```swift +struct SizePreferenceKey: PreferenceKey { + static var defaultValue: CGSize = .zero + static func reduce(value: inout CGSize, nextValue: () -> CGSize) { + value = nextValue() + } +} + +struct ParentView: View { + @State private var childSize: CGSize = .zero + + var body: some View { + VStack { + Text("Child size: \(childSize.width) x \(childSize.height)") + + ChildView() + .onPreferenceChange(SizePreferenceKey.self) { size in + childSize = size + } + } + } +} + +struct ChildView: View { + var body: some View { + Text("Hello") + .background( + GeometryReader { geometry in + Color.clear.preference( + key: SizePreferenceKey.self, + value: geometry.size + ) + } + ) + } +} +``` + + + +When to investigate performance and what to optimize. + +## Should You Optimize? + +``` +Is there a user-facing performance issue? +├─ No → Don't optimize +└─ Yes → Continue + +Have you profiled with Instruments? +├─ No → Profile first (never optimize without data) +└─ Yes → Continue + +Did profiling identify a specific bottleneck? +├─ No → Issue might not be SwiftUI (check networking, data layer) +└─ Yes → Continue + +Is the bottleneck in SwiftUI view updates? +├─ No → Optimize data layer, networking, image loading +└─ Yes → Continue to optimization strategies +``` + +## Optimization Priority + +**1. High Impact, Low Effort**: +- Switch VStack to LazyVStack for long lists +- Configure URLCache for AsyncImage +- Extract static subviews from frequently updating views + +**2. High Impact, Medium Effort**: +- Replace ObservableObject with @Observable +- Implement pagination for large datasets +- Add custom Equatable to expensive views + +**3. Medium Impact, Low Effort**: +- Debounce text field updates +- Use task priority for non-critical work +- Constrain image sizes with frame modifiers + +**4. Medium Impact, High Effort**: +- Build custom cached image loading +- Rewrite complex views to reduce body complexity +- Implement view recycling for very large datasets + +**5. Low Priority** (only if profiling shows specific issue): +- Optimize modifier ordering +- Use Canvas for complex drawing +- PreferenceKey instead of Binding + +## Red Flags Requiring Optimization + +| Symptom | Likely Cause | Action | +|---------|--------------|--------| +| Scrolling stutters | Heavy row views | Profile, use lazy loading, simplify rows | +| Typing lags | Expensive search on every keystroke | Debounce, move work off main thread | +| Navigation slow | Loading all data upfront | Implement pagination, async loading | +| App hangs on launch | Too much work in view init | Move to task, use loading states | +| Memory growing unbounded | Images not releasing | Implement image cache with limits | + +## Performance Targets + +**Scrolling**: Maintain 60fps (16.67ms per frame) +- Budget ~10ms for SwiftUI updates +- Budget ~6ms for rendering + +**Interactions**: Respond within 100ms +- User perceives instant response < 100ms +- 100-300ms feels sluggish +- \> 300ms feels broken + +**Launch**: Show content within 1 second +- Use skeleton screens / placeholders +- Load critical content first, optional content later + + + +Common performance mistakes and how to avoid them. + +## 1. Using @State with Reference Types + +**Problem**: @State creates new instance on every view recreation when used with classes. + +```swift +// Wrong - creates new instance repeatedly +struct BadView: View { + @State private var viewModel = ViewModel() // ViewModel is a class + + var body: some View { + Text(viewModel.text) + } +} + +// Correct - use @Observable and @State for iOS 17+ +@Observable +class ViewModel { + var text = "Hello" +} + +struct GoodView: View { + @State private var viewModel = ViewModel() + + var body: some View { + Text(viewModel.text) + } +} + +// Alternative for iOS 16- - use @StateObject with ObservableObject +class LegacyViewModel: ObservableObject { + @Published var text = "Hello" +} + +struct LegacyView: View { + @StateObject private var viewModel = LegacyViewModel() + + var body: some View { + Text(viewModel.text) + } +} +``` + +## 2. Overusing AnyView + +**Problem**: Type erasure prevents SwiftUI from diffing efficiently, forcing complete view recreation. + +```swift +// Wrong - loses type information +func makeView(type: ViewType) -> some View { + switch type { + case .text: + return AnyView(Text("Hello")) + case .image: + return AnyView(Image(systemName: "star")) + } +} + +// Correct - preserve types with @ViewBuilder +@ViewBuilder +func makeView(type: ViewType) -> some View { + switch type { + case .text: + Text("Hello") + case .image: + Image(systemName: "star") + } +} + +// Alternative - use Group for conditional views +var body: some View { + Group { + if showText { + Text("Hello") + } else { + Image(systemName: "star") + } + } +} +``` + +## 3. Creating New Objects in Body + +**Problem**: Every body evaluation creates new instances, preventing SwiftUI from recognizing stable values. + +```swift +// Wrong - creates new DateFormatter on every body evaluation +struct BadDateView: View { + let date: Date + + var body: some View { + let formatter = DateFormatter() // New instance every time! + formatter.dateStyle = .medium + return Text(formatter.string(from: date)) + } +} + +// Correct - create once, reuse +struct GoodDateView: View { + let date: Date + + private static let formatter: DateFormatter = { + let f = DateFormatter() + f.dateStyle = .medium + return f + }() + + var body: some View { + Text(Self.formatter.string(from: date)) + } +} + +// Alternative - use built-in formatters +struct BetterDateView: View { + let date: Date + + var body: some View { + Text(date, style: .date) // SwiftUI handles formatting + } +} +``` + +## 4. Branching on View State Instead of Modifiers + +**Problem**: Branches change structural identity, losing state and triggering transitions. + +```swift +// Wrong - structural identity changes on toggle +struct BadToggleView: View { + @State private var isExpanded = false + + var body: some View { + if isExpanded { + ExpandedContentView() // Destroyed on collapse + } else { + CollapsedContentView() // Destroyed on expand + } + } +} + +// Correct - preserve identity with conditional modifiers +struct GoodToggleView: View { + @State private var isExpanded = false + + var body: some View { + ContentView(isExpanded: isExpanded) // Same view, different state + } +} + +// Alternative - use opacity/frame to hide +struct AlternativeToggleView: View { + @State private var isExpanded = false + + var body: some View { + VStack { + HeaderView() + + DetailView() + .frame(height: isExpanded ? nil : 0) // Collapse without destroying + .opacity(isExpanded ? 1 : 0) + } + } +} +``` + +## 5. Excessive GeometryReader Usage + +**Problem**: GeometryReader recalculates on every layout change, triggering cascade of updates. + +```swift +// Wrong - unnecessary GeometryReader +struct BadLayout: View { + var body: some View { + GeometryReader { geometry in + VStack { + Text("Width: \(geometry.size.width)") + .frame(width: geometry.size.width * 0.8) // Could use .frame(maxWidth:) + } + } + } +} + +// Correct - use frame modifiers +struct GoodLayout: View { + var body: some View { + VStack { + Text("Responsive width") + .frame(maxWidth: .infinity) // Fills available space + .padding(.horizontal) // 80% width effect + } + } +} + +// Use GeometryReader only when truly needed +struct ValidGeometryUse: View { + var body: some View { + GeometryReader { geometry in + // Valid: need actual size for custom drawing + CustomShape(size: geometry.size) + } + } +} +``` + +## 6. Not Using Lazy Containers for Long Lists + +**Problem**: Non-lazy stacks create all views immediately, consuming excessive memory. + +```swift +// Wrong - loads all 1000 items immediately +ScrollView { + VStack { + ForEach(0..<1000) { index in + HeavyItemView(index: index) // All 1000 created at once + } + } +} + +// Correct - lazy loading +ScrollView { + LazyVStack { + ForEach(0..<1000) { index in + HeavyItemView(index: index) // Created as scrolled into view + } + } +} +``` + +## 7. Performing Expensive Work on Main Thread + +**Problem**: Blocking main thread makes UI unresponsive. + +```swift +// Wrong - expensive work blocks UI +struct BadDataView: View { + @State private var data: [Item] = [] + + var body: some View { + List(data) { item in + Text(item.name) + } + .onAppear { + // Blocks UI while loading + data = loadDataFromDisk() // Expensive! + } + } +} + +// Correct - async work off main thread +struct GoodDataView: View { + @State private var data: [Item] = [] + @State private var isLoading = true + + var body: some View { + Group { + if isLoading { + ProgressView() + } else { + List(data) { item in + Text(item.name) + } + } + } + .task { + // Runs on background thread + data = await loadDataAsync() + isLoading = false + } + } +} +``` + +## 8. Using ObservableObject Without Scoping Published Properties + +**Problem**: Views recompute when any @Published property changes, even ones they don't use. + +```swift +// Problematic - view recomputes on all changes +class AppState: ObservableObject { + @Published var userProfile: User? // Changes rarely + @Published var unreadCount: Int = 0 // Changes frequently + @Published var networkStatus: Status = .online // Changes frequently +} + +struct ProfileView: View { + @ObservedObject var appState: AppState + + var body: some View { + // Only uses userProfile, but recomputes on unreadCount changes! + Text(appState.userProfile?.name ?? "") + } +} + +// Solution 1: Use @Observable (iOS 17+) for fine-grained observation +@Observable +class AppState { + var userProfile: User? // ProfileView only observes this + var unreadCount: Int = 0 + var networkStatus: Status = .online +} + +// Solution 2: Split into focused ObservableObjects +class UserState: ObservableObject { + @Published var profile: User? +} + +class NotificationState: ObservableObject { + @Published var unreadCount: Int = 0 +} + +struct ProfileView: View { + @ObservedObject var userState: UserState // Only observes relevant state + + var body: some View { + Text(userState.profile?.name ?? "") + } +} +``` + +These anti-patterns account for the majority of SwiftUI performance issues. Profiling with Instruments reveals which patterns affect your specific app. + + +--- + +## Sources + +- [Optimizing SwiftUI Performance: Best Practices](https://medium.com/@garejakirit/optimizing-swiftui-performance-best-practices-93b9cc91c623) +- [Demystify SwiftUI performance - WWDC23](https://developer.apple.com/videos/play/wwdc2023/10160/) +- [Making our production SwiftUI app 100x faster — Clay](https://clay.earth/stories/production-swiftui-performance-increase) +- [How the SwiftUI View Lifecycle and Identity work - DoorDash Engineering](https://doordash.engineering/2022/05/31/how-the-swiftui-view-lifecycle-and-identity-work/) +- [Identity in SwiftUI - Geek Culture](https://medium.com/geekculture/identity-in-swiftui-6aacf8f587d9) +- [Demystify SwiftUI - WWDC21](https://developer.apple.com/videos/play/wwdc2021/10022/) +- [id(_): Identifying SwiftUI Views - The SwiftUI Lab](https://swiftui-lab.com/swiftui-id/) +- [How to use Instruments to profile your SwiftUI code - Hacking with Swift](https://www.hackingwithswift.com/quick-start/swiftui/how-to-use-instruments-to-profile-your-swiftui-code-and-identify-slow-layouts) +- [Profiling SwiftUI app using Instruments - Swift with Majid](https://swiftwithmajid.com/2021/01/20/profiling-swiftui-app-using-instruments/) +- [@Observable Macro performance increase over ObservableObject](https://www.avanderlee.com/swiftui/observable-macro-performance-increase-observableobject/) +- [@Observable vs ObservableObject in SwiftUI - Malcolm Hall](https://www.malcolmhall.com/2024/04/22/observable-vs-observableobject-in-swiftui/) +- [Tuning Lazy Stacks and Grids in SwiftUI: A Performance Guide](https://medium.com/@wesleymatlock/tuning-lazy-stacks-and-grids-in-swiftui-a-performance-guide-2fb10786f76a) +- [Tips and Considerations for Using Lazy Containers in SwiftUI](https://fatbobman.com/en/posts/tips-and-considerations-for-using-lazy-containers-in-swiftui/) +- [List or LazyVStack - Choosing the Right Lazy Container in SwiftUI](https://fatbobman.com/en/posts/list-or-lazyvstack/) +- [Optimizing AsyncImage in SwiftUI: Build a Custom Cached Solution](https://medium.com/@sviatoslav.kliuchev/improve-asyncimage-in-swiftui-5aae28f1a331) +- [AsyncImage in SwiftUI: Loading Images from URLs with Caching](https://matteomanferdini.com/swiftui-asyncimage/) +- [SwiftUI Performance and Stability: Avoiding the Most Costly Mistakes](https://dev.to/arshtechpro/swiftui-performance-and-stability-avoiding-the-most-costly-mistakes-234c) +- [Common SwiftUI Mistakes - Hacking with Swift](https://www.hackingwithswift.com/articles/224/common-swiftui-mistakes-and-how-to-fix-them) +- [Avoiding having to recompute values within SwiftUI views - Swift by Sundell](https://www.swiftbysundell.com/articles/avoiding-swiftui-value-recomputation/) +- [Optimizing SwiftUI: Reducing Body Recalculation and Minimizing @State Updates](https://medium.com/@wesleymatlock/optimizing-swiftui-reducing-body-recalculation-and-minimizing-state-updates-8f7944253725) +- [How to Avoid Repeating SwiftUI View Updates](https://fatbobman.com/en/posts/avoid_repeated_calculations_of_swiftui_views/) diff --git a/src/resources/skills/swiftui/references/platform-integration.md b/src/resources/skills/swiftui/references/platform-integration.md new file mode 100644 index 000000000..47ec2ad20 --- /dev/null +++ b/src/resources/skills/swiftui/references/platform-integration.md @@ -0,0 +1,204 @@ + +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 + + + +## 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 +``` + + + +## 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 +} +``` + + + +## 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") { } + } +} +``` + + + +## 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 +``` + + + +## 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() +} +``` + + + +## 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 +} +``` + + + +## 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 + + + +## What NOT to Do + + +**Problem:** Platform checks everywhere +**Instead:** Extract to platform-specific files + + + +**Problem:** iOS patterns on macOS +**Instead:** Respect each platform's conventions + + + +**Problem:** Missing real device behaviors +**Instead:** Test on physical devices + + diff --git a/src/resources/skills/swiftui/references/state-management.md b/src/resources/skills/swiftui/references/state-management.md new file mode 100644 index 000000000..f355dc11f --- /dev/null +++ b/src/resources/skills/swiftui/references/state-management.md @@ -0,0 +1,1443 @@ + +SwiftUI state management is fundamentally different from imperative UI frameworks. You describe what the UI should look like for any given state, and SwiftUI handles the updates when state changes. + +**Read this file when:** Building views that need to respond to data changes, sharing data between views, choosing property wrappers, debugging state issues, or migrating from ObservableObject patterns. + +**Key insight:** SwiftUI uses a declarative, unidirectional data flow. State flows down through the view hierarchy via properties. Changes flow up through bindings or actions. You describe state, not mutations. + +**Modern SwiftUI (iOS 17+)** uses the @Observable macro for reference types, eliminating most needs for ObservableObject, @Published, @StateObject, @ObservedObject, and @EnvironmentObject. The mental model is simpler: value types use @State/@Binding, reference types use @Observable with @State/@Bindable. + + + +## Property Wrappers + + +**Purpose:** Manage mutable value types owned by a single view. The source of truth for simple, view-local data. + +**When to use:** Simple values (Int, String, Bool, Array, struct) that belong to this view and need to trigger UI updates when changed. + +**Ownership:** The view owns this data. SwiftUI manages its lifecycle. + +**Lifecycle:** Persists across view body recomputes. Reset when the view is removed from the hierarchy and recreated with a new identity. + +```swift +struct CounterView: View { + @State private var count = 0 + @State private var isExpanded = false + + var body: some View { + VStack { + Text("Count: \(count)") + + Button("Increment") { + count += 1 + } + + if isExpanded { + Text("Details about count...") + } + + Toggle("Show Details", isOn: $isExpanded) + } + } +} +``` + +**With @Observable classes (iOS 17+):** +```swift +@Observable +class ViewModel { + var items: [String] = [] + var selectedItem: String? +} + +struct ContentView: View { + @State private var viewModel = ViewModel() + + var body: some View { + List(viewModel.items, id: \.self) { item in + Text(item) + } + } +} +``` + +**Common mistakes:** +- Making @State public (should be private to enforce view-local ownership) +- Using @State for data passed from a parent (use @Binding or receive as plain property) +- Not initializing @State with a value +- Using @State with ObservableObject classes pre-iOS 17 (use @StateObject instead) + + + +**Purpose:** Create a two-way connection to state owned by another view. Allows a child view to read and write a parent's state without owning it. + +**When to use:** Passing writable access to value type data from parent to child. The child needs to modify data it doesn't own. + +**Ownership:** The parent owns the data. This view has read-write access via reference. + +**Lifecycle:** Tied to the source of truth it references. + +```swift +struct ParentView: View { + @State private var username = "" + + var body: some View { + VStack { + Text("Hello, \(username)") + UsernameField(username: $username) + } + } +} + +struct UsernameField: View { + @Binding var username: String + + var body: some View { + TextField("Enter name", text: $username) + .textFieldStyle(.roundedBorder) + .padding() + } +} +``` + +**With custom controls:** +```swift +struct ToggleButton: View { + @Binding var isOn: Bool + let label: String + + var body: some View { + Button(label) { + isOn.toggle() + } + .foregroundStyle(isOn ? .green : .gray) + } +} + +// Usage +struct ContentView: View { + @State private var notificationsEnabled = false + + var body: some View { + ToggleButton( + isOn: $notificationsEnabled, + label: "Notifications" + ) + } +} +``` + +**Common mistakes:** +- Providing a default value to @Binding (bindings are always passed from outside) +- Making @Binding private (it must be accessible to receive the binding) +- Passing the value without $ prefix (passes a copy, not a binding) +- Using @Binding when the child shouldn't modify the value (use a plain property instead) + + + +**Purpose:** Mark a class as observable so SwiftUI automatically tracks property changes and updates views. Replaces ObservableObject protocol in iOS 17+. + +**When to use:** Reference type data models shared across multiple views. Complex state that benefits from reference semantics. Data that needs to be passed down the view hierarchy. + +**Ownership:** Created and owned by a view using @State, or passed through @Environment for app-wide access. + +**Lifecycle:** Follows standard Swift reference type lifecycle. When stored in @State, survives view body recomputes. + +```swift +import Observation + +@Observable +class ShoppingCart { + var items: [Item] = [] + var discount: Double = 0.0 + + var total: Double { + let subtotal = items.reduce(0) { $0 + $1.price } + return subtotal * (1 - discount) + } + + func addItem(_ item: Item) { + items.append(item) + } +} + +struct StoreView: View { + @State private var cart = ShoppingCart() + + var body: some View { + VStack { + CartSummary(cart: cart) + ProductList(cart: cart) + } + } +} + +struct CartSummary: View { + var cart: ShoppingCart // Plain property, no wrapper needed + + var body: some View { + Text("Total: $\(cart.total, specifier: "%.2f")") + .font(.headline) + } +} +``` + +**With @ObservationIgnored for non-tracked properties:** +```swift +@Observable +class UserSession { + var username: String = "" + var loginCount: Int = 0 + + @ObservationIgnored + var temporaryCache: [String: Any] = [:] // Won't trigger view updates +} +``` + +**Common mistakes:** +- Using @Published with @Observable (not needed, all properties are observed by default) +- Forgetting to import Observation +- Using @StateObject instead of @State for @Observable classes +- Not using @ObservationIgnored for properties that shouldn't trigger updates (like caches, formatters) + + + +**Purpose:** Create bindings to properties of @Observable objects when the view doesn't own the object. Bridges @Observable with SwiftUI's $ binding syntax. + +**When to use:** You have an @Observable object passed from a parent, and you need to create two-way bindings to its properties (for TextField, Toggle, etc.). + +**Ownership:** The view doesn't own the object. It's passed from outside. + +**Lifecycle:** Tied to the lifecycle of the @Observable object it references. + +```swift +@Observable +class FormData { + var name: String = "" + var email: String = "" + var agreedToTerms: Bool = false +} + +struct ParentView: View { + @State private var formData = FormData() + + var body: some View { + FormView(formData: formData) + } +} + +struct FormView: View { + @Bindable var formData: FormData + + var body: some View { + Form { + TextField("Name", text: $formData.name) + TextField("Email", text: $formData.email) + Toggle("I agree to terms", isOn: $formData.agreedToTerms) + } + } +} +``` + +**Nested child views:** +```swift +struct NestedChildView: View { + @Bindable var formData: FormData + + var body: some View { + // Can still create bindings to properties + Toggle("Marketing emails", isOn: $formData.agreedToTerms) + } +} +``` + +**Common mistakes:** +- Using @Bindable when you own the object (use @State instead) +- Using @Binding for @Observable objects (use @Bindable for reference types) +- Forgetting that @Bindable doesn't work with ObservableObject (legacy pattern) +- Using @ObservedObject instead of @Bindable for iOS 17+ code + + + +**Purpose:** Read values from SwiftUI's environment or inject custom values accessible throughout the view hierarchy. Replaces @EnvironmentObject in iOS 17+. + +**When to use:** +- Accessing system values (colorScheme, locale, dismiss, etc.) +- Sharing app-wide or subtree-wide state without prop drilling +- Dependency injection for services and models + +**Ownership:** Provided by ancestor views or the system. Current view reads it. + +**Lifecycle:** Managed by the provider. Available to all descendant views. + +```swift +// System environment values +struct ThemedView: View { + @Environment(\.colorScheme) var colorScheme + @Environment(\.dismiss) var dismiss + + var body: some View { + VStack { + Text("Current theme: \(colorScheme == .dark ? "Dark" : "Light")") + + Button("Close") { + dismiss() + } + } + .foregroundStyle(colorScheme == .dark ? .white : .black) + } +} +``` + +**Custom environment values (iOS 17+):** +```swift +@Observable +class AppSettings { + var fontSize: Double = 16 + var accentColor: Color = .blue +} + +// In your app root +@main +struct MyApp: App { + @State private var settings = AppSettings() + + var body: some Scene { + WindowGroup { + ContentView() + .environment(settings) + } + } +} + +// Access in any descendant view +struct SettingsView: View { + @Environment(AppSettings.self) var settings + + var body: some View { + VStack { + Text("Font size: \(settings.fontSize)") + ColorPicker("Accent", selection: $settings.accentColor) + } + } +} +``` + +**Legacy custom environment values (pre-iOS 17):** +```swift +private struct ThemeKey: EnvironmentKey { + static let defaultValue = Theme.light +} + +extension EnvironmentValues { + var theme: Theme { + get { self[ThemeKey.self] } + set { self[ThemeKey.self] = newValue } + } +} + +// Usage +struct ContentView: View { + @Environment(\.theme) var theme + + var body: some View { + Text("Hello") + .foregroundStyle(theme.textColor) + } +} +``` + +**Common mistakes:** +- Using @EnvironmentObject instead of @Environment for iOS 17+ code +- Not providing the environment value before accessing it (runtime crash) +- Overusing environment for data that should be passed as properties +- Using environment for frequently changing values (can cause unnecessary updates) + + + +**Purpose:** Read and write UserDefaults values with automatic UI updates when the value changes. + +**When to use:** Storing user preferences, settings, or small amounts of persistent data that should survive app relaunches. + +**Ownership:** Backed by UserDefaults. View has read-write access. + +**Lifecycle:** Persists between app launches until explicitly removed. + +```swift +struct SettingsView: View { + @AppStorage("username") private var username = "Guest" + @AppStorage("notificationsEnabled") private var notificationsEnabled = true + @AppStorage("theme") private var theme = "system" + + var body: some View { + Form { + TextField("Username", text: $username) + + Toggle("Notifications", isOn: $notificationsEnabled) + + Picker("Theme", selection: $theme) { + Text("System").tag("system") + Text("Light").tag("light") + Text("Dark").tag("dark") + } + } + } +} +``` + +**With custom UserDefaults suite:** +```swift +struct SharedSettingsView: View { + @AppStorage("syncEnabled", store: UserDefaults(suiteName: "group.com.example.app")) + private var syncEnabled = false + + var body: some View { + Toggle("Sync", isOn: $syncEnabled) + } +} +``` + +**Supported types:** Bool, Int, Double, String, URL, Data + +**Common mistakes:** +- Storing sensitive data (UserDefaults is not encrypted) +- Storing large amounts of data (performance degradation) +- Using for data that changes frequently during a session (use @State instead) +- Not providing a default value +- Assuming cross-app synchronization (requires App Groups configuration) + + + +**Purpose:** Automatic state restoration per scene. Saves and restores values when the app is backgrounded/foregrounded or scenes are destroyed/recreated. + +**When to use:** Preserving UI state for state restoration (selected tab, scroll position, current navigation path, form data). + +**Ownership:** Managed per scene by the system. View has read-write access. + +**Lifecycle:** Persists when app backgrounds. Destroyed when user explicitly kills the app from the app switcher. + +```swift +struct ContentView: View { + @SceneStorage("selectedTab") private var selectedTab = "home" + + var body: some View { + TabView(selection: $selectedTab) { + HomeView() + .tabItem { Label("Home", systemImage: "house") } + .tag("home") + + ProfileView() + .tabItem { Label("Profile", systemImage: "person") } + .tag("profile") + } + } +} +``` + +**With navigation state:** +```swift +struct NavigationExample: View { + @SceneStorage("navigationPath") private var navigationPathData: Data? + @State private var path = NavigationPath() + + var body: some View { + NavigationStack(path: $path) { + List { + NavigationLink("Details", value: "details") + } + .navigationDestination(for: String.self) { value in + Text("Showing: \(value)") + } + } + .onAppear { + if let data = navigationPathData, + let restored = try? JSONDecoder().decode(NavigationPath.CodableRepresentation.self, from: data) { + path = NavigationPath(restored) + } + } + .onChange(of: path) { _, newPath in + if let representation = newPath.codable, + let data = try? JSONEncoder().encode(representation) { + navigationPathData = data + } + } + } +} +``` + +**Supported types:** Bool, Int, Double, String, URL, Data + +**Common mistakes:** +- Storing sensitive data (not secure) +- Storing large amounts of data (Apple warns against this) +- Expecting data to persist after force-quit (it's cleared) +- Using for cross-scene data (each scene has its own storage) +- Not providing a default value + + + +**Purpose:** Create and own an ObservableObject in a view. Ensures the object survives view body recomputes. + +**When to use:** Legacy code (pre-iOS 17) when you need to create and own an ObservableObject. For iOS 17+, use @State with @Observable instead. + +**Ownership:** The view owns and manages the object's lifecycle. + +**Lifecycle:** Created once when the view is initialized. Survives view body recomputes. Destroyed when view is removed. + +```swift +// Legacy pattern (pre-iOS 17) +class LegacyViewModel: ObservableObject { + @Published var count = 0 + @Published var items: [String] = [] +} + +struct LegacyView: View { + @StateObject private var viewModel = LegacyViewModel() + + var body: some View { + VStack { + Text("Count: \(viewModel.count)") + + Button("Increment") { + viewModel.count += 1 + } + } + } +} +``` + +**Common mistakes:** +- Using @StateObject for iOS 17+ (use @State with @Observable instead) +- Using @ObservedObject when the view creates the object (causes recreation bugs) +- Creating @StateObject in non-root views unnecessarily (consider passing from parent) +- Using for value types (use @State instead) + + + +**Purpose:** Observe an ObservableObject owned by another view. Doesn't create or own the object. + +**When to use:** Legacy code (pre-iOS 17) when receiving an ObservableObject from a parent. For iOS 17+, pass @Observable objects as plain properties. + +**Ownership:** Parent or external source owns the object. This view observes it. + +**Lifecycle:** Tied to the source that owns it. + +```swift +// Legacy pattern (pre-iOS 17) +class SharedViewModel: ObservableObject { + @Published var data: String = "" +} + +struct ParentView: View { + @StateObject private var viewModel = SharedViewModel() + + var body: some View { + ChildView(viewModel: viewModel) + } +} + +struct ChildView: View { + @ObservedObject var viewModel: SharedViewModel + + var body: some View { + Text(viewModel.data) + } +} +``` + +**Common mistakes:** +- Creating the object within the view using @ObservedObject (use @StateObject instead, or @State for @Observable) +- Using for iOS 17+ code (pass @Observable objects as plain properties) +- Confusing ownership (if you create it, you own it - use @StateObject not @ObservedObject) + + + + +## Choosing the Right Property Wrapper + +**iOS 17+ Decision Process:** + +1. **Is this a value type (Int, String, Bool, struct)?** + - Owned by this view? → `@State` + - Passed from parent, needs modification? → `@Binding` + - Just reading it? → Plain property + +2. **Is this an @Observable class?** + - Created and owned by this view? → `@State` + - Passed from parent, need to create bindings to properties? → `@Bindable` + - Passed from parent, just reading? → Plain property + - App-wide or subtree-wide access? → `@Environment` + +3. **Is this a system value or custom environment value?** + → `@Environment` + +4. **Does this need to persist to UserDefaults?** + → `@AppStorage` + +5. **Does this need automatic state restoration per scene?** + → `@SceneStorage` + +**Pre-iOS 17 Decision Process:** + +1. **Is this a value type?** + - Owned by this view? → `@State` + - Passed from parent? → `@Binding` + +2. **Is this an ObservableObject?** + - Created by this view? → `@StateObject` + - Passed from parent? → `@ObservedObject` + - App-wide access? → `@EnvironmentObject` + +3. **Is this a system value?** + → `@Environment` + +**Quick Reference Table:** + +| Data Type | Ownership | iOS 17+ | Pre-iOS 17 | +|-----------|-----------|---------|------------| +| Value type | Own | @State | @State | +| Value type | Parent owns, need write | @Binding | @Binding | +| Value type | Parent owns, read only | Plain property | Plain property | +| @Observable class | Own | @State | N/A | +| @Observable class | Parent owns, need bindings | @Bindable | N/A | +| @Observable class | Parent owns, read only | Plain property | N/A | +| @Observable class | App-wide | @Environment | N/A | +| ObservableObject | Own | N/A | @StateObject | +| ObservableObject | Parent owns | N/A | @ObservedObject | +| ObservableObject | App-wide | N/A | @EnvironmentObject | +| System values | N/A | @Environment | @Environment | +| UserDefaults | N/A | @AppStorage | @AppStorage | +| State restoration | N/A | @SceneStorage | @SceneStorage | + + + +## Common Patterns + + +**Use when:** Building any SwiftUI view hierarchy. This is the fundamental pattern. + +**Concept:** Data flows down the view hierarchy as properties. Changes flow up through bindings or callbacks. State has a single source of truth. + +**Implementation:** +```swift +@Observable +class AppState { + var items: [Item] = [] + var selectedItemId: UUID? + + func selectItem(_ id: UUID) { + selectedItemId = id + } + + func addItem(_ item: Item) { + items.append(item) + } +} + +struct AppView: View { + @State private var appState = AppState() + + var body: some View { + NavigationStack { + ItemList( + items: appState.items, + selectedId: appState.selectedItemId, + onSelect: { appState.selectItem($0) } + ) + } + } +} + +struct ItemList: View { + let items: [Item] + let selectedId: UUID? + let onSelect: (UUID) -> Void + + var body: some View { + List(items) { item in + ItemRow( + item: item, + isSelected: item.id == selectedId, + onTap: { onSelect(item.id) } + ) + } + } +} + +struct ItemRow: View { + let item: Item + let isSelected: Bool + let onTap: () -> Void + + var body: some View { + HStack { + Text(item.name) + if isSelected { + Image(systemName: "checkmark") + } + } + .onTapGesture(perform: onTap) + } +} +``` + +**Considerations:** +- Clear data flow is easier to debug than bidirectional mutations +- Callbacks can become verbose for deeply nested hierarchies (consider @Environment) +- Single source of truth prevents sync issues + + + +**Use when:** Multiple views need access to shared state without prop drilling. Dependency injection for services. + +**Implementation:** +```swift +@Observable +class UserSession { + var isLoggedIn = false + var username: String? + + func login(username: String) { + self.username = username + isLoggedIn = true + } + + func logout() { + username = nil + isLoggedIn = false + } +} + +@main +struct MyApp: App { + @State private var session = UserSession() + + var body: some Scene { + WindowGroup { + ContentView() + .environment(session) + } + } +} + +struct ContentView: View { + @Environment(UserSession.self) private var session + + var body: some View { + if session.isLoggedIn { + HomeView() + } else { + LoginView() + } + } +} + +struct LoginView: View { + @Environment(UserSession.self) private var session + @State private var username = "" + + var body: some View { + VStack { + TextField("Username", text: $username) + Button("Login") { + session.login(username: username) + } + } + } +} + +struct HomeView: View { + @Environment(UserSession.self) private var session + + var body: some View { + VStack { + Text("Welcome, \(session.username ?? "")") + Button("Logout") { + session.logout() + } + } + } +} +``` + +**Considerations:** +- Convenient for app-wide state (settings, auth, theme) +- Runtime crash if environment value not provided +- Can make testing harder (need to provide environment in previews/tests) +- Overuse can hide dependencies and make data flow unclear + + + +**Use when:** Computing values from other state. Avoid storing redundant state. + +**Implementation:** +```swift +@Observable +class ShoppingCart { + var items: [CartItem] = [] + var discountCode: String? + + // Derived - computed from items + var subtotal: Double { + items.reduce(0) { $0 + ($1.price * Double($1.quantity)) } + } + + // Derived - computed from subtotal and discountCode + var discount: Double { + guard let code = discountCode else { return 0 } + switch code { + case "SAVE10": return subtotal * 0.1 + case "SAVE20": return subtotal * 0.2 + default: return 0 + } + } + + // Derived - computed from subtotal and discount + var total: Double { + subtotal - discount + } +} + +struct CartView: View { + @State private var cart = ShoppingCart() + + var body: some View { + VStack { + List(cart.items) { item in + HStack { + Text(item.name) + Spacer() + Text("$\(item.price * Double(item.quantity), specifier: "%.2f")") + } + } + + Divider() + + HStack { + Text("Subtotal:") + Spacer() + Text("$\(cart.subtotal, specifier: "%.2f")") + } + + if cart.discount > 0 { + HStack { + Text("Discount:") + Spacer() + Text("-$\(cart.discount, specifier: "%.2f")") + .foregroundStyle(.green) + } + } + + HStack { + Text("Total:") + .bold() + Spacer() + Text("$\(cart.total, specifier: "%.2f")") + .bold() + } + } + } +} +``` + +**Considerations:** +- Computed properties are always in sync with source data +- No need to manually update derived state +- Recomputed on every access (cache if expensive) +- Keep computations simple or consider caching + + + +**Use when:** State only matters for presentation, not business logic (UI-only state like selection, expansion, animation). + +**Implementation:** +```swift +struct ExpandableCard: View { + let content: String + @State private var isExpanded = false // UI state only + + var body: some View { + VStack(alignment: .leading) { + HStack { + Text(content.prefix(50)) + .lineLimit(isExpanded ? nil : 1) + Spacer() + Image(systemName: isExpanded ? "chevron.up" : "chevron.down") + } + + if isExpanded { + Text(content) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .padding() + .background(.quaternary) + .cornerRadius(8) + .onTapGesture { + withAnimation { + isExpanded.toggle() + } + } + } +} +``` + +**Considerations:** +- Keeps business logic separate from UI state +- Resets naturally when view is recreated +- Makes components self-contained and reusable +- Consider if state needs to persist (use @SceneStorage for restoration) + + + +**Use when:** Two sibling views need to share mutable state. + +**Implementation:** +```swift +struct ParentView: View { + @State private var searchQuery = "" // Shared state lives in parent + + var body: some View { + VStack { + SearchBar(query: $searchQuery) // Pass binding to both siblings + SearchResults(query: searchQuery) + } + } +} + +struct SearchBar: View { + @Binding var query: String + + var body: some View { + TextField("Search", text: $query) + .textFieldStyle(.roundedBorder) + .padding() + } +} + +struct SearchResults: View { + let query: String + + var body: some View { + List { + // Filter results based on query + Text("Results for: \(query)") + } + } +} +``` + +**Considerations:** +- State lives in lowest common ancestor +- Clear data flow (parent owns, children use) +- Siblings can't directly communicate (goes through parent) +- Consider @Observable model if state becomes complex + + + + +## What NOT to Do + + +**Problem:** Creating an ObservableObject with @ObservedObject instead of @StateObject. + +**Why it's bad:** SwiftUI can recreate views at any time. @ObservedObject doesn't guarantee the object survives, causing data loss, crashes, and unpredictable behavior. The object gets recreated on every view update. + +**Instead:** +```swift +// WRONG +struct MyView: View { + @ObservedObject var viewModel = ViewModel() // ❌ Will be recreated! + var body: some View { /* ... */ } +} + +// RIGHT (pre-iOS 17) +struct MyView: View { + @StateObject private var viewModel = ViewModel() // ✅ Survives redraws + var body: some View { /* ... */ } +} + +// RIGHT (iOS 17+) +@Observable +class ViewModel { + var data = "" +} + +struct MyView: View { + @State private var viewModel = ViewModel() // ✅ Modern approach + var body: some View { /* ... */ } +} +``` + + + +**Problem:** Declaring @State properties as public or internal. + +**Why it's bad:** @State is meant for view-local state. Making it public violates encapsulation and suggests the state should be passed from outside (making it not truly @State). Creates confusion about ownership. + +**Instead:** +```swift +// WRONG +struct MyView: View { + @State var count = 0 // ❌ Not private + var body: some View { /* ... */ } +} + +// RIGHT +struct MyView: View { + @State private var count = 0 // ✅ Private ownership + var body: some View { /* ... */ } +} + +// If state needs to come from outside: +struct MyView: View { + @Binding var count: Int // ✅ Use @Binding instead + var body: some View { /* ... */ } +} +``` + + + +**Problem:** Storing large value types or arrays in @State, causing performance issues. + +**Why it's bad:** SwiftUI recreates the view body whenever @State changes. Large value types cause expensive copies. Massive arrays cause performance degradation. + +**Instead:** +```swift +// WRONG +struct ListView: View { + @State private var items: [LargeItem] = loadThousandsOfItems() // ❌ Expensive copies + var body: some View { /* ... */ } +} + +// RIGHT +@Observable +class ItemStore { + var items: [LargeItem] = [] // Reference type, no copies +} + +struct ListView: View { + @State private var store = ItemStore() // ✅ Only reference is copied + var body: some View { /* ... */ } +} +``` + + + +**Problem:** Passing a state value to a child expecting @Binding without the $ prefix. + +**Why it's bad:** Passes a copy of the value instead of a binding. Child's changes don't propagate back to parent. + +**Instead:** +```swift +struct ParentView: View { + @State private var text = "" + + var body: some View { + // WRONG + ChildView(text: text) // ❌ Passes copy + + // RIGHT + ChildView(text: $text) // ✅ Passes binding + } +} + +struct ChildView: View { + @Binding var text: String + var body: some View { + TextField("Enter text", text: $text) + } +} +``` + + + +**Problem:** Changing @State or @Observable properties inside computed properties or body. + +**Why it's bad:** Causes infinite loops or unpredictable update cycles. SwiftUI reads body to determine what to render; mutating state during rendering triggers another render. + +**Instead:** +```swift +// WRONG +struct MyView: View { + @State private var count = 0 + + var body: some View { + let _ = count += 1 // ❌ Infinite loop! + Text("Count: \(count)") + } +} + +// RIGHT +struct MyView: View { + @State private var count = 0 + + var body: some View { + VStack { + Text("Count: \(count)") + Button("Increment") { + count += 1 // ✅ Mutate in response to events + } + } + .onAppear { + count = 0 // ✅ Or in lifecycle events + } + } +} +``` + + + +**Problem:** Using @AppStorage for passwords, tokens, or other sensitive data. + +**Why it's bad:** UserDefaults is not encrypted. Data is easily accessible to anyone with device access or backup access. Security vulnerability. + +**Instead:** +```swift +// WRONG +@AppStorage("password") private var password = "" // ❌ Not secure! +@AppStorage("authToken") private var token = "" // ❌ Not secure! + +// RIGHT +import Security + +class KeychainManager { + func save(password: String, for account: String) { + // Use Keychain for sensitive data + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: account, + kSecValueData as String: password.data(using: .utf8)! + ] + SecItemAdd(query as CFDictionary, nil) + } +} + +// For auth tokens, user credentials, etc. +struct SecureView: View { + @State private var keychain = KeychainManager() + + var body: some View { + Button("Save Password") { + keychain.save(password: "secret", for: "user@example.com") + } + } +} +``` + + + +**Problem:** Using ObservableObject, @Published, @StateObject, @ObservedObject, @EnvironmentObject in new iOS 17+ projects. + +**Why it's bad:** The @Observable macro is simpler, more performant, and the recommended approach. Legacy patterns add unnecessary complexity. Better compiler optimization with @Observable. + +**Instead:** +```swift +// WRONG (legacy) +class ViewModel: ObservableObject { + @Published var name = "" + @Published var count = 0 +} + +struct OldView: View { + @StateObject private var viewModel = ViewModel() + var body: some View { /* ... */ } +} + +// RIGHT (iOS 17+) +@Observable +class ViewModel { + var name = "" + var count = 0 +} + +struct ModernView: View { + @State private var viewModel = ViewModel() + var body: some View { /* ... */ } +} +``` + + + +**Problem:** Putting everything in @Environment, even data that should be passed as properties. + +**Why it's bad:** Hides dependencies, makes views harder to test and preview, unclear data flow, runtime crashes if environment not provided. + +**Instead:** +```swift +// WRONG - overusing environment +struct ItemRow: View { + @Environment(AppState.self) private var appState // ❌ Just to access one property + + var body: some View { + Text(appState.currentItem.name) + } +} + +// RIGHT - explicit dependencies +struct ItemRow: View { + let item: Item // ✅ Clear dependency + + var body: some View { + Text(item.name) + } +} + +// Environment is good for truly cross-cutting concerns: +struct ThemedView: View { + @Environment(\.colorScheme) var colorScheme // ✅ System value + @Environment(UserSession.self) var session // ✅ App-wide auth state + + var body: some View { /* ... */ } +} +``` + + + + +## Migrating from Legacy Patterns + +**ObservableObject → @Observable:** + +```swift +// Before (legacy) +class ViewModel: ObservableObject { + @Published var name: String = "" + @Published var count: Int = 0 + @Published var items: [Item] = [] + + private var cache: [String: Any] = [:] // Not published +} + +struct OldView: View { + @StateObject private var viewModel = ViewModel() + + var body: some View { + Text(viewModel.name) + } +} + +// After (iOS 17+) +import Observation + +@Observable +class ViewModel { + var name: String = "" + var count: Int = 0 + var items: [Item] = [] + + @ObservationIgnored + private var cache: [String: Any] = [:] // Won't trigger updates +} + +struct ModernView: View { + @State private var viewModel = ViewModel() + + var body: some View { + Text(viewModel.name) + } +} +``` + +**@EnvironmentObject → @Environment:** + +```swift +// Before (legacy) +class AppSettings: ObservableObject { + @Published var theme: String = "light" +} + +@main +struct OldApp: App { + @StateObject private var settings = AppSettings() + + var body: some Scene { + WindowGroup { + ContentView() + .environmentObject(settings) + } + } +} + +struct OldContentView: View { + @EnvironmentObject var settings: AppSettings + + var body: some View { + Text("Theme: \(settings.theme)") + } +} + +// After (iOS 17+) +@Observable +class AppSettings { + var theme: String = "light" +} + +@main +struct ModernApp: App { + @State private var settings = AppSettings() + + var body: some Scene { + WindowGroup { + ContentView() + .environment(settings) + } + } +} + +struct ModernContentView: View { + @Environment(AppSettings.self) private var settings + + var body: some View { + Text("Theme: \(settings.theme)") + } +} +``` + +**@ObservedObject (child views) → Plain properties:** + +```swift +// Before (legacy) +class SharedData: ObservableObject { + @Published var value: String = "" +} + +struct ParentView: View { + @StateObject private var data = SharedData() + + var body: some View { + ChildView(data: data) + } +} + +struct ChildView: View { + @ObservedObject var data: SharedData + + var body: some View { + Text(data.value) + } +} + +// After (iOS 17+) +@Observable +class SharedData { + var value: String = "" +} + +struct ParentView: View { + @State private var data = SharedData() + + var body: some View { + ChildView(data: data) + } +} + +struct ChildView: View { + var data: SharedData // Plain property, no wrapper + + var body: some View { + Text(data.value) + } +} +``` + +**Creating bindings to @Observable properties:** + +```swift +// Before (legacy) +class FormData: ObservableObject { + @Published var username: String = "" + @Published var email: String = "" +} + +struct LegacyForm: View { + @ObservedObject var formData: FormData + + var body: some View { + Form { + TextField("Username", text: $formData.username) + TextField("Email", text: $formData.email) + } + } +} + +// After (iOS 17+) +@Observable +class FormData { + var username: String = "" + var email: String = "" +} + +struct ModernForm: View { + @Bindable var formData: FormData + + var body: some View { + Form { + TextField("Username", text: $formData.username) + TextField("Email", text: $formData.email) + } + } +} +``` + +**Migration checklist:** + +1. Add `import Observation` to files using @Observable +2. Replace `class X: ObservableObject` with `@Observable class X` +3. Remove `@Published` from properties (all properties are observed by default) +4. Add `@ObservationIgnored` to properties that shouldn't trigger updates +5. Replace `@StateObject` with `@State` in owning views +6. Replace `@ObservedObject` with plain properties in child views (no wrapper) +7. Replace `@EnvironmentObject` with `@Environment(Type.self)` +8. Replace `.environmentObject(obj)` with `.environment(obj)` +9. Use `@Bindable` when you need to create bindings to @Observable properties +10. Test thoroughly - SwiftUI will warn about missing environment values at runtime + + + +## Debugging State Issues + +**State not updating views:** +- Verify property is marked with correct wrapper (@State, @Observable) +- Check that mutations happen on main thread for UI updates +- Ensure @ObservationIgnored isn't on properties that should update views +- Confirm view is actually observing the state (proper property wrapper usage) + +**Views updating too much:** +- Check if @Observable class is triggering updates from non-UI properties (use @ObservationIgnored) +- Verify child views aren't receiving entire model when they only need specific properties +- Consider breaking large models into smaller focused models +- Use Instruments Time Profiler to identify expensive body computations + +**Runtime crashes:** +- "Missing @Environment" - Forgot to provide environment value with `.environment(value)` +- Force unwrapping nil @AppStorage or @SceneStorage - Always provide default values +- Access to deallocated object - Using @ObservedObject instead of @StateObject for owned objects + +**Previews not working:** +- Provide all required @Environment values in preview +- Initialize @Binding properties with `.constant(value)` in previews +- Ensure @Observable classes are properly initialized + +**Example debugging view:** +```swift +struct DebugStateView: View { + @State private var viewModel = ViewModel() + + var body: some View { + VStack { + Text("Count: \(viewModel.count)") + Button("Increment") { + print("Before: \(viewModel.count)") + viewModel.count += 1 + print("After: \(viewModel.count)") + } + } + // Add debugging modifier + ._printChanges() // Prints when view updates and why + } +} +``` + diff --git a/src/resources/skills/swiftui/references/swiftdata.md b/src/resources/skills/swiftui/references/swiftdata.md new file mode 100644 index 000000000..02ec7fad1 --- /dev/null +++ b/src/resources/skills/swiftui/references/swiftdata.md @@ -0,0 +1,297 @@ + +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) + + + +## 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() + } +} +``` + + + +## 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 + } +} +``` + + + +## 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 +``` + + + +## 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 { $0.isCompleted == false }) var items: [Item] +``` + +**Dynamic queries:** +```swift +struct SearchableItemList: View { + @Query var items: [Item] + + init(searchText: String) { + let predicate = #Predicate { item in + searchText.isEmpty || item.name.localizedStandardContains(searchText) + } + _items = Query(filter: predicate) + } +} +``` + +**FetchDescriptor for context queries:** +```swift +let descriptor = FetchDescriptor( + predicate: #Predicate { $0.isCompleted }, + sortBy: [SortDescriptor(\.timestamp)] +) +let items = try context.fetch(descriptor) +``` + + + +## 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() +``` + + + +## 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 + + + +## 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] } +} +``` + + + +## 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) + + + +## What NOT to Do + + +**Problem:** @Query requires SwiftUI environment +**Instead:** Use FetchDescriptor with explicit context in view models + + + +**Problem:** Silently breaks CloudKit sync +**Instead:** Handle uniqueness in app logic + + + +**Problem:** Compiles but crashes at runtime +**Instead:** Use persisted properties for filtering + + diff --git a/src/resources/skills/swiftui/references/testing-debugging.md b/src/resources/skills/swiftui/references/testing-debugging.md new file mode 100644 index 000000000..153bb6770 --- /dev/null +++ b/src/resources/skills/swiftui/references/testing-debugging.md @@ -0,0 +1,247 @@ + +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. + + + +## 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) + ] +} +``` + + + +## 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 + } +} +``` + + + +## 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)) + } +} +``` + + + +## 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)) +``` + + + +## 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.** + + + +## 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) +``` + + + +## 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 + + + +## What NOT to Do + + +**Problem:** Trying to unit test SwiftUI views directly +**Instead:** Extract logic to view models, test those + + + +**Problem:** Using text to find elements in UI tests +**Instead:** Use .accessibilityIdentifier("stableId") + + + +**Problem:** Hardcoded dependencies in view models +**Instead:** Use protocols, inject mocks in tests + + diff --git a/src/resources/skills/swiftui/references/uikit-appkit-interop.md b/src/resources/skills/swiftui/references/uikit-appkit-interop.md new file mode 100644 index 000000000..cf4268252 --- /dev/null +++ b/src/resources/skills/swiftui/references/uikit-appkit-interop.md @@ -0,0 +1,218 @@ + +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 + + + +## 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 + + + +## 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() + } + } +} +``` + + + +## 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 + } + } +} +``` + + + +## 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) + } +} +``` + + + +## 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 + } +} +``` + + + +## 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 + + + +## What NOT to Do + + +**Problem:** Using UIViewRepresentable when SwiftUI works +**Instead:** Check if SwiftUI added the feature + + + +**Problem:** Handling delegates without Coordinator +**Instead:** Always use Coordinator for delegate patterns + + + +**Problem:** Not managing child view controller properly +**Instead:** addChild → addSubview → didMove(toParent:) + + diff --git a/src/resources/skills/swiftui/workflows/add-feature.md b/src/resources/skills/swiftui/workflows/add-feature.md new file mode 100644 index 000000000..0b3590665 --- /dev/null +++ b/src/resources/skills/swiftui/workflows/add-feature.md @@ -0,0 +1,191 @@ + +**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 + + + +## 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 + + + +## 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 + + + +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 + diff --git a/src/resources/skills/swiftui/workflows/build-new-app.md b/src/resources/skills/swiftui/workflows/build-new-app.md new file mode 100644 index 000000000..2ea53e548 --- /dev/null +++ b/src/resources/skills/swiftui/workflows/build-new-app.md @@ -0,0 +1,311 @@ + +**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 + + + +## 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]" + + + +## 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() } } + } +} +``` + + + +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 + diff --git a/src/resources/skills/swiftui/workflows/debug-swiftui.md b/src/resources/skills/swiftui/workflows/debug-swiftui.md new file mode 100644 index 000000000..53b5edb4f --- /dev/null +++ b/src/resources/skills/swiftui/workflows/debug-swiftui.md @@ -0,0 +1,192 @@ + +**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 + + + +## 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" + + + +## 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 + + + +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 + diff --git a/src/resources/skills/swiftui/workflows/optimize-performance.md b/src/resources/skills/swiftui/workflows/optimize-performance.md new file mode 100644 index 000000000..2803240e6 --- /dev/null +++ b/src/resources/skills/swiftui/workflows/optimize-performance.md @@ -0,0 +1,197 @@ + +**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 + + + +## 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" + + + +## 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 + + + +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 + diff --git a/src/resources/skills/swiftui/workflows/ship-app.md b/src/resources/skills/swiftui/workflows/ship-app.md new file mode 100644 index 000000000..227bcc3ba --- /dev/null +++ b/src/resources/skills/swiftui/workflows/ship-app.md @@ -0,0 +1,203 @@ + +**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 + + + +## 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 + + + +## 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 + + + +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 + diff --git a/src/resources/skills/swiftui/workflows/write-tests.md b/src/resources/skills/swiftui/workflows/write-tests.md new file mode 100644 index 000000000..a2fa5efb0 --- /dev/null +++ b/src/resources/skills/swiftui/workflows/write-tests.md @@ -0,0 +1,235 @@ + +**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 + + + +## 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..." + + + +## 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() { } +``` + + + +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 +