From 052112b19c7f611209696fd46f89a19a9e0d6cea Mon Sep 17 00:00:00 2001 From: Thomas Ricouard Date: Tue, 30 Dec 2025 15:40:30 +0100 Subject: [PATCH] Add current skills --- app-store-changelog/SKILL.md | 40 +++ .../references/release-notes-guidelines.md | 34 ++ .../scripts/collect_release_changes.sh | 33 ++ ios-debugger-agent/SKILL.md | 49 +++ swift-concurrency-expert/SKILL.md | 34 ++ .../references/swift-6-2-concurrency.md | 272 ++++++++++++++++ swiftui-liquid-glass/SKILL.md | 90 ++++++ .../references/liquid-glass.md | 280 ++++++++++++++++ swiftui-view-refactor/SKILL.md | 61 ++++ .../references/mv-patterns.md | 302 ++++++++++++++++++ 10 files changed, 1195 insertions(+) create mode 100644 app-store-changelog/SKILL.md create mode 100644 app-store-changelog/references/release-notes-guidelines.md create mode 100755 app-store-changelog/scripts/collect_release_changes.sh create mode 100644 ios-debugger-agent/SKILL.md create mode 100644 swift-concurrency-expert/SKILL.md create mode 100644 swift-concurrency-expert/references/swift-6-2-concurrency.md create mode 100644 swiftui-liquid-glass/SKILL.md create mode 100644 swiftui-liquid-glass/references/liquid-glass.md create mode 100644 swiftui-view-refactor/SKILL.md create mode 100644 swiftui-view-refactor/references/mv-patterns.md diff --git a/app-store-changelog/SKILL.md b/app-store-changelog/SKILL.md new file mode 100644 index 0000000..ccfd0cc --- /dev/null +++ b/app-store-changelog/SKILL.md @@ -0,0 +1,40 @@ +--- +name: app-store-changelog +description: Create user-facing App Store release notes by collecting and summarizing all user-impacting changes since the last git tag (or a specified ref). Use when asked to generate a comprehensive release changelog, App Store "What's New" text, or release notes based on git history or tags. +--- + +# App Store Changelog + +## Overview +Generate a comprehensive, user-facing changelog from git history since the last tag, then translate commits into clear App Store release notes. + +## Workflow + +### 1) Collect changes +- Run `scripts/collect_release_changes.sh` from the repo root to gather commits and touched files. +- If needed, pass a specific tag or ref: `scripts/collect_release_changes.sh v1.2.3 HEAD`. +- If no tags exist, the script falls back to full history. + +### 2) Triage for user impact +- Scan commits and files to identify user-visible changes. +- Group changes by theme (New, Improved, Fixed) and deduplicate overlaps. +- Drop internal-only work (build scripts, refactors, dependency bumps, CI). + +### 3) Draft App Store notes +- Write short, benefit-focused bullets for each user-facing change. +- Use clear verbs and plain language; avoid internal jargon. +- Prefer 5 to 10 bullets unless the user requests a different length. + +### 4) Validate +- Ensure every bullet maps back to a real change in the range. +- Check for duplicates and overly technical wording. +- Ask for clarification if any change is ambiguous or possibly internal-only. + +## Output Format +- Title (optional): "What’s New" or product name + version. +- Bullet list only; one sentence per bullet. +- Stick to storefront limits if the user provides one. + +## Resources +- `scripts/collect_release_changes.sh`: Collect commits and touched files since last tag. +- `references/release-notes-guidelines.md`: Language, filtering, and QA rules for App Store notes. diff --git a/app-store-changelog/references/release-notes-guidelines.md b/app-store-changelog/references/release-notes-guidelines.md new file mode 100644 index 0000000..c0beecb --- /dev/null +++ b/app-store-changelog/references/release-notes-guidelines.md @@ -0,0 +1,34 @@ +# App Store Release Notes Guidelines + +## Goals +- Produce user-facing release notes that describe visible changes since the last tag. +- Include all user-impacting changes; omit purely internal or refactor-only work. +- Keep language plain, short, and benefit-focused. + +## Output Shape +- Prefer 5 to 10 bullets total for most releases. +- Group by theme if needed: New, Improved, Fixed. +- Each bullet should be one sentence and start with a verb. +- Avoid internal codenames, ticket IDs, or file paths. + +## Filtering Rules +- Include: new features, UI changes, behavior changes, bug fixes users would notice, performance improvements with visible impact. +- Exclude: refactors, dependency bumps, CI changes, developer tooling, internal logging, analytics changes unless they affect user privacy or behavior. +- If a change is ambiguous, ask for clarification or describe it as a small improvement only if it is user-visible. + +## Language Guidance +- Translate technical terms into user-facing descriptions. +- Avoid versions of "API", "refactor", "nil", "crash log", or "dependency". +- Prefer "Improved", "Added", "Fixed", "Updated" or action verbs like "Search", "Upload", "Sync". +- Keep tense present or past: "Added", "Improved", "Fixed". + +## Examples +- "Added account switching from the profile menu." +- "Improved timeline loading speed on slow connections." +- "Fixed media attachments not opening in full screen." + +## QA Checklist +- Every bullet ties to a real change in the range. +- No duplicate bullets that describe the same change. +- No internal jargon or file paths. +- Final list fits App Store text limits for the target storefront if provided. diff --git a/app-store-changelog/scripts/collect_release_changes.sh b/app-store-changelog/scripts/collect_release_changes.sh new file mode 100755 index 0000000..f7e4659 --- /dev/null +++ b/app-store-changelog/scripts/collect_release_changes.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +set -euo pipefail + +since_ref="${1:-}" +until_ref="${2:-HEAD}" + +if [[ -z "${since_ref}" ]]; then + if git describe --tags --abbrev=0 >/dev/null 2>&1; then + since_ref="$(git describe --tags --abbrev=0)" + fi +fi + +range="" +if [[ -n "${since_ref}" ]]; then + range="${since_ref}..${until_ref}" +else + range="${until_ref}" +fi + +repo_root="$(git rev-parse --show-toplevel)" + +printf "Repo: %s\n" "${repo_root}" +if [[ -n "${since_ref}" ]]; then + printf "Range: %s..%s\n" "${since_ref}" "${until_ref}" +else + printf "Range: start..%s (no tags found)\n" "${until_ref}" +fi + +printf "\n== Commits ==\n" +git log --reverse --date=short --pretty=format:'%h|%ad|%s' ${range} + +printf "\n\n== Files Touched ==\n" +git log --reverse --name-only --pretty=format:'--- %h %s' ${range} | sed '/^$/d' diff --git a/ios-debugger-agent/SKILL.md b/ios-debugger-agent/SKILL.md new file mode 100644 index 0000000..6dbcf46 --- /dev/null +++ b/ios-debugger-agent/SKILL.md @@ -0,0 +1,49 @@ +--- +name: ios-debugger-agent +description: Use XcodeBuildMCP to build, run, launch, and debug the current iOS project on a booted simulator. Trigger when asked to run an iOS app, interact with the simulator UI, inspect on-screen state, capture logs/console output, or diagnose runtime behavior using XcodeBuildMCP tools. +--- + +# iOS Debugger Agent + +## Overview +Use XcodeBuildMCP to build and run the current project scheme on a booted iOS simulator, interact with the UI, and capture logs. Prefer the MCP tools for simulator control, logs, and view inspection. + +## Core Workflow +Follow this sequence unless the user asks for a narrower action. + +### 1) Discover the booted simulator +- Call `mcp__XcodeBuildMCP__list_sims` and select the simulator with state `Booted`. +- If none are booted, ask the user to boot one (do not boot automatically unless asked). + +### 2) Set session defaults +- Call `mcp__XcodeBuildMCP__session-set-defaults` with: + - `projectPath` or `workspacePath` (whichever the repo uses) + - `scheme` for the current app + - `simulatorId` from the booted device + - Optional: `configuration: "Debug"`, `useLatestOS: true` + +### 3) Build + run (when requested) +- Call `mcp__XcodeBuildMCP__build_run_sim`. +- If the app is already built and only launch is requested, use `mcp__XcodeBuildMCP__launch_app_sim`. +- If bundle id is unknown: + 1) `mcp__XcodeBuildMCP__get_sim_app_path` + 2) `mcp__XcodeBuildMCP__get_app_bundle_id` + +## UI Interaction & Debugging +Use these when asked to inspect or interact with the running app. + +- **Describe UI**: `mcp__XcodeBuildMCP__describe_ui` before tapping or swiping. +- **Tap**: `mcp__XcodeBuildMCP__tap` (prefer `id` or `label`; use coordinates only if needed). +- **Type**: `mcp__XcodeBuildMCP__type_text` after focusing a field. +- **Gestures**: `mcp__XcodeBuildMCP__gesture` for common scrolls and edge swipes. +- **Screenshot**: `mcp__XcodeBuildMCP__screenshot` for visual confirmation. + +## Logs & Console Output +- Start logs: `mcp__XcodeBuildMCP__start_sim_log_cap` with the app bundle id. +- Stop logs: `mcp__XcodeBuildMCP__stop_sim_log_cap` and summarize important lines. +- For console output, set `captureConsole: true` and relaunch if required. + +## Troubleshooting +- If build fails, ask whether to retry with `preferXcodebuild: true`. +- If the wrong app launches, confirm the scheme and bundle id. +- If UI elements are not hittable, re-run `describe_ui` after layout changes. diff --git a/swift-concurrency-expert/SKILL.md b/swift-concurrency-expert/SKILL.md new file mode 100644 index 0000000..ca029fc --- /dev/null +++ b/swift-concurrency-expert/SKILL.md @@ -0,0 +1,34 @@ +--- +name: swift-concurrency-expert +description: Swift Concurrency review and remediation for Swift 6.2+. Use when asked to review Swift Concurrency usage, improve concurrency compliance, or fix Swift concurrency compiler errors in a feature or file. +--- + +# Swift Concurrency Expert + +## Overview + +Review and fix Swift Concurrency issues in Swift 6.2+ codebases by applying actor isolation, Sendable safety, and modern concurrency patterns with minimal behavior changes. + +## Workflow + +### 1. Triage the issue + +- Capture the exact compiler diagnostics and the offending symbol(s). +- Identify the current actor context (`@MainActor`, `actor`, `nonisolated`) and whether a default actor isolation mode is enabled. +- Confirm whether the code is UI-bound or intended to run off the main actor. + +### 2. Apply the smallest safe fix + +Prefer edits that preserve existing behavior while satisfying data-race safety. + +Common fixes: +- **UI-bound types**: annotate the type or relevant members with `@MainActor`. +- **Protocol conformance on main actor types**: make the conformance isolated (e.g., `extension Foo: @MainActor SomeProtocol`). +- **Global/static state**: protect with `@MainActor` or move into an actor. +- **Background work**: move expensive work into a `@concurrent` async function on a `nonisolated` type or use an `actor` to guard mutable state. +- **Sendable errors**: prefer immutable/value types; add `Sendable` conformance only when correct; avoid `@unchecked Sendable` unless you can prove thread safety. + + +## Reference material + +- See `references/swift-6-2-concurrency.md` for Swift 6.2 changes, patterns, and examples. diff --git a/swift-concurrency-expert/references/swift-6-2-concurrency.md b/swift-concurrency-expert/references/swift-6-2-concurrency.md new file mode 100644 index 0000000..aa4c5cb --- /dev/null +++ b/swift-concurrency-expert/references/swift-6-2-concurrency.md @@ -0,0 +1,272 @@ +## Concurrent programming updates in Swift 6.2 + +Concurrent programming is hard because sharing memory between multiple tasks is prone to mistakes that lead to unpredictable behavior. + +## Data-race safety + + Data-race safety in Swift 6 prevents these mistakes at compile time, so you can write concurrent code without fear of introducing hard-to-debug runtime bugs. But in many cases, the most natural code to write is prone to data races, leading to compiler errors that you have to address. A class with mutable state, like this `PhotoProcessor` class, is safe as long as you don’t access it concurrently. + +```swift +class PhotoProcessor { + func extractSticker(data: Data, with id: String?) async -> Sticker? { } +} + +@MainActor +final class StickerModel { + let photoProcessor = PhotoProcessor() + + func extractSticker(_ item: PhotosPickerItem) async throws -> Sticker? { + guard let data = try await item.loadTransferable(type: Data.self) else { + return nil + } + + // Error: Sending 'self.photoProcessor' risks causing data races + // Sending main actor-isolated 'self.photoProcessor' to nonisolated instance method 'extractSticker(data:with:)' + // risks causing data races between nonisolated and main actor-isolated uses + return await photoProcessor.extractSticker(data: data, with: item.itemIdentifier) + } +} +``` + + It has an async method to extract a `Sticker` by computing the subject of the given image data. But if you try to call `extractSticker` from UI code on the main actor, you’ll get an error that the call risks causing data races. This is because there are several places in the language that offload work to the background implicitly, even if you never needed code to run in parallel. + +Swift 6.2 changes this philosophy to stay single threaded by default until you choose to introduce concurrency. + +```swift +class PhotoProcessor { + func extractSticker(data: Data, with id: String?) async -> Sticker? { } +} + +@MainActor +final class StickerModel { + let photoProcessor = PhotoProcessor() + + func extractSticker(_ item: PhotosPickerItem) async throws -> Sticker? { + guard let data = try await item.loadTransferable(type: Data.self) else { + return nil + } + + // No longer a data race error in Swift 6.2 because of Approachable Concurrency and default actor isolation + return await photoProcessor.extractSticker(data: data, with: item.itemIdentifier) + } +} +``` + +The language changes in Swift 6.2 make the most natural code to write data race free by default. This provides a more approachable path to introducing concurrency in a project. + +When you choose to introduce concurrency because you want to run code in parallel, data-race safety will protect you. + +First, we've made it easier to call async functions on types with mutable state. Instead of eagerly offloading async functions that aren't tied to a specific actor, the function will continue to run on the actor it was called from. This eliminates data races because the values passed into the async function are never sent outside the actor. Async functions can still offload work in their implementation, but clients don’t have to worry about their mutable state. + +Next, we’ve made it easier to implement conformances on main actor types. Here I have a protocol called `Exportable`, and I’m trying to implement a conformance for my main actor `StickerModel` class. The export requirement doesn’t have actor isolation, so the language assumed that it could be called from off the main actor, and prevented `StickerModel` from using main actor state in its implementation. + +```swift +protocol Exportable { + func export() +} + +extension StickerModel: Exportable { // error: Conformance of 'StickerModel' to protocol 'Exportable' crosses into main actor-isolated code and can cause data races + func export() { + photoProcessor.exportAsPNG() + } +} +``` + +Swift 6.2 supports these conformances. A conformance that needs main actor state is called an *isolated* conformance. This is safe because the compiler ensures a main actor conformance is only used on the main actor. + +```swift +// Isolated conformances + +protocol Exportable { + func export() +} + +extension StickerModel: @MainActor Exportable { + func export() { + photoProcessor.exportAsPNG() + } +} +``` + + I can create an `ImageExporter` type that adds a `StickerModel` to an array of any `Exportable` items as long as it stays on the main actor. + +```swift + // Isolated conformances + +@MainActor +struct ImageExporter { + var items: [any Exportable] + + mutating func add(_ item: StickerModel) { + items.append(item) + } + + func exportAll() { + for item in items { + item.export() + } + } +} +``` + +But if I allow `ImageExporter` to be used from anywhere, the compiler prevents adding `StickerModel` to the array because it isn’t safe to call export on `StickerModel` from outside the main actor. + +```swift +// Isolated conformances + +nonisolated +struct ImageExporter { + var items: [any Exportable] + + mutating func add(_ item: StickerModel) { + items.append(item) // error: Main actor-isolated conformance of 'StickerModel' to 'Exportable' cannot be used in nonisolated context + } + + func exportAll() { + for item in items { + item.export() + } + } +} +``` + +With isolated conformances, you only have to solve data race safety issues when the code indicates that it uses the conformance concurrently. + +## Global State + +Global and static variables are prone to data races because they allow mutable state to be accessed from anywhere. + +```swift +final class StickerLibrary { + static let shared: StickerLibrary = .init() // error: Static property 'shared' is not concurrency-safe because non-'Sendable' type 'StickerLibrary' may have shared mutable state +} +``` + +The most common way to protect global state is with the main actor. + +```swift +final class StickerLibrary { + @MainActor + static let shared: StickerLibrary = .init() +} +``` + + And it’s common to annotate an entire class with the main actor to protect all of its mutable state, especially in a project that doesn’t have a lot of concurrent tasks. + +```swift +@MainActor +final class StickerLibrary { + static let shared: StickerLibrary = .init() +} +``` + +You can model a program that's entirely single-threaded by writing `@MainActor` on everything in your project. + +```swift +@MainActor +final class StickerLibrary { + static let shared: StickerLibrary = .init() +} + +@MainActor +final class StickerModel { + let photoProcessor: PhotoProcessor + + var selection: [PhotosPickerItem] +} + +extension StickerModel: @MainActor Exportable { + func export() { + photoProcessor.exportAsPNG() + } +} +``` + +To make it easier to model single-threaded code, we’ve introduced a mode to infer main actor by default. + +```swift +// Mode to infer main actor by default in Swift 6.2 + +final class StickerLibrary { + static let shared: StickerLibrary = .init() +} + +final class StickerModel { + let photoProcessor: PhotoProcessor + + var selection: [PhotosPickerItem] +} + +extension StickerModel: Exportable { + func export() { + photoProcessor.exportAsPNG() + } +} +``` + + This eliminates data-race safety errors about unsafe global and static variables, calls to other main actor functions like ones from the SDK, and more, because the main actor protects all mutable state by default. It also reduces concurrency annotations in code that’s mostly single-threaded. This mode is great for projects that do most of the work on the main actor, and concurrent code is encapsulated within specific types or files. It’s opt-in and it’s recommended for apps, scripts, and other executable targets. + +## Offloading work to the background + +Offloading work to the background is still important for performance, such as keeping apps responsive when performing CPU-intensive tasks. + +Let’s look at the implementation of the `extractSticker` method on `PhotoProcessor`. + +```swift +// Explicitly offloading async work + +class PhotoProcessor { + var cachedStickers: [String: Sticker] + + func extractSticker(data: Data, with id: String) async -> Sticker { + if let sticker = cachedStickers[id] { + return sticker + } + + let sticker = await Self.extractSubject(from: data) + cachedStickers[id] = sticker + return sticker + } + + // Offload expensive image processing using the @concurrent attribute. + @concurrent + static func extractSubject(from data: Data) async -> Sticker { } +} +``` + +It first checks whether it already extracted a sticker for an image, so it can return the cached sticker immediately. If the sticker hasn’t been cached, it extracts the subject from the image data and creates a new sticker. The `extractSubject` method performs expensive image processing that I don’t want to block the main actor or any other actor. + +I can offload this work using the `@concurrent` attribute. `@concurrent` ensures that a function always runs on the concurrent thread pool, freeing up the actor to run other tasks at the same time. + +### An example + +Say you have a function called `process` that you would like to run on a background thread. To call that function on a background thread you need to: + +- make sure the structure or class is `nonisolated` +- add the `@concurrent` attribute to the function you want to run in the background +- add the keyword `async` to the function if it is not already asynchronous +- and then add the keyword `await` to any callers + +Like this: + +```swift +nonisolated struct PhotoProcessor { + + @concurrent + func process(data: Data) async -> ProcessedPhoto? { ... } +} + +// Callers with the added await +processedPhotos[item.id] = await PhotoProcessor().process(data: data) +``` + + +## Summary + +These language changes work together to make concurrency more approachable. + +You start by writing code that runs on the main actor by default, where there’s no risk of data races. When you start to use async functions, those functions run wherever they’re called from. There’s still no risk of data races because all of your code still runs on the main actor. When you’re ready to embrace concurrency to improve performance, it’s easy to offload specific code to the background to run in parallel. + +Some of these language changes are opt-in because they require changes in your project to adopt. You can find and enable all of the approachable concurrency language changes under the Swift Compiler - Concurrency section of Xcode build settings. You can also enable these features in a Swift package manifest file using the SwiftSettings API. + + Swift 6.2 includes migration tooling to help you make the necessary code changes automatically. You can learn more about migration tooling at swift.org/migration. diff --git a/swiftui-liquid-glass/SKILL.md b/swiftui-liquid-glass/SKILL.md new file mode 100644 index 0000000..c06e23f --- /dev/null +++ b/swiftui-liquid-glass/SKILL.md @@ -0,0 +1,90 @@ +--- +name: swiftui-liquid-glass +description: Implement, review, or improve SwiftUI features using the iOS 26+ Liquid Glass API. Use when asked to adopt Liquid Glass in new SwiftUI UI, refactor an existing feature to Liquid Glass, or review Liquid Glass usage for correctness, performance, and design alignment. +--- + +# SwiftUI Liquid Glass + +## Overview +Use this skill to build or review SwiftUI features that fully align with the iOS 26+ Liquid Glass API. Prioritize native APIs (`glassEffect`, `GlassEffectContainer`, glass button styles) and Apple design guidance. Keep usage consistent, interactive where needed, and performance aware. + +## Workflow Decision Tree +Choose the path that matches the request: + +### 1) Review an existing feature +- Inspect where Liquid Glass should be used and where it should not. +- Verify correct modifier order, shape usage, and container placement. +- Check for iOS 26+ availability handling and sensible fallbacks. + +### 2) Improve a feature using Liquid Glass +- Identify target components for glass treatment (surfaces, chips, buttons, cards). +- Refactor to use `GlassEffectContainer` where multiple glass elements appear. +- Introduce interactive glass only for tappable or focusable elements. + +### 3) Implement a new feature using Liquid Glass +- Design the glass surfaces and interactions first (shape, prominence, grouping). +- Add glass modifiers after layout/appearance modifiers. +- Add morphing transitions only when the view hierarchy changes with animation. + +## Core Guidelines +- Prefer native Liquid Glass APIs over custom blurs. +- Use `GlassEffectContainer` when multiple glass elements coexist. +- Apply `.glassEffect(...)` after layout and visual modifiers. +- Use `.interactive()` for elements that respond to touch/pointer. +- Keep shapes consistent across related elements for a cohesive look. +- Gate with `#available(iOS 26, *)` and provide a non-glass fallback. + +## Review Checklist +- **Availability**: `#available(iOS 26, *)` present with fallback UI. +- **Composition**: Multiple glass views wrapped in `GlassEffectContainer`. +- **Modifier order**: `glassEffect` applied after layout/appearance modifiers. +- **Interactivity**: `interactive()` only where user interaction exists. +- **Transitions**: `glassEffectID` used with `@Namespace` for morphing. +- **Consistency**: Shapes, tinting, and spacing align across the feature. + +## Implementation Checklist +- Define target elements and desired glass prominence. +- Wrap grouped glass elements in `GlassEffectContainer` and tune spacing. +- Use `.glassEffect(.regular.tint(...).interactive(), in: .rect(cornerRadius: ...))` as needed. +- Use `.buttonStyle(.glass)` / `.buttonStyle(.glassProminent)` for actions. +- Add morphing transitions with `glassEffectID` when hierarchy changes. +- Provide fallback materials and visuals for earlier iOS versions. + +## Quick Snippets +Use these patterns directly and tailor shapes/tints/spacing. + +```swift +if #available(iOS 26, *) { + Text("Hello") + .padding() + .glassEffect(.regular.interactive(), in: .rect(cornerRadius: 16)) +} else { + Text("Hello") + .padding() + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16)) +} +``` + +```swift +GlassEffectContainer(spacing: 24) { + HStack(spacing: 24) { + Image(systemName: "scribble.variable") + .frame(width: 72, height: 72) + .font(.system(size: 32)) + .glassEffect() + Image(systemName: "eraser.fill") + .frame(width: 72, height: 72) + .font(.system(size: 32)) + .glassEffect() + } +} +``` + +```swift +Button("Confirm") { } + .buttonStyle(.glassProminent) +``` + +## Resources +- Reference guide: `references/liquid-glass.md` +- Prefer Apple docs for up-to-date API details. diff --git a/swiftui-liquid-glass/references/liquid-glass.md b/swiftui-liquid-glass/references/liquid-glass.md new file mode 100644 index 0000000..2abbd0d --- /dev/null +++ b/swiftui-liquid-glass/references/liquid-glass.md @@ -0,0 +1,280 @@ +# Implementing Liquid Glass Design in SwiftUI + +## Overview + +Liquid Glass is a dynamic material introduced in iOS that combines the optical properties of glass with a sense of fluidity. It blurs content behind it, reflects color and light from surrounding content, and reacts to touch and pointer interactions in real time. This guide covers how to implement and customize Liquid Glass effects in SwiftUI applications. You should always seek guides on Liquid Glass when asked for help adopting new Apple design. + +Key features of Liquid Glass: +- Blurs content behind the material +- Reflects color and light from surrounding content +- Reacts to touch and pointer interactions +- Can morph between shapes during transitions +- Available for standard and custom components + +## Basic Implementation + +### Adding Liquid Glass to a View + +The simplest way to add Liquid Glass to a view is using the `glassEffect()` modifier: + +```swift +Text("Hello, World!") + .font(.title) + .padding() + .glassEffect() +``` + +By default, this applies the regular variant of Glass within a Capsule shape behind the view's content. + +### Customizing the Shape + +You can specify a different shape for the Liquid Glass effect: + +```swift +Text("Hello, World!") + .font(.title) + .padding() + .glassEffect(in: .rect(cornerRadius: 16.0)) +``` + +Common shape options: +- `.capsule` (default) +- `.rect(cornerRadius: CGFloat)` +- `.circle` + +## Customizing Liquid Glass Effects + +### Glass Variants and Properties + +You can customize the Liquid Glass effect by configuring the `Glass` structure: + +```swift +Text("Hello, World!") + .font(.title) + .padding() + .glassEffect(.regular.tint(.orange).interactive()) +``` + +Key customization options: +- `.regular` - Standard glass effect +- `.tint(Color)` - Add a color tint to suggest prominence +- `.interactive(Bool)` - Make the glass react to touch and pointer interactions + +### Making Interactive Glass + +To make Liquid Glass react to touch and pointer interactions: + +```swift +Text("Hello, World!") + .font(.title) + .padding() + .glassEffect(.regular.interactive(true)) +``` + +Or more concisely: + +```swift +Text("Hello, World!") + .font(.title) + .padding() + .glassEffect(.regular.interactive()) +``` + +## Working with Multiple Glass Effects + +### Using GlassEffectContainer + +When applying Liquid Glass effects to multiple views, use `GlassEffectContainer` for better rendering performance and to enable blending and morphing effects: + +```swift +GlassEffectContainer(spacing: 40.0) { + HStack(spacing: 40.0) { + Image(systemName: "scribble.variable") + .frame(width: 80.0, height: 80.0) + .font(.system(size: 36)) + .glassEffect() + + Image(systemName: "eraser.fill") + .frame(width: 80.0, height: 80.0) + .font(.system(size: 36)) + .glassEffect() + } +} +``` + +The `spacing` parameter controls how the Liquid Glass effects interact with each other: +- Smaller spacing: Views need to be closer to merge effects +- Larger spacing: Effects merge at greater distances + +### Uniting Multiple Glass Effects + +To combine multiple views into a single Liquid Glass effect, use the `glassEffectUnion` modifier: + +```swift +@Namespace private var namespace + +// Later in your view: +GlassEffectContainer(spacing: 20.0) { + HStack(spacing: 20.0) { + ForEach(symbolSet.indices, id: \.self) { item in + Image(systemName: symbolSet[item]) + .frame(width: 80.0, height: 80.0) + .font(.system(size: 36)) + .glassEffect() + .glassEffectUnion(id: item < 2 ? "1" : "2", namespace: namespace) + } + } +} +``` + +This is useful when creating views dynamically or with views that live outside of an HStack or VStack. + +## Morphing Effects and Transitions + +### Creating Morphing Transitions + +To create morphing effects during transitions between views with Liquid Glass: + +1. Create a namespace using the `@Namespace` property wrapper +2. Associate each Liquid Glass effect with a unique identifier using `glassEffectID` +3. Use animations when changing the view hierarchy + +```swift +@State private var isExpanded: Bool = false +@Namespace private var namespace + +var body: some View { + GlassEffectContainer(spacing: 40.0) { + HStack(spacing: 40.0) { + Image(systemName: "scribble.variable") + .frame(width: 80.0, height: 80.0) + .font(.system(size: 36)) + .glassEffect() + .glassEffectID("pencil", in: namespace) + + if isExpanded { + Image(systemName: "eraser.fill") + .frame(width: 80.0, height: 80.0) + .font(.system(size: 36)) + .glassEffect() + .glassEffectID("eraser", in: namespace) + } + } + } + + Button("Toggle") { + withAnimation { + isExpanded.toggle() + } + } + .buttonStyle(.glass) +} +``` + +The morphing effect occurs when views with Liquid Glass appear or disappear due to view hierarchy changes. + +## Button Styling with Liquid Glass + +### Glass Button Style + +SwiftUI provides built-in button styles for Liquid Glass: + +```swift +Button("Click Me") { + // Action +} +.buttonStyle(.glass) +``` + +### Glass Prominent Button Style + +For a more prominent glass button: + +```swift +Button("Important Action") { + // Action +} +.buttonStyle(.glassProminent) +``` + +## Advanced Techniques + +### Background Extension Effect + +To stretch content behind a sidebar or inspector with the background extension effect: + +```swift +NavigationSplitView { + // Sidebar content +} detail: { + // Detail content + .background { + // Background content that extends under the sidebar + } +} +``` + +### Extending Horizontal Scrolling Under Sidebar + +To extend horizontal scroll views under a sidebar or inspector: + +```swift +ScrollView(.horizontal) { + // Scrollable content +} +.scrollExtensionMode(.underSidebar) +``` + +## Best Practices + +1. **Container Usage**: Always use `GlassEffectContainer` when applying Liquid Glass to multiple views for better performance and morphing effects. + +2. **Effect Order**: Apply the `.glassEffect()` modifier after other modifiers that affect the appearance of the view. + +3. **Spacing Consideration**: Carefully choose spacing values in containers to control how and when glass effects merge. + +4. **Animation**: Use animations when changing view hierarchies to enable smooth morphing transitions. + +5. **Interactivity**: Add `.interactive()` to glass effects that should respond to user interaction. + +6. **Consistent Design**: Maintain consistent shapes and styles across your app for a cohesive look and feel. + +## Example: Custom Badge with Liquid Glass + +```swift +struct BadgeView: View { + let symbol: String + let color: Color + + var body: some View { + ZStack { + Image(systemName: "hexagon.fill") + .foregroundColor(color) + .font(.system(size: 50)) + + Image(systemName: symbol) + .foregroundColor(.white) + .font(.system(size: 30)) + } + .glassEffect(.regular, in: .rect(cornerRadius: 16)) + } +} + +// Usage: +GlassEffectContainer(spacing: 20) { + HStack(spacing: 20) { + BadgeView(symbol: "star.fill", color: .blue) + BadgeView(symbol: "heart.fill", color: .red) + BadgeView(symbol: "leaf.fill", color: .green) + } +} +``` + +## References + +- [Applying Liquid Glass to custom views](https://developer.apple.com/documentation/SwiftUI/Applying-Liquid-Glass-to-custom-views) +- [Landmarks: Building an app with Liquid Glass](https://developer.apple.com/documentation/SwiftUI/Landmarks-Building-an-app-with-Liquid-Glass) +- [SwiftUI View.glassEffect(_:in:isEnabled:)](https://developer.apple.com/documentation/SwiftUI/View/glassEffect(_:in:isEnabled:)) +- [SwiftUI GlassEffectContainer](https://developer.apple.com/documentation/SwiftUI/GlassEffectContainer) +- [SwiftUI GlassEffectTransition](https://developer.apple.com/documentation/SwiftUI/GlassEffectTransition) +- [SwiftUI GlassButtonStyle](https://developer.apple.com/documentation/SwiftUI/GlassButtonStyle) diff --git a/swiftui-view-refactor/SKILL.md b/swiftui-view-refactor/SKILL.md new file mode 100644 index 0000000..99d6659 --- /dev/null +++ b/swiftui-view-refactor/SKILL.md @@ -0,0 +1,61 @@ +--- +name: swiftui-view-refactor +description: Refactor and review SwiftUI view files for consistent structure, dependency injection, and Observation usage. Use when asked to clean up a SwiftUI view’s layout/ordering, handle view models safely (non-optional when possible), or standardize how dependencies and @Observable state are initialized and passed. +--- + +# SwiftUI View Refactor + +## Overview +Apply a consistent structure and dependency pattern to SwiftUI views, with a focus on ordering, Model-View (MV) patterns, careful view model handling, and correct Observation usage. + +## Core Guidelines + +### 1) View ordering (top → bottom) +- Environment +- `private`/`public` `let` +- `@State` / other stored properties +- computed `var` (non-view) +- `init` +- `body` +- computed view builders / other view helpers +- helper / async functions + +### 2) Prefer MV (Model-View) patterns +- Default to MV: Views are lightweight state expressions; models/services own business logic. +- Favor `@State`, `@Environment`, `@Query`, and `task`/`onChange` for orchestration. +- Inject services and shared models via `@Environment`; keep views small and composable. +- Split large views into subviews rather than introducing a view model. + +### 3) View model handling (only if already present) +- Do not introduce a view model unless the request or existing code clearly calls for one. +- If a view model exists, make it non-optional when possible. +- Pass dependencies to the view via `init`, then pass them into the view model in the view's `init`. +- Avoid `bootstrapIfNeeded` patterns. + +Example (Observation-based): + +```swift +@State private var viewModel: SomeViewModel + +init(dependency: Dependency) { + _viewModel = State(initialValue: SomeViewModel(dependency: dependency)) +} +``` + +### 4) Observation usage +- For `@Observable` reference types, store them as `@State` in the root view. +- Pass observables down explicitly as needed; avoid optional state unless required. + +## Workflow + +1) Reorder the view to match the ordering rules. +2) Favor MV: move lightweight orchestration into the view using `@State`, `@Environment`, `@Query`, `task`, and `onChange`. +3) If a view model exists, replace optional view models with a non-optional `@State` view model initialized in `init` by passing dependencies from the view. +4) Confirm Observation usage: `@State` for root `@Observable` view models, no redundant wrappers. +5) Keep behavior intact: do not change layout or business logic unless requested. + +## Notes + +- Prefer small, explicit helpers over large conditional blocks. +- Keep computed view builders below `body` and non-view computed vars above `init`. +- For MV-first guidance and rationale, see `references/mv-patterns.md`. diff --git a/swiftui-view-refactor/references/mv-patterns.md b/swiftui-view-refactor/references/mv-patterns.md new file mode 100644 index 0000000..7a485b3 --- /dev/null +++ b/swiftui-view-refactor/references/mv-patterns.md @@ -0,0 +1,302 @@ +# MV Patterns Reference + +Source provided by user: "SwiftUI in 2025: Forget MVVM" (Thomas Ricouard). + +Use this as guidance when deciding whether to introduce a view model. + +Key points: +- Default to MV: views are lightweight state expressions and orchestration points. +- Prefer `@State`, `@Environment`, `@Query`, `task`, and `onChange` over view models. +- Inject services and shared models via `@Environment`; keep logic in services/models. +- Split large views into smaller views instead of moving logic into a view model. +- Avoid manual data fetching that duplicates SwiftUI/SwiftData mechanisms. +- Test models/services and business logic; views should stay simple and declarative. + +# SwiftUI in 2025: Forget MVVM + +*Let me tell you why* + +**Thomas Ricouard** +10 min read · Jun 2, 2025 + +--- + +It’s 2025, and I’m still getting asked the same question: + +> “Where are your ViewModels?” + +Every time I share this opinion or code from my open-source projects like my BlueSky client **IcySky**, or even the Medium iOS app, developers are surprised to see clean, simple views without a single ViewModel in sight. + +Let me be clear: + +You don’t need ViewModels in SwiftUI. +You never did. +You never will. + +--- + +## The MVVM Trap + +When SwiftUI launched in 2019, many developers brought their UIKit baggage with them. We were so used to the *Massive View Controller* problem that we immediately reached for MVVM as our savior. + +But SwiftUI isn’t UIKit. + +It was designed from the ground up with a different philosophy, highlighted in multiple WWDC sessions like: + +- *Data Flow Through SwiftUI (WWDC19)* +- *Data Essentials in SwiftUI (WWDC20)* +- *Discover Observation in SwiftUI (WWDC23)* + +Those sessions barely mention ViewModels. + +Why? Because ViewModels are almost alien to SwiftUI’s data flow model. + +SwiftUI views are **structs**, not classes. They are lightweight, disposable, and recreated frequently. Adding a ViewModel means fighting the framework’s core design. + +--- + +## Views as Pure State Expressions + +In my latest IcySky app, every view follows the same pattern I’ve advocated for years. + +```swift +struct FeedView: View { + + @Environment(BlueSkyClient.self) private var client + @Environment(AppTheme.self) private var theme + + enum ViewState { + case loading + case error(String) + case loaded([Post]) + } + + @State private var viewState: ViewState = .loading + @State private var isRefreshing = false + + var body: some View { + NavigationStack { + List { + switch viewState { + case .loading: + ProgressView("Loading feed...") + .frame(maxWidth: .infinity) + .listRowSeparator(.hidden) + + case .error(let message): + ErrorStateView( + message: message, + retryAction: { await loadFeed() } + ) + .listRowSeparator(.hidden) + + case .loaded(let posts): + ForEach(posts) { post in + PostRowView(post: post) + .listRowInsets(.init()) + } + } + } + .listStyle(.plain) + .refreshable { await refreshFeed() } + .task { await loadFeed() } + } + } +} +The state is defined inside the view, using an enum. + +No ViewModel. +No indirection. +The view is a direct expression of state. + +The Magic of Environment +Instead of dependency injection through ViewModels, SwiftUI gives us @Environment. + +swift +Copy code +@Environment(BlueSkyClient.self) private var client + +private func loadFeed() async { + do { + let posts = try await client.getFeed() + viewState = .loaded(posts) + } catch { + viewState = .error(error.localizedDescription) + } +} +Your services live in the environment, are testable in isolation, and encapsulate complexity. + +The view orchestrates UI flow — nothing else. + +Real-World Complexity +“This only works for simple apps.” + +No. + +IcySky handles authentication, complex feeds, navigation, and user interaction — without ViewModels. + +The Medium iOS app (millions of users) is now mostly SwiftUI and uses very few ViewModels, most of them legacy from 2019. + +For new features, we inject services into the environment and build lightweight views with local state. + +Using .task(id:) and .onChange() +SwiftUI’s modifiers act as small state reducers. + +swift +Copy code +.task(id: searchText) { + guard !searchText.isEmpty else { return } + await searchFeed(query: searchText) +} +.onChange(of: isInSearch, initial: false) { + guard !isInSearch else { return } + Task { await fetchSuggestedFeed() } +} +Readable. Local. Explicit. + +App-Level Environment Setup +swift +Copy code +@main +struct IcySkyApp: App { + + @Environment(\.scenePhase) var scenePhase + + @State var client: BSkyClient? + @State var auth: Auth = .init() + @State var currentUser: CurrentUser? + @State var router: AppRouter = .init(initialTab: .feed) + + var body: some Scene { + WindowGroup { + TabView(selection: $router.selectedTab) { + if client != nil && currentUser != nil { + ForEach(AppTab.allCases) { tab in + AppTabRootView(tab: tab) + .tag(tab) + .toolbarVisibility(.hidden, for: .tabBar) + } + } else { + ProgressView() + .containerRelativeFrame([.horizontal, .vertical]) + } + } + .environment(client) + .environment(currentUser) + .environment(auth) + .environment(router) + } + } +} +All dependencies are injected once and available everywhere. + +SwiftData: The Perfect Example +SwiftData was built to work directly in views. + +swift +Copy code +struct BookListView: View { + + @Query private var books: [Book] + @Environment(\.modelContext) private var modelContext + + var body: some View { + List { + ForEach(books) { book in + BookRowView(book: book) + .swipeActions { + Button("Delete", role: .destructive) { + modelContext.delete(book) + } + } + } + } + } +} +Now compare that to forcing a ViewModel: + +swift +Copy code +@Observable +class BookListViewModel { + private var modelContext: ModelContext + var books: [Book] = [] + + init(modelContext: ModelContext) { + self.modelContext = modelContext + fetchBooks() + } + + func fetchBooks() { + let descriptor = FetchDescriptor() + books = try! modelContext.fetch(descriptor) + } +} +Manual fetching. Manual refresh. Boilerplate everywhere. + +You’re fighting the framework. + +Testing Reality +Testing SwiftUI views provides minimal value. + +Instead: + +Unit test services and business logic + +Test models and transformations + +Use SwiftUI previews for visual regression + +Use UI automation for E2E tests + +If needed, use ViewInspector for view introspection. + +The 2025 Reality +SwiftUI is mature: + +@Observable + +Better Environment + +Improved async & task lifecycle + +Almost everything you need lives inside the view. + +I’ll reconsider ViewModels when Apple lets us access Environment outside views. + +Until then, vanilla SwiftUI is the canon. + +Why This Matters +Every ViewModel adds: + +More complexity + +More objects to sync + +More indirection + +More cognitive overhead + +SwiftUI gives you: + +@State + +@Environment + +@Observable + +Binding + +Use them. Trust the framework. + +The Bottom Line +In 2025, there’s no excuse for cluttering SwiftUI apps with unnecessary ViewModels. + +Let views be pure expressions of state. + +Focus complexity where it belongs: services and business logic. + +Goodbye MVVM 🚮 +Long live the View 👑 + +Happy coding 🚀