llms-full.txt@site · git:20260917.a725c6f · 2026-09-17 · sha256 966c09c2abfc66ce
llms-full.txt@site git:20260917.a725c6fB
Immutable. This exact content is served forever at /api/v1/blob/966c09c2abfc66ce.
# iOS Agent Skill
Give your coding agent the Apple references, Swift source and local tools it needs to build and review an iOS app. Use it to turn an app idea into an editable starter, improve an existing Swift project, and check the result with Xcode and the simulator.
# Practical Swift & AI development guides
https://nagarjuna2997.github.io/ios-agent-skill/blog.html
The project notebookBuild with AI.
Check with evidence.Practical Swift and iOS workflows: focused reviews, simulator checks and honest setup advice.Explore all framework and feature guides →
Guided series →All
Swift reviews
Testing & Simulator
AI clients
SwiftUI
Design & assets
Agent workflowSwift reviewsReview AI-generated Swift before you trust itA focused review, a small patch and a real test beat a confident completion message.Read the guide →
Testing & SimulatorFive checks for an AI-built SwiftUI appUse persistence, search and accessible states to make “it works” a testable claim.Read the guide →
AI clientsChoose an AI client for your iOS workflowUnderstand the difference between a skill, a local MCP connection and ChatGPT web setup.Read the guide →
AI clientsClaude Code: turn a Swift finding into a tested patchA small review-to-test loop for an existing iOS project.Read the guide →
AI clientsChatGPT and Codex: plan the app, then verify it locallySeparate a browser planning session from a connected development environment.Read the guide →
AI clientsGemini CLI: connect once and review one iOS featureAvoid duplicate connections and keep setup evidence separate from app evidence.Read the guide →
AI clientsMuse Code: what our integration actually verifiesDiscovery and hooks are useful milestones, but they are not a finished app.Read the guide →
SwiftUISwiftUI: review state transitions before polishing the screenMake loading, empty, success and error states part of the implementation brief.Read the guide →
Design & assetsGenerate light and dark asset catalogs without a paid design toolTurn explicit color tokens into Assets.xcassets, then inspect the result in your app.Read the guide →
AI clientsWhich Muse Code MCP settings actually work with a local Swift review server?A reproducible Muse 1.3.0 connection check, with the exact configuration and clear limits on what discovery proves.Read the guide →
Agent workflowHow can Claude Code hooks protect generated files and verify a project before stopping?A tested generated-file guard and Stop check, with configuration, reproducible exit codes, and enforcement limits.Read the guide →
Swift reviewsHow can I catch Swift concurrency review findings before accepting an agent’s changes?A real before-and-after MCP review with file and line evidence, plus the limits of text-based concurrency checks.Read the guide →
Swift reviewsHow should I review availability guards when moving a Swift app toward iOS 27?A measured guard-review example that separates SDK availability, runtime readiness, and static-analysis limits.Read the guide →
Design & assetsHow can design tokens generate light, dark and high-contrast color assets?Generate a real xcassets catalog from explicit color tokens, inspect its four variants, and compile it with actool.Read the guide →
---
# How can design tokens generate light, dark and…
https://nagarjuna2997.github.io/ios-agent-skill/blog/asset-catalogs.html
← Back to all articlesDesign & assets · iOS Agent Skill projectHow can design tokens generate light, dark and high-contrast color assets?Generate a real xcassets catalog from explicit color tokens, inspect its four variants, and compile it with actool.On this pageWhat belongs in the token file?How do I generate the catalog?What should I inspect in Contents.json?Did Xcode accept the generated files?Do I need Figma or a paid design tool?LimitsLast verified
The workflow
01
What belongs in the token file?02
How do I generate the catalog?03
What should I inspect in Contents.json?04
Did Xcode accept the generated files?
Define each semantic color with explicit light, dark, high-contrast-light, and high-contrast-dark values, then generate a named color set from that JSON. In this walkthrough, the published CLI created a real asset catalog and Xcode's asset compiler successfully produced an
Assets.car
file.
What belongs in the token file?
A token name should express a role that the application can reuse.
AccentColor
,
Background
, and
TextPrimary
describe intent more clearly than names tied to a particular screen coordinate. This example uses only the required accent to keep the generated output small enough to inspect completely.
The generator accepts a versioned JSON format, not arbitrary design prose. Every color needs all four appearance values. That requirement avoids silently guessing a dark equivalent from a light color. It also makes omissions visible during generation instead of leaving the coding agent to fill them differently in several Swift files.
Here is the
exact token input
used for the run:
{
"version": 1,
"colors": {
"AccentColor": {
"light": "#2457DB",
"dark": "#90B4FF",
"highContrastLight": "#12358F",
"highContrastDark": "#C7DAFF"
}
}
}
The repository documents sRGB hexadecimal values, including an optional alpha component. It also requires ASCII identifier names that are unique without regard to case. These are constraints of this generator's input contract, not a claim that every asset pipeline must use the same token format.
How do I generate the catalog?
With the published package, the command is:
npx -y ios-agent-mcp@2.7.0 assets \
--tokens tokens.json \
--output App/Assets.xcassets
The parent
App
directory must already exist. The destination catalog must not exist, because this command refuses to overwrite an existing catalog. Generate a sibling directory when evaluating a token change, inspect the differences, and merge the intended output into the project through its normal review process.
For the actual run I used an isolated installation of that exact npm version and called its entry point directly. This avoids an ambiguous
latest
dependency while recording evidence. The command reported two created catalog files: the catalog metadata and
AccentColor.colorset/Contents.json
.
The resulting directory structure is small:
Assets.xcassets/
Contents.json
AccentColor.colorset/
Contents.json
Apple's
named-color format reference
documents color-set metadata and color components. The generated
Contents.json
is available with this draft, rather than represented only by an illustration.
What should I inspect in Contents.json?
Check that the color set has four entries and that each entry corresponds to the intended appearance. The default entry supplies the ordinary light value. The remaining entries distinguish dark appearance, increased contrast, and their combination. Compare the normalized channel values with the input rather than assuming a successfully written file has the intended color.
Also check the semantic name. A Swift reference to a differently spelled asset will not become correct merely because the catalog itself compiles. Keep the code and token vocabulary aligned. This generator does not audit all
Color
calls or prove that the application actually uses the newly generated color.
For broader palettes, review foreground/background pairs together. Four present variants are a structural property. Readable contrast is a separate visual and numerical property that depends on the actual background, transparency, and surrounding interface.
Did Xcode accept the generated files?
Yes. I compiled the generated catalog with the installed simulator SDK:
mkdir -p compiled-assets
xcrun actool Assets.xcassets \
--compile compiled-assets \
--platform iphonesimulator \
--minimum-deployment-target 17.0 \
--target-device iphone \
--output-format human-readable-text
The command returned exit code 0 and reported
compiled-assets/Assets.car
. The
recorded compiler output
is included. This verifies that the asset compiler accepted the catalog under the recorded toolchain; it is stronger evidence than JSON parsing alone.
It is still not a screenshot test. The run did not launch an app, switch interface appearance, or inspect Dynamic Type. To establish the final user experience, integrate the catalog into the target, build, and inspect the relevant screens in all supported appearance states.
Do I need Figma or a paid design tool?
No. The input is ordinary JSON and can be authored in a text editor. If a designer already uses Figma, map resolved variables to the four explicit fields. Do not add a second tool solely to supply a small token file.
Icons are a separate concern. The repository can rasterize ordered SVG layers into a PNG, but a flattened PNG is not a native Icon Composer document. This color-only test generated no icon and makes no claim about Liquid Glass, required icon sizes, or App Store submission. Keep those deliverables and their verification records separate.
Limits
Successful generation does not certify accessibility, color contrast, semantic naming quality, or review acceptance. Existing catalogs are deliberately not overwritten. This run used one color and one simulator target; it did not exercise every possible alpha value, device family, or project integration. The cover image is conceptual artwork, not a rendering of these exact four swatches in an app.
Last verified
September 16, 2026. Published MCP package 2.7.0 with CLI 0.3.0; Node.js 24.15.0; Xcode 26.6 build 17F113. Asset generation and
actool
compilation passed. Repository source:
docs/design/asset-generation.md
.
Example project:
ios-agent-skill
.
---
# How should I review availability guards when moving a…
https://nagarjuna2997.github.io/ios-agent-skill/blog/availability-guards.html
← Back to all articlesSwift reviews · iOS Agent Skill projectHow should I review availability guards when moving a Swift app toward iOS 27?A measured guard-review example that separates SDK availability, runtime readiness, and static-analysis limits.On this pageWhich versions must stay separate?What did the test contain?What did the reviewer report?What happened after editing the fixtures?Which checks belong in the real review?LimitsLast verified
The workflow
01
Which versions must stay separate?02
What did the test contain?03
What did the reviewer report?04
What happened after editing the fixtures?
Guard a symbol at the OS version where it becomes available, then verify the older-system fallback and any separate runtime readiness requirements. This article ran the published availability reviewer against synthetic files, but did not compile or execute iOS 27 APIs because the installed toolchain is Xcode 26.6.
Which versions must stay separate?
A toolchain version, the SDK it contains, and an application's minimum deployment target answer different questions. The repository's compatibility matrix distinguishes them because a newly installed SDK should not automatically remove support for older devices. A guard must describe the API being used, not simply repeat the newest version number in the project documentation.
There are two useful review failures to look for. An unguarded newer API can prevent supporting an older deployment target. An unnecessarily restrictive guard can send supported devices down an older fallback path. The latter may be invisible if every local test runs on the newest OS.
Apple's
Private Cloud Compute integration guide
supplies a concrete iOS 27 example and discusses falling back on earlier systems. Treat that documentation as the API authority. The table below is instead a record of what this review tool detected.
What did the test contain?
I created four separate Swift source fixtures.
Card.swift
uses
glassEffect()
inside an iOS 27 guard. Three additional files contain bare symbol references for
PrivateCloudComputeLanguageModel
,
DynamicProfile
, and
OCRTool
. The latter files are explicitly labeled lexical fixtures: they exercise the reviewer's matching rules and are not compiling examples of those APIs.
The distinction is especially important for nested types or APIs that require framework-specific setup. A token appearing in a source file is enough to test a lexical check, but not enough to teach correct API usage. I have kept the synthetic source downloadable so a reader can see exactly what was tested.
I called
check_availability_guards
through the published server's stdio MCP connection, passing the fixture directory. The
before response
reported three blocker findings and one serious finding across four files.
What did the reviewer report?
Fixture location
Matched item
Reported result
Cloud.swift:2
PrivateCloudComputeLanguageModel
missing iOS 27 guard
Profile.swift:2
DynamicProfile
missing iOS 27 guard
Tools.swift:2
OCRTool
missing iOS 27 guard
Card.swift:6
glassEffect()
iOS 27 guard stricter than the reviewer's iOS 26 entry
This table is a reproducible analyzer result, not a complete iOS 27 availability index. Before adopting any symbol, check its current Apple declaration, enclosing type, platform, and minor-version requirements. A short maintained pattern table cannot enumerate every API in an SDK.
For the card, the proposed change is deliberately small:
if #available(iOS 26.0, *) {
Text("Reading").glassEffect()
} else {
Text("Reading")
}
The corresponding
Apple API reference
remains the source for the modifier's contract. The fallback here preserves readable content; a production design may require more deliberate visual treatment.
What happened after editing the fixtures?
I changed the card guard from 27.0 to 26.0. For the lexical iOS 27 fixtures, I placed the references inside functions annotated with
@available(iOS 27.0, *)
. These changes test the rule's response to the relevant annotation; they do not turn placeholder references into complete Foundation Models features.
The same tool then returned zero findings across the four files. The
after response
preserves that result. The before and after sources are included beside the JSON, allowing another reviewer to check that the experiment changed guards rather than removing the matched names entirely.
The clean result answers a narrow question: did the tool stop flagging these patterns? It does not answer whether the surrounding app compiles, whether every call site is guarded, or whether a selected model is available at runtime.
Which checks belong in the real review?
First identify the application's oldest supported OS. Then inspect the introduction version of each newly adopted symbol. Check the scope of the guard around the use, including helper methods and initializers; finding an unrelated guard elsewhere in a file is insufficient.
Next inspect the fallback as product behavior. Does the same action remain possible? Is disabled functionality explained? Does a view still have meaningful content? An empty branch can be syntactically acceptable while leaving an older device with a broken experience.
For model-backed features, separate API presence from model readiness. Apple's
generation guide
documents checking availability before starting a session. An OS-version check alone does not establish that the model can answer a request.
Limits
The reviewer uses source heuristics and can miss scope, target settings, and minor-version distinctions. It is not an SDK parser or a compiler. This draft demonstrates its current behavior, including that limitation, and does not certify iOS 27 compatibility. Runtime fallback testing on supported OS versions is still required. Never replace a compiler diagnostic with a more convenient zero-findings report.
Last verified
September 16, 2026, local time. Published
ios-agent-mcp
2.7.0; Node.js 24.15.0; host Xcode 26.6 build 17F113. No Xcode 27 build performed. Repository sources:
docs/compatibility-matrix.md
and
docs/apple/ios-27-release-verification.md
.
Example project:
ios-agent-skill
.
---
# ChatGPT and Codex: plan the app, then verify it locally
https://nagarjuna2997.github.io/ios-agent-skill/blog/chatgpt-codex-ios.html
← Back to all articlesAI clients · iOS Agent Skill projectChatGPT and Codex: plan the app, then verify it locallySeparate a browser planning session from a connected development environment.On this pageStart with behavior, not a screen description aloneChoose the correct connection pathMake the handoff explicitReview the completion messageDecision checkpoints
The workflow
01
App brief02
Acceptance criteria03
Local implementation04
Recorded evidence
Start with behavior, not a screen description alone
A useful app brief describes what the developer should be able to verify. For a reading list, specify adding a book, preserving it after a relaunch and returning the original list after clearing a search. Ask the agent to list unresolved decisions before generating a large implementation.
Plan a reading-list app with local persistence and search.
List screens, state transitions and acceptance criteria.
Identify which checks require a running simulator.
Do not claim any check has passed until it has run.
Choose the correct connection path
For a local Codex environment, the project documents this MCP setup:
codex mcp add ios-agent -- npx -y ios-agent-mcp@latest
ChatGPT web is a different environment. It does not run that command on your Mac. Follow the
ChatGPT guide
for supported skills or a separately configured HTTPS/private-tunnel connection. Availability depends on account and workspace policy; this project does not provide a public hosted Mac.
Make the handoff explicit
Carry the brief and acceptance criteria into the local project. Ask the connected agent to inspect existing code, retrieve relevant references and propose one implementation slice. Keep the actual app source and test results in the local development workflow. A plan written in a browser is not a simulator result.
Review the completion message
Require the changed files, commands run, outcomes and remaining gaps. Compilation is useful but does not prove persistence or accessible states. Use the simulator for visual evidence and tests for behavior where practical. Do not infer better token efficiency merely because references are retrieved in smaller sections.
Official Codex MCP documentation
·
Project setup instructions
Decision checkpoints
Situation
Action or status
Evidence or boundaryPlanning
Screens and acceptance criteria
A plan, not a running appImplementation
Connected local environment
Inspectable source changesVerification
Build, tests and simulator
Recorded evidence
---
# Choose an AI client for your iOS workflow
https://nagarjuna2997.github.io/ios-agent-skill/blog/choose-ai-client.html
← Back to all articlesAI clients · iOS Agent Skill projectChoose an AI client for your iOS workflowUnderstand the difference between a skill, a local MCP connection and ChatGPT web setup.On this pageA skill and a tool connection do different jobsClaude and Codex on your MacGemini CLI and MuseChatGPT in a browserUse the same first taskDecision checkpoints
The workflow
01
A skill and a tool connection do different jobs02
Claude and Codex on your Mac03
Gemini CLI and Muse04
ChatGPT in a browser
A skill and a tool connection do different jobs
A skill supplies instructions and references to a coding agent. An MCP connection exposes callable tools. Installing source guidance alone does not give a client permission or an executable path to build your app in Xcode.
iOS Agent Skill focuses on Claude, ChatGPT/Codex, Gemini CLI and Muse. Choose the client you already use and start with one small review before enabling build or simulator actions.
Claude and Codex on your Mac
Local coding clients can connect to the npm server. Open your app folder, follow the appropriate setup command and verify tool discovery. Claude Desktop uses a different configuration path from Claude Code; do not paste one client’s configuration into another.
Claude setup
·
Codex setup
Gemini CLI and Muse
The repository includes setup instructions for both. The Gemini record verifies extension validation and a stdio connection. Muse’s record verifies tool discovery and a Stop hook. These are bounded checks, not evidence that every model-driven app-building task succeeds.
Gemini CLI setup
·
Muse setup
ChatGPT in a browser
A browser conversation cannot use a local stdio command as if it were a terminal client. The guide separates portable skills from an HTTPS or private-tunnel MCP connection. Supported options depend on your account and workspace policy. Do not expose a local development server publicly just to make a connection work.
Read the ChatGPT connection options
Use the same first task
List the available iOS Agent tools.
Review one Swift file and show the finding locations.
Explain which checks ran and which remain unverified.
The package requires Node.js 20 or later. Xcode build, test and simulator operations require macOS and Xcode. The project is MIT-licensed, but your selected AI service may have its own costs.
When comparing clients, keep the project, prompt and acceptance criteria constant. A successful connection is a setup result; it is not a benchmark or a guarantee of a finished app.
Read the recorded client checks
Decision checkpoints
Situation
Action or status
Evidence or boundaryLocal coding client
Local stdio MCP server
Node.js; Xcode for simulator workBrowser conversation
Supported remote connection or portable guidance
Account policy and secure hostingConnection test
Tool discovery
Does not establish model task success
---
# How can Claude Code hooks protect generated files and…
https://nagarjuna2997.github.io/ios-agent-skill/blog/claude-hooks.html
← Back to all articlesAgent workflow · iOS Agent Skill projectHow can Claude Code hooks protect generated files and verify a project before stopping?A tested generated-file guard and Stop check, with configuration, reproducible exit codes, and enforcement limits.On this pageWhich problem does each hook solve?How are the three events connected?What did the worked example return?What does the Stop check establish?What can these hooks miss?LimitsLast verified
The workflow
01
Which problem does each hook solve?02
How are the three events connected?03
What did the worked example return?04
What does the Stop check establish?
Use a PreToolUse hook to reject edits to generated files, a PostToolUse hook to regenerate outputs after source changes, and a Stop hook to run deterministic checks. In this example, direct script tests blocked an instruction mirror, allowed its source, and passed repository consistency checks; a complete Claude-driven lifecycle session was not rerun.
Which problem does each hook solve?
A generated file can look like the easiest place to make an edit. The change appears to work until the generator runs again and removes it. A prompt asking the coding agent to remember the source of truth helps, but a script can detect the specific mistake earlier and return a useful explanation.
The example repository keeps its source instructions in
SKILL.md
. Its supported client instruction files are generated mirrors. The guard protects those mirror paths and tells the agent to edit the source. This is a repository-maintenance example used in an iOS tooling project, not a claim that every iOS app should generate the same files.
The second hook synchronizes mirrors after the source changes. The final hook checks that the repository is internally consistent before the turn ends. Each check has a small, inspectable responsibility. None is a replacement for compiling an app or exercising a simulator.
Anthropic's
hook reference
documents event configuration and hook inputs. Keep the client reference close when adapting a hook: input fields and event behavior should come from the client, while your file-protection policy should come from your project.
How are the three events connected?
The repository configures these command handlers:
{
"hooks": {
"PreToolUse": [{
"matcher": "Edit|Write|MultiEdit",
"hooks": [{"type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/scripts/hooks/guard-generated-files.sh", "timeout": 10}]
}],
"PostToolUse": [{
"matcher": "Edit|Write|MultiEdit",
"hooks": [{"type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/scripts/hooks/sync-mirrors-on-edit.sh", "timeout": 30}]
}],
"Stop": [{
"hooks": [{"type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/scripts/hooks/verify-repo.sh", "timeout": 60}]
}]
}
}
The quoted project directory matters when a checkout path contains spaces. The command scripts must exist and be executable. Inspect them before enabling the configuration, and merge the hooks with existing settings instead of overwriting permissions or other handlers.
Do not copy these paths into an unrelated app and expect them to work. An app might protect generated API models or an Xcode project derived from a specification. Its generator and verification command will differ. Start by naming the actual source file, generated outputs, and command that relates them.
What did the worked example return?
I sent the guard synthetic Edit input for
AGENTS.md
, using the checkout's absolute path. The process returned exit code 2 and a message explaining that the file is generated from
SKILL.md
. It named the source file and the synchronization command. No edit was performed; this test exercises the decision script directly.
I repeated the input with
SKILL.md
. The guard returned exit code 0 with no error. That pair tests both sides of the policy: a protected target is rejected, while a permitted target can proceed. Testing only the rejection could hide a guard that blocks all development.
Then I ran the configured verification script directly:
bash scripts/hooks/verify-repo.sh
It returned exit code 0. The
recorded inputs and results
preserve these checks and a successful direct PostToolUse synchronization run. Read the
guard script
,
synchronization handler
, and
verification script
before enabling them; they depend on a complete source checkout. The guard message, rather than a screenshot of a green badge, is the useful evidence because it shows the corrective instruction that would be returned.
What does the Stop check establish?
The repository documentation describes mirror synchronization, instruction frontmatter, referenced documentation paths, and subagent frontmatter as consistency checks. A successful run means those checks passed for this checkout at this moment. It does not mean Swift tests ran, an app launched, or every reference page is technically correct.
For an application project, select checks that match the work. A resource-only edit might need asset compilation and a build. A persistence change needs behavior tests. Avoid describing a successful documentation script as an app acceptance result merely because both run at the same lifecycle event.
The most useful failure output identifies the check, the affected path, and the repair command. A bare failure code forces the agent to rediscover the problem. Conversely, a message claiming a test ran when the script only checked metadata creates false confidence.
What can these hooks miss?
The configured matcher covers named editing tools. It is not a filesystem security boundary. A different write path, including a shell command, needs separate consideration. Path normalization, symlinks, and platform separators also deserve tests before extending this pattern across operating systems.
A PostToolUse action occurs after a tool has run, so it cannot retroactively prevent that write. Keep generated changes reviewable and retain CI checks even when hooks work locally. Client-side feedback and remote verification serve different points in the workflow.
Limits
This article verified standalone script behavior, not the current client's complete hook dispatch. The PostToolUse script also completed successfully when invoked directly with synthetic source-edit input; it was not exercised in a model session. The example protects instruction mirrors, not arbitrary Xcode outputs. Existing documentation contains broader descriptions of generated mirrors; use the tested paths and current script when deciding what is actually protected.
Last verified
September 16, 2026. Installed Claude Code 2.1.273; direct Bash/Python hook tests on macOS; repository verification exit code 0. Repository source:
docs/orchestration/hooks.md
; the downloadable record states the narrower tested scope.
Example project:
ios-agent-skill
.
---
# Claude Code: turn a Swift finding into a tested patch
https://nagarjuna2997.github.io/ios-agent-skill/blog/claude-swift-review.html
← Back to all articlesAI clients · iOS Agent Skill projectClaude Code: turn a Swift finding into a tested patchA small review-to-test loop for an existing iOS project.On this pageKeep the first task deliberately smallConnect the local toolsAsk for evidence before a rewriteStop at a meaningful checkpointDecision checkpoints
The workflow
01
Connect MCP02
Review one file03
Inspect patch04
Build + test
Keep the first task deliberately small
When an agent reviews an entire app at once, it can be difficult to tell which suggestions are worth acting on. Start with the Swift file you just changed. Define the behavior it must preserve, then ask for one focused review before allowing edits.
Connect the local tools
claude mcp add ios-agent -- npx -y ios-agent-mcp@latest
Run the command in your project context, reconnect Claude Code and confirm the server is available. The local server needs Node.js 20 or later. Xcode operations additionally need a Mac with Xcode. Claude Desktop has a different configuration path; use the
client setup guide
.
Ask for evidence before a rewrite
Review the concurrency in the Swift file I changed.
Show each finding with its location and supporting context.
Propose one minimal patch. Preserve the public behavior.
After approval, build and run the relevant tests.
Report failures and checks you could not run.
Read the suggested diff. A heuristic finding about actor isolation is a reason to inspect the surrounding code, not a reason to annotate every type. Keep unrelated refactors out of the patch so a failing test has a smaller set of possible causes.
Stop at a meaningful checkpoint
Save the changed files, commands used and remaining failures. If the build cannot run because Xcode or the scheme is unavailable, record that constraint instead of claiming success. The repository's Claude record demonstrates a bounded text-repair test; it does not establish complete app generation.
Official Claude MCP documentation
·
Project verification record
Decision checkpoints
Situation
Action or status
Evidence or boundaryBefore editing
Read the finding in context
Location and reasonAfter patching
Review the diff
Only intended changesBefore completion
Build and test
Commands, outcomes, remaining gaps
---
# Gemini CLI: connect once and review one iOS feature
https://nagarjuna2997.github.io/ios-agent-skill/blog/gemini-cli-ios.html
← Back to all articlesAI clients · iOS Agent Skill projectGemini CLI: connect once and review one iOS featureAvoid duplicate connections and keep setup evidence separate from app evidence.On this pageUse one installation pathRetrieve only what the feature needsDiagnose setup separatelyKnow what the project testedDecision checkpoints
The workflow
01
Project folder02
One connection03
Focused references04
Swift review
Use one installation path
The project supports a local MCP connection and also has extension guidance. Start with one path rather than installing both and assuming every duplicate tool is necessary. From your app folder, the documented direct connection is:
gemini mcp add ios-agent -- npx -y ios-agent-mcp@latest
Restart or reconnect Gemini CLI and confirm that the server appears. This workflow is for Gemini CLI, not Gemini web chat. The server requires Node.js 20 or later, and simulator tools require macOS and Xcode.
Retrieve only what the feature needs
For a persistence change, ask for the relevant local source or guide sections before reviewing the implementation. Loading an entire documentation collection can obscure the specific behavior you are trying to verify. Focused retrieval is an organization technique, not a measured cost-saving promise.
Inspect this app's persistence change.
Find relevant local references and identify assumptions.
Review only the affected Swift files.
Propose a minimal fix and a test for relaunch persistence.
Diagnose setup separately
If no tools appear, first check the configuration, command path and package availability. A failed connection is not a Swift compiler failure. If tools appear but a build fails, inspect the project, scheme and Xcode diagnostic instead of reinstalling the client repeatedly.
Know what the project tested
The Gemini CLI 0.49.0 record verifies extension validation and a stdio connection to the published package. It explicitly leaves the model session unverified. Reproduce your own task, record its results and report only what happened in that run.
Official Gemini CLI MCP documentation
·
Verification record
Decision checkpoints
Situation
Action or status
Evidence or boundaryTools absent
Check configuration and executable
Connection problemBuild fails
Inspect scheme and Xcode diagnostic
Project problemWrong behavior
Reproduce acceptance criterion
Implementation problem
---
# Generate light and dark asset catalogs without a paid…
https://nagarjuna2997.github.io/ios-agent-skill/blog/generate-asset-catalog.html
← Back to all articlesDesign & assets · iOS Agent Skill projectGenerate light and dark asset catalogs without a paid design toolTurn explicit color tokens into Assets.xcassets, then inspect the result in your app.On this pageMake colors explicitGenerate a new catalogChoose every appearance deliberatelyKeep icon layers editableInspect in contextDecision checkpoints
The workflow
01
Color tokens02
Four appearances03
Asset catalog04
App verification
Make colors explicit
Asset generation is a shipped workflow in ios-agent-mcp 2.7.0. It converts a strict JSON token file into a new asset catalog locally. It does not require Figma, an AI model or an API key. Start with semantic names rather than scattering literal colors through SwiftUI views.
{
"version": 1,
"colors": {
"AccentColor": {
"light": "#2457DB",
"dark": "#90B4FF",
"highContrastLight": "#12358F",
"highContrastDark": "#C7DAFF"
}
}
}
Generate a new catalog
npx -y ios-agent-mcp@latest assets --tokens tokens.json --output App/Assets.xcassets
The parent directory must exist. The generator refuses to overwrite an existing catalog. Generate a sibling catalog when reviewing changes, compare the output and merge only the intended assets.
Choose every appearance deliberately
All four color appearances are required; the generator does not invent a dark-mode conversion. Names must be ASCII identifiers and unique ignoring case, with AccentColor present. Values use the documented sRGB hex format. Check contrast against the actual background rather than assuming a high-contrast token name guarantees readability.
Keep icon layers editable
New app scaffolds can retain editable SVG layers and render their composition as an opaque 1024-pixel PNG. Customize placeholder shapes before distributing an app. A flattened PNG is not a native Liquid Glass Icon Composer bundle, and generating it does not certify App Review acceptance.
Inspect in context
Use the named assets in the app, build it and inspect light, dark and higher-contrast appearances. The generator handles catalog structure; you still verify the visual result and accessible usage. Keep these checks in your acceptance criteria so a later design change does not silently undo them.
Full token schema and icon workflow
Decision checkpoints
Situation
Action or status
Evidence or boundaryLight and dark
Explicit token values
Inspect both appearancesHigh contrast
Two additional explicit values
Measure against real backgroundsExisting catalog
Generate a sibling output
Review and merge intentionally
---
# Which Muse Code MCP settings actually work with a local…
https://nagarjuna2997.github.io/ios-agent-skill/blog/muse-connection.html
← Back to all articlesAI clients · iOS Agent Skill projectWhich Muse Code MCP settings actually work with a local Swift review server?A reproducible Muse 1.3.0 connection check, with the exact configuration and clear limits on what discovery proves.On this pageWhat was actually tested?Which configuration should I start with?How can I repeat the example?What should I check when discovery fails?LimitsLast verified
The workflow
01
What was actually tested?02
Which configuration should I start with?03
How can I repeat the example?04
What should I check when discovery fails?
Muse Code 1.3.0 connected to the published Swift review server using
schema_version: 1
and the camel-case
mcpServers
configuration key. The recorded check discovered 36 tools and executed a Stop hook, but did not verify a model choosing tools or building an app.
What was actually tested?
This walkthrough is deliberately a connection test. The local
echo
provider exercises initialization without making a model request. That distinction matters: a successful tool list establishes that two programs can communicate, while a completed development task requires evidence from the model, the tool, and the resulting project.
The repository's installation guide and its existing client record describe this boundary. I repeated the discovery harness against an isolated installation of the published
ios-agent-mcp@2.7.0
package. The result again contained 36 tool names. Representative entries included
review_swift_concurrency
,
search_local_references
,
create_app
, and
simulator_list
. A command-based Stop hook also left the expected marker.
The record is available as
the complete verification JSON
. It identifies the client build, server package, discovered catalog, and the checks that remain false. It contains no account tokens, personal project content, or generated-app claims.
Which configuration should I start with?
For a local installation, this is the configuration shape documented by the project:
{
"schema_version": 1,
"mcpServers": {
"ios-agent": {
"command": "/absolute/path/to/ios-agent-mcp",
"args": []
}
}
}
Merge that server entry into your existing settings rather than replacing the file. The absolute executable path is a placeholder: obtain the real location from your installation. The harness used an absolute Node executable and the server's JavaScript entry point, rather than relying on shell startup files to add a command to PATH.
The repository's configuration note records camel-case
mcpServers
as the tested key. It does not establish that the snake-case alternative
mcp_servers
works, and this article makes no such claim. Likewise, the schema version belongs to Muse's configuration format; it is not the npm package version and should not be changed to match a release number.
Meta's
configuration documentation
is the vendor reference. That page did not expose readable content to the unauthenticated research tool during this review. The compatibility statement here therefore rests on the executable record and repository documentation, not an invented quotation from Meta.
How can I repeat the example?
Use the verification harness from a source checkout and point it at the Muse executable and the installed server entry point:
node scripts/verify-muse.mjs \
/absolute/path/to/muse \
/absolute/path/to/ios-agent-mcp/dist/unified.js
Both paths must exist. The harness prepares temporary configuration and a temporary workspace, places the existing skill in that workspace, starts Muse with the echo provider, and records MCP discovery. It checks several representative tools rather than accepting any nonempty response. Finally it checks the Stop marker and removes its temporary files.
For this article, the server came from a separate installation pinned to 2.7.0. A first run against the working checkout exposed 37 tools, including an unpublished feedback tool. That was useful development evidence but unsuitable as a description of the npm release. Repeating the check against the released artifact resolved the mismatch. This is why a package version string alone is insufficient when testing uncommitted builds.
A compact reading of the published-package result is:
{
"serverVersion": "2.7.0",
"toolCount": 36,
"stopHookExecuted": true,
"provider": "echo",
"modelSessionVerified": false
}
What should I check when discovery fails?
First confirm the executable path outside the agent. Then validate the JSON and check that the server is nested under the tested key. Preserve unrelated settings when correcting either problem. A missing executable and a valid executable that never completes initialization are different failures, so keep the startup error with the client version.
Next compare the environment used by your shell with the environment the client inherits. This example avoids package fetching during startup; it does not prove an
npx
download will succeed under every network policy. A global installation may be convenient, but the sandboxed download workaround has not been verified by this article.
Finally, do not paste private settings into a public issue. A synthetic configuration with executable placeholders, the versions, and the failure category is usually enough to begin diagnosis.
Limits
Tool discovery does not verify tool invocation, simulator permissions, model quality, skill selection, or app completion. The Stop test does not verify PreToolUse, PostToolUse, or an observer feature. No claim about HTTP transport, pricing tiers, or training policy follows from this stdio test. Recheck compatibility after either program changes.
Last verified
September 16, 2026, America/Chicago; the JSON timestamp is September 17 in UTC. Muse Code 1.3.0 build 1.3.0-R3233.1, published MCP package 2.7.0, Node.js 24.15.0. Repository sources:
docs/mcp/installation.md
and
examples/client-verification/muse-1.3.0.json
.
Example project:
ios-agent-skill
.
---
# Muse Code: what our integration actually verifies
https://nagarjuna2997.github.io/ios-agent-skill/blog/muse-verification.html
← Back to all articlesAI clients · iOS Agent Skill projectMuse Code: what our integration actually verifiesDiscovery and hooks are useful milestones, but they are not a finished app.On this pageRead the evidence before the compatibility claimInstall outside the agent sandboxVerify in stagesKeep failures usefulDecision checkpoints
The workflow
01
Install server02
Discover tools03
Check hook04
Verify model task
Read the evidence before the compatibility claim
The repository records Muse Code 1.3.0 discovering the published MCP tools and executing a Stop hook. The record uses an echo provider. It does not verify a model-driven app-building session, pre/post hooks or observer behavior. That distinction is the starting point for trying the integration.
Install outside the agent sandbox
The project setup guide uses a global install, followed by a settings entry:
npm install -g ios-agent-mcp@latest
Follow the
Muse setup section
and merge its configuration with your existing settings. Preserve the schema version and other servers. If the command cannot be found, inspect the installed executable path instead of guessing a new location.
Verify in stages
Confirm the server starts and tools are discoverable.Ask for a read-only review of a small synthetic Swift file.Inspect the returned finding and its source location.Only then try a controlled edit and an available build/test step.
Record the Muse version and npm package version alongside the outcome. A successful hook invocation does not prove that the agent understood the failure or repaired it correctly.
Keep failures useful
If a step fails, distinguish an unavailable executable, an invalid configuration, a tool error and a model decision. Keep private project source out of public reports. A small synthetic reproduction is easier to inspect and safer to share.
This article describes the project's recorded test scope, not a newly verified Muse release or a claim that its background observer works with every subagent. Future compatibility claims should be backed by a new reproducible record.
Inspect the exact verification record
Decision checkpoints
Situation
Action or status
Evidence or boundaryTool discovery
Recorded
Server communicationStop hook
Recorded with echo provider
Hook invocationModel-driven app build
Not verified by this record
Requires a separate real task
---
# Review AI-generated Swift before you trust it
https://nagarjuna2997.github.io/ios-agent-skill/blog/review-swift-with-ai.html
← Back to all articlesSwift reviews · iOS Agent Skill projectReview AI-generated Swift before you trust itA focused review, a small patch and a real test beat a confident completion message.On this pageStart with one questionConnect, then narrow the taskVerify the proposed fixKeep the review reproducibleDecision checkpoints
The workflow
01
Start with one question02
Connect, then narrow the task03
Verify the proposed fix04
Keep the review reproducible
Start with one question
A broad “review my app” request can produce a long list without a clear next action. Start with the file or feature you changed. Ask your agent to distinguish a compiler error, a heuristic finding and a design suggestion.
iOS Agent Skill provides local references and file-located reviews. It does not replace the Swift compiler. A reported issue is something to investigate, and a clean result is not proof that the app is correct.
Connect, then narrow the task
For Claude Code, run this from your app folder:
claude mcp add ios-agent -- npx -y ios-agent-mcp@latest
Reconnect the client and confirm that the tools appear. For another client, use its
setup guide
. The local server requires Node.js 20 or later.
Review the Swift concurrency in the files I changed.
Search only relevant local references.
For each finding, show the file, line and reason.
Propose the smallest fix and explain how to test it.
Separate verified results from suggestions.
Verify the proposed fix
For example, when a finding concerns UI-observed state, inspect where that state is mutated and what actor isolation the type actually has. Do not add annotations across the project simply because a heuristic suggests them. Ask for the relevant source context, then build with your target SDK.
Review the diff before accepting it. Run tests for the affected behavior and record the command and outcome. If no suitable test exists, say that explicitly instead of treating compilation as a behavior test.
Keep the review reproducible
Keep one issue and its proposed patch together.Record the package version and client used.Use a synthetic example when reporting a false positive.Never attach private app source or credentials to a public issue.
The useful result is an inspectable change with evidence. Token savings and a quality advantage over other workflows have not been established.
Inspect the review tools
·
Read the evidence and limits
Decision checkpoints
Situation
Action or status
Evidence or boundaryCompiler diagnostic
Build with the same scheme and SDK
A successful buildHeuristic finding
Inspect the surrounding isolation and ownership
A justified patch or documented false positiveBehavior concern
Reproduce the user action
A passing behavioral test
---
# How can I catch Swift concurrency review findings before…
https://nagarjuna2997.github.io/ios-agent-skill/blog/swift-concurrency.html
← Back to all articlesSwift reviews · iOS Agent Skill projectHow can I catch Swift concurrency review findings before accepting an agent’s changes?A real before-and-after MCP review with file and line evidence, plus the limits of text-based concurrency checks.On this pageWhy review before accepting a change?What source did the reviewer inspect?Which findings were returned?What change was made?How should I use the result in an app?LimitsLast verified
The workflow
01
Why review before accepting a change?02
What source did the reviewer inspect?03
Which findings were returned?04
What change was made?
Run a focused concurrency review on the proposed changes, inspect each finding in its isolation context, and then use the compiler and behavior tests to verify the repair. A synthetic Swift model produced two real findings in this walkthrough; a narrower, main-actor-isolated version produced none, which is evidence about the review tool rather than proof of race freedom.
Why review before accepting a change?
Agent-generated code can combine patterns from different examples. A model might use observation for UI state while also starting detached work that writes the same state. The important review question is who owns the mutable value and which execution context may access it. Counting occurrences of
async
does not answer that question.
The repository documents a focused tool called
review_swift_concurrency
. It returns file locations, rule identifiers, severities, explanations, and suggested changes. That structure makes a finding easier to investigate than a broad request to improve concurrency. The tool is still heuristic: it reads source patterns rather than constructing the compiler's full isolation model.
Swift's
concurrency migration guide
is the primary reference for language-level diagnosis. The experiment below measures this project's reviewer. It is not a survey establishing which mistakes coding agents make most often.
What source did the reviewer inspect?
I created an intentionally small, synthetic fixture called
FeedModel.swift
. It contains no user app code, networking, or persistence:
import Observation
@Observable
final class FeedModel {
var title = ""
func refresh() {
Task.detached { self.title = "Updated" }
}
}
The detached operation has no independent computation to perform. It exists only to assign a string. That makes the example suitable for discussing ownership without pretending that moving expensive work onto the main actor is a general performance solution.
I connected an MCP client to the published 2.7.0 server and called
review_swift_concurrency
with the fixture directory as its absolute
path
argument. This was a real tool call, not an illustration of what a response might look like. The complete response is preserved in
the before record
.
Which findings were returned?
The response reported one blocker and one serious finding across one Swift file:
Location
Rule
Reported severity
FeedModel.swift:3
observable-without-mainactor
blocker
FeedModel.swift:7
task-detached
serious
Those are the reviewer's classifications. Do not confuse them with compiler diagnostic levels. In particular, observation alone does not establish that every observable type must have an explicit main-actor annotation. Actual ownership, callers, and project isolation settings matter. A reviewer that uses text patterns cannot infer every one of those conditions.
The second finding points to an operation whose detached execution is unnecessary in this fixture. The repair should preserve the program's intended behavior, not merely remove a keyword until the count turns green. In a real application, identify the work that should run independently, the values it returns, and the isolated state that consumes them.
What change was made?
For this UI-state example I made ownership explicit and removed the unnecessary task:
import Observation
@MainActor
@Observable
final class FeedModel {
var title = ""
func refresh() {
title = "Updated"
}
}
The change does not introduce another asynchronous wrapper. Callers now have to respect the model's actor isolation. If a real refresh loads data, keep the loading contract explicit and update the model through its isolation boundary; this tiny example does not implement that service.
I called the same reviewer against the second fixture. The
after record
reports zero findings across one file. Both source fixtures are included with the draft so the line references and change can be inspected. There is no hidden rewrite, model scoring step, or manually adjusted result.
How should I use the result in an app?
Start with the changed model and its callers. Determine whether isolation is explicit on the type, inherited through context, or selected by build settings. Then decide whether the suggested annotation describes the intended ownership. A correct fix in one model may be unnecessary or misleading in another.
Next compile using the application's real Swift language mode and deployment settings. The fixture's successful review does not establish that a view initializer, service, or test can call the repaired model correctly. Those dependencies are precisely where a compiler adds information that the text reviewer lacks.
Finally exercise the behavior that motivated the edit. A feed refresh should show loading, success, failure, and cancellation states as required by the app. A clean review cannot demonstrate any of them. Keep review output beside build and test results so the acceptance report does not flatten several different checks into a single pass.
Limits
This experiment did not run a coding model, compile these fixtures, or measure runtime races. It does not establish an agent error rate or a token-saving advantage. The published reviewer's explanatory wording is stronger than the evidence its pattern matching can prove; interpret findings as investigation leads. A file-level annotation can also conceal a problem in another type in the same file. Zero findings means no matching rule fired, not that all concurrency behavior is correct.
Last verified
September 16, 2026, local time. Published
ios-agent-mcp
2.7.0, Node.js 24.15.0, actual stdio tool calls. Repository sources:
docs/mcp/tools.md
,
docs/mcp/examples.md
, and
docs/evidence-and-scope.md
.
Example project:
ios-agent-skill
.
---
# SwiftUI: review state transitions before polishing the…
https://nagarjuna2997.github.io/ios-agent-skill/blog/swiftui-state-review.html
← Back to all articlesSwiftUI · iOS Agent Skill projectSwiftUI: review state transitions before polishing the screenMake loading, empty, success and error states part of the implementation brief.On this pageA polished screen can still hide missing behaviorDescribe the transitionsKeep ownership understandableUse reviews as a starting pointVerify more than the screenshotDecision checkpoints
The workflow
01
Loading02
Empty03
Content04
Recoverable error
A polished screen can still hide missing behavior
A preview usually shows a convenient state. Real users encounter an empty store, an unfinished request and a failed operation. Before asking an agent to polish a SwiftUI screen, list the states and the actions that move between them.
Describe the transitions
For a reading list, a fresh store starts empty. Adding an item produces content. A search with no matches is different from having no saved books. A storage failure should preserve useful context and offer an appropriate recovery action. Treat those as separate acceptance criteria.
Review this screen's state transitions.
Distinguish loading, empty library, empty search and error.
Keep previews deterministic with injected sample data.
List one observable acceptance check for each state.
Keep ownership understandable
Ask which object owns the state, which views read it and where mutations occur. Follow the project's concurrency guidance and validate isolation with the installed Swift toolchain. Do not change observation mechanisms solely because an API name sounds newer; the app's deployment target and existing architecture matter.
Use reviews as a starting point
The repository includes a SwiftUI review tool and local state/data-flow guidance. Inspect each finding in context. Heuristics do not provide a complete model of runtime behavior, and a screen that compiles may still show the wrong state after an action.
Verify more than the screenshot
Capture each important state in the simulator, then separately test transitions such as clearing a search and relaunching after saving. Inspect larger text and meaningful control labels. Visual checks and accessibility checks answer different questions; do not substitute one for the other.
Read the local state guidance
·
Use the five-check verification workflow
Decision checkpoints
Situation
Action or status
Evidence or boundaryEmpty library
Fresh test store
Explain how to add an itemEmpty search
Nonmatching query
Clear search without losing dataError
Controlled failure
Preserve context and offer recovery
---
# Five checks for an AI-built SwiftUI app
https://nagarjuna2997.github.io/ios-agent-skill/blog/verify-swiftui-simulator.html
← Back to all articlesTesting & Simulator · iOS Agent Skill projectFive checks for an AI-built SwiftUI appUse persistence, search and accessible states to make “it works” a testable claim.On this pageDefine “working” before generating code1. Add and reopen2. Search and recover3. Check empty and error states4. Inspect accessibility5. Keep build and test evidenceDecision checkpoints
The workflow
01
Define “working” before generating code02
1. Add and reopen03
2. Search and recover04
3. Check empty and error states
Define “working” before generating code
A screenshot of a populated list can look convincing while persistence or search is broken. Give your coding agent observable acceptance criteria before asking it to implement the feature. A reading-list app makes a useful small exercise because its behavior is easy to describe.
1. Add and reopen
Create a synthetic book, terminate the app and reopen it. Confirm the item survives. A list that only stays populated during one process has not demonstrated persistence. Keep this test separate from any preview data used for development.
2. Search and recover
Search for a known title, then a title that is absent. Clear the query and verify the original list returns. Decide whether matching should ignore case before implementing the test; do not silently change acceptance criteria to fit the output.
3. Check empty and error states
Use a fresh test store to exercise an empty library. Trigger a controlled storage failure through a test seam if available. The app should explain what happened and provide an appropriate next action. A happy-path screenshot does not verify an error state.
4. Inspect accessibility
Check meaningful labels, readable text and the layout with larger text settings. A screenshot helps reveal clipping, but it does not prove VoiceOver behavior or control semantics. Report visual and accessibility checks separately.
5. Keep build and test evidence
Run the build and tests on a configured Mac with Xcode. Capture the important simulator states and compare them with the acceptance criteria. The bundled MCP tools can build, test, install, launch and capture screens; the agent still needs to choose the right project and checks.
Build the Reading List project and run its tests.
Verify add, persistence after relaunch, search and empty state.
Capture the relevant simulator screens.
List any failed or unverified acceptance criteria.
The linked sample contains recorded project evidence; it does not prove that an arbitrary app or a new client session passed these checks. Start with the sample’s own instructions and do not overwrite real user data.
Run the Reading List demo
·
Set up the verification workflow
Decision checkpoints
Situation
Action or status
Evidence or boundaryPersistence
Terminate and relaunch
Previously saved item remainsSearch
Clear a nonmatching query
Original list returnsAccessibility
Inspect labels and larger text
Readable layout and meaningful controls
---
# Daily community monitor
https://nagarjuna2997.github.io/ios-agent-skill/community-monitor.html
Daily community monitorDaily community monitor
Operational instructions for the scheduled Codex task. Scheduling is in Codex, not the Pages workflow. The daily Pages job refreshes download counts; it does not search the web.
Repository workflow
Read
site/community-mentions.json
and the live
community.html
page first.
Verified mentions are stored in that JSON. Generate the isolated HTML section in
site/community.html
with
python3 scripts/render-community.py
.
Run
python3 scripts/render-community.py --check
,
python3 scripts/render-site.py --check
,
python3 -m unittest discover -s scripts/tests -p test_community.py
and
bash scripts/hooks/verify-repo.sh
.
Keep verification dates stable on no-change days. Do not commit daily reports, timestamp-only updates or inaccessible-candidate churn. Record the run report in the scheduled task.
Work from a clean current checkout; preserve unrelated work, never reset it. Commit only intended files. Push main to publish through GitHub Pages. Never publish npm or change package versions.
Initial deferred baseline links: LibHunt, PickMCP, Product Hunt and npm web page could not be opened successfully. Retry later; inaccessible does not mean removed. Agentmods has a verified alternative canonical card; do not add its hooks/tag pages as duplicate coverage.
Deduplicate language variants, mirrors on the same site and alternate canonical URLs using editorial judgment plus the renderer’s URL checks. Each standalone republication must be explicitly labelled a mirror.
This initial run found skills.rest beyond the supplied baseline. Exclude scraper republications from the directory list. Reddit is maintainer-started with independent comments, not an independent endorsement.
User-supplied monitoring specification
You are responsible for maintaining the public “Community & Mentions” section for this project:
GitHub:
https://github.com/Nagarjuna2997/ios-agent-skill
npm:
https://www.npmjs.com/package/ios-agent-mcp
Project names to monitor:
ios-agent-skill
ios-agent-mcp
iOS Agent Skill
iOS Agent MCP
Nagarjuna2997/ios-agent-skill
Run this workflow every day.
Your job is to search the public internet for new mentions of the project, verify them, compare them against the mentions already shown on the website, and update the website only when a genuinely new and useful mention is found.
Do not add duplicate links.
Do not rewrite the section every day if nothing has changed.
Do not manufacture work.
If everything already looks correct, leave the website unchanged.
SEARCH FOR MENTIONS ACROSS:
Google-indexed web pages
developer blogs
personal blogs
newsletters
Reddit
Hacker News
X/Twitter pages that are publicly indexed
LinkedIn pages that are publicly indexed
YouTube videos and descriptions
GitHub repositories
GitHub issues and discussions
MCP directories
Agent Skill directories
Claude Code directories
Codex directories
Swift/iOS developer communities
Product Hunt
DEV Community
Medium
Hashnode
npm-related pages
Apple/Swift ecosystem websites
AI coding-agent resource pages
comparison websites
curated developer-tool lists
software discovery websites
international or translated pages
Search both exact names and URLs.
Use queries based on:
"ios-agent-skill"
"ios-agent-mcp"
"Nagarjuna2997/ios-agent-skill"
"github.com/Nagarjuna2997/ios-agent-skill"
"npm ios-agent-mcp"
"iOS Agent MCP"
"iOS Agent Skill"
Also search combinations with:
Swift
SwiftUI
Xcode
Xcode 27
iOS
MCP
Claude Code
Codex
Cursor
Gemini CLI
coding agents
Apple development
AI coding
CLASSIFY EVERY RESULT
For each result determine whether it is:
INDEPENDENT COMMUNITY MENTION
Someone else discussed, recommended, reviewed, compared, commented on, linked to, or used the project.
This is the highest-value category.
DIRECTORY / INDEX
A third-party directory automatically or manually indexed the project.
This is useful for discovery but should not be described as an endorsement.
COMMUNITY DISCUSSION
A Reddit, Hacker News, GitHub, forum, or other discussion containing real comments about the project.
ARTICLE / BLOG
An independent article that discusses the project.
MY OWN CONTENT
Posts originally created by Nagarjuna Reddy / Nagarjuna2997.
These can be listed as project coverage but must not be presented as independent press.
MIRROR / REPUBLICATION
A site copying, syndicating, translating, or mirroring one of my own articles.
Do not describe this as independent praise.
OFFICIAL PROJECT PAGE
GitHub, npm, project website, Product Hunt listing, etc.
VERIFY BEFORE ADDING
Before adding a result:
Open the page.
Confirm that it genuinely mentions this exact project.
Confirm the URL still works.
Check whether it is already on the website.
Do not add search-result pages that only coincidentally contain similar words.
Do not confuse similarly named iOS MCP projects with this repository.
Do not add spam or scraped garbage pages unless they provide meaningful discovery value.
Do not claim:
“endorsed by”
“recommended by”
“partnered with”
“officially supported by”
“featured by”
unless the source explicitly supports that statement.
Use safer wording such as:
“Mentioned on”
“Discussed on”
“Indexed on”
“Discovered on”
“Listed on”
“Community discussion”
“Featured, discussed, indexed, or discovered across”
WEBSITE SECTION
Maintain a section titled:
Community & Mentions
Keep this introduction:
“Seeing ios-agent-skill shared, indexed, discussed, and discovered across the developer community means a lot to me. Thank you to everyone who has checked out the project, shared feedback, starred the repository, or helped others discover it. I’m still improving it, and every bit of support genuinely motivates me to keep building.”
Then show the verified mentions as clean cards.
Each card should contain:
Site/platform name
Short category such as:
Community
Article
Directory
Developer Resource
Discussion
Launch
Package
One short factual description
Direct external link
Do not show exaggerated marketing language.
Prefer the strongest mentions first.
Suggested ordering:
Independent human/community mentions
Independent articles/blogs
Developer resource collections
Discussions
Curated directories
Automated directories
My own posts
Mirrors
CURRENT KNOWN LINKS
Use these as the starting baseline and do not duplicate them:
Kimi
https://www.kimi.ai/resources/software-skills-for-agents
LibHunt
https://www.libhunt.com/compare-appstore-doctor-vs-ios-agent-skill
SkillsMP
https://skillsmp.com/creators/nagarjuna2997/ios-agent-skill/skill
Awesome Skills
https://www.awesomeskills.dev/en/skill/nagarjuna2997-ios-agent-skill
Awesome MCP Servers
https://mcpservers.org/servers/nagarjuna2997/ios-agent-skill
PickMCP
https://pickmcp.com/servers/Nagarjuna2997/ios-agent-skill
Agentmods
https://agentmods.dev/hooks/nagarjuna2997/ios-agent-skill
SkillWorks
https://skillworks.thecompound.tech/claude-md-examples
Reddit / r/Xcode
https://www.reddit.com/r/Xcode/comments/1v8j94w/i_got_tired_of_ai_agents_writing_2019era_swiftui/
DEV Community
https://dev.to/nagarjuna_reddy_7ca85e003/im-building-an-mcp-toolbox-for-swift-xcode-and-ios-simulator-kp9
Product Hunt
https://www.producthunt.com/products/ios-agent-mcp
npm
https://www.npmjs.com/package/ios-agent-mcp
GitHub
https://github.com/Nagarjuna2997/ios-agent-skill
Do not assume this list is complete.
Search for new mentions every day.
THANK-YOU AREA
Keep this message near the bottom:
“Thank you for supporting ios-agent-skill.”
“This started as a side project because I wanted AI coding agents to work better with real iOS development workflows. Seeing developers discover it, read the documentation, give feedback, and share it keeps me motivated to make it better.”
Then add:
“Using ios-agent-skill in a real project? I’d love to hear about it.”
Keep buttons for:
View on GitHub
Share Feedback
Star the Project
DAILY BEHAVIOR
Every daily run should follow this process:
Read the existing website section first.
Extract all URLs already displayed.
Search broadly for new mentions.
Verify every candidate.
Deduplicate against existing links.
Add only genuinely new mentions.
Preserve all good existing links.
Fix broken links if necessary.
Do not remove a mention simply because it did not appear in today's search.
Do not modify unrelated parts of the website.
Do not redesign the page unless there is an actual layout problem.
Keep the section fast, responsive, accessible, and mobile friendly.
External links should open safely in a new tab where appropriate.
If the website repository has tests or linting, run them after changes.
If the build can be run, verify it before committing.
GIT WORKFLOW
If new verified mentions are found:
Update only the necessary website files.
Run available checks.
Commit with a clear message such as:
docs: add new community mentions
or
site: update ios-agent-skill mentions
Do not create meaningless daily commits when there are no changes.
If no new mention exists:
Do not change files.
Do not create a commit.
Report:
“No new verified mentions today. Website unchanged.”
DAILY REPORT
At the end of every run report:
New mentions found:
[number]
Added to website:
[number]
Independent human/community mentions:
[number]
Directory/index mentions:
[number]
Duplicates ignored:
[number]
Questionable/unverified results ignored:
[number]
Website changed:
YES / NO
Build/check status:
PASS / FAIL / NOT REQUIRED
For each new mention show:
Platform:
URL:
Type:
Why it matters:
Added to website: YES / NO
Most important rule:
The goal is not to make the project appear more popular than it is.
The goal is to honestly document the growing public footprint of ios-agent-skill and thank the people and communities helping others discover it.
If a link is already present and correct, leave it alone.
If nothing new happened, do nothing.
---
# Community & Mentions
https://nagarjuna2997.github.io/ios-agent-skill/community.html
Public footprint
Community & Mentions
A record of verified directory listings, articles and developer discussions about ios-agent-skill. Thank you to everyone who has shared useful feedback or helped others find the project.
Verified links, clearly labelled. Directory listings are not endorsements; maintainer posts and republications are identified separately.
Developer ResourceKimiLists ios-agent-skill among open-source software skills and links to the repository.Visit Kimi (opens in a new tab)
Discussion · maintainer-startedReddit / r/XcodeReaders discuss Xcode overlap, rule sources and the need for measured evidence.Visit Reddit / r/Xcode (opens in a new tab)
Developer Resource · indexSkillWorksIndexes the repository’s CLAUDE.md among real-world instruction-file examples.Visit SkillWorks (opens in a new tab)
DirectorySkillsMPProvides a skill listing with the source repository and installation information.Visit SkillsMP (opens in a new tab)
DirectoryAwesome SkillsLists the skill with a direct source link and installation options.Visit Awesome Skills (opens in a new tab)
DirectoryAwesome MCP ServersLists the MCP server and reproduces project documentation.Visit Awesome MCP Servers (opens in a new tab)
DirectoryAgentmodsIndexes the project’s Gemini CLI extension with links to its source.Visit Agentmods (opens in a new tab)
Directoryskills.restProvides an indexed skill page linking to this repository.Visit skills.rest (opens in a new tab)
Article · maintainer-authoredDEV CommunityThe maintainer explains the Swift, Xcode and simulator toolbox and asks for feedback.Visit DEV Community (opens in a new tab)
Official project pageGitHubSource code, issues and releases maintained by the project.Visit GitHub (opens in a new tab)
Using ios-agent-skill in a real project? I’d love to hear about it.
View on GitHub
Share Feedback
---
# Documentation index
https://nagarjuna2997.github.io/ios-agent-skill/docs-index.html
Documentation indexBrowse the repository’s documentation indexes and their linked source guides on GitHub.AI and Apple IntelligenceProfessional Motion and AnimationApple Framework Indexdocs/frameworks/apple-intelligence.mddocs/frameworks/core-ai.mddocs/frameworks/ml/coreml.mddocs/frameworks/core-spotlight-rag.mddocs/testing/evaluations.mddocs/frameworks/foundation-models.mddocs/frameworks/extended-apple-frameworks.mddocs/frameworks/ml/natural-language.mddocs/frameworks/ml/sound-analysis.mddocs/frameworks/ml/speech.mddocs/frameworks/ml/translation.mddocs/frameworks/ml/vision.mddocs/frameworks/authentication-services.mddocs/frameworks/cryptokit.mddocs/frameworks/device-integrity.mddocs/frameworks/local-authentication.mddocs/frameworks/avfoundation.mddocs/frameworks/photosui.mddocs/frameworks/services/passkit.mddocs/frameworks/storekit.mddocs/frameworks/activitykit.mddocs/frameworks/app-clips.mddocs/frameworks/app-intents.mddocs/frameworks/swift-charts.mddocs/swiftui/views-and-controls.mddocs/frameworks/tipkit.mddocs/uikit/uikit-essentials.mddocs/frameworks/visionkit.mddocs/frameworks/widgetkit.mddocs/frameworks/cloudkit.mddocs/frameworks/core-data.mddocs/frameworks/data-concurrency.mddocs/frameworks/foundation.mddocs/frameworks/swiftdata.mddocs/design/stunning-ui-patterns.mddocs/design/liquid-glass-adoption.mddocs/web/native-vs-web-animation.mddocs/swiftui/animations.mddocs/tooling/device-hub.mddocs/tooling/fm-cli.mddocs/tooling/foundation-models-instruments.mddocs/tooling/ios-simulator-mcp.mddocs/tooling/xcode-27-agents.mddocs/frameworks/arkit.mddocs/frameworks/metal.mddocs/frameworks/realitykit.mddocs/frameworks/scenekit.mddocs/frameworks/hardware/core-motion.mddocs/frameworks/hardware/core-nfc.mddocs/frameworks/hardware/healthkit.mddocs/frameworks/hardware/core-bluetooth.mddocs/frameworks/network-framework.mddocs/frameworks/networking.mddocs/platforms/ios.mddocs/platforms/macos.mddocs/platforms/tvos.mddocs/platforms/visionos.mddocs/platforms/watchos.mddocs/frameworks/background-tasks.mddocs/frameworks/services/contacts.mddocs/frameworks/core-location.mddocs/frameworks/services/eventkit.mddocs/frameworks/hardware/homekit.mddocs/frameworks/mapkit.mddocs/frameworks/oslog.mddocs/frameworks/usernotifications.mddocs/frameworks/services/weatherkit.mdData and PersistenceProfessional UI/UX SystemGraphics, 3D, and Spatial DevelopmentNetworking and ConnectivityPerformanceSecurity, Authentication, and PrivacyWebKit and JavaScript Interoperability
---
# App Store Submission Checklist
https://nagarjuna2997.github.io/ios-agent-skill/guides/checklists-app-store-submission.html
← Back to all frameworks & guides
All articles →Authentication, Security, and Privacy · Reference guideApp Store Submission ChecklistRepository guidance for Privacy Manifest. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
App Metadata
Screenshots and Previews
App Review Guidelines Compliance
1.0 Safety
2.0 Performance
3.0 Business
4.0 Design
5.0 Legal
Privacy
App Transport Security
Required Device Capabilities
Launch Screen and App Icons
Entitlements and Capabilities
Build Configuration
TestFlight Beta Testing
Common Rejection Reasons and Fixes
Xcode Archive and Upload
Post-Submission
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Inventory app capabilities
↓2
Check privacy and signing
↓3
Exercise release build
↓4
Submit reviewed archive
02 / ArchitectureResponsibility boundariesBoundary 1
Release artifactBoundary 2
Privacy declarationsBoundary 3
Review evidenceConnected responsibilities, not a required class hierarchy or an execution trace.
A comprehensive checklist for submitting an iOS app. Work through each section before uploading your archive to App Store Connect.
App Metadata
[ ] App name finalized (30 character limit, no keyword stuffing)
[ ] Subtitle written (30 character limit, descriptive and compelling)
[ ] App description written (up to 4000 characters, most important info first)
[ ] Promotional text set (170 characters, can be updated without a new build)
[ ] Keywords optimized (100 character budget, comma-separated, no spaces after commas)
[ ] Primary and secondary categories selected
[ ] Support URL provided (must be a working webpage)
[ ] Marketing URL provided (optional but recommended)
[ ] Copyright field filled (e.g., "2026 Your Company Name")
[ ] Version number follows semantic versioning (e.g., 1.0.0)
Screenshots and Previews
[ ] Screenshots provided for 6.7" display (iPhone 15 Pro Max / 16 Pro Max -- 1290 x 2796)
[ ] Screenshots provided for 6.5" display (iPhone 11 Pro Max -- 1242 x 2688) if targeting older devices
[ ] Screenshots provided for 5.5" display (iPhone 8 Plus -- 1242 x 2208) if supporting iPhone SE
[ ] iPad Pro 12.9" screenshots (2048 x 2732) if universal app
[ ] iPad Pro 13" (M4) screenshots (2064 x 2752) if targeting latest iPads
[ ] Minimum 3 screenshots per device size (maximum 10)
[ ] Screenshots show actual app UI (not misleading)
[ ] App preview videos uploaded (optional, 15-30 seconds, no watermarks)
[ ] All screenshots and previews localized for each supported language
App Review Guidelines Compliance
1.0 Safety
[ ] No objectionable content without proper age gating
[ ] User-generated content has reporting and blocking mechanisms
[ ] No realistic violence in icons or screenshots for apps aimed at children
2.0 Performance
[ ] App is complete and functional (no beta, demo, or trial labels)
[ ] No hidden or undocumented features
[ ] App does not download additional executable code after install
[ ] App works without requiring additional hardware to review
3.0 Business
[ ] In-app purchases use StoreKit (not third-party payment for digital goods)
[ ] Subscriptions include clear pricing and terms
[ ] Subscription offers restore previous purchases
[ ] Free trials clearly state what happens when trial ends
[ ] No bait-and-switch pricing
4.0 Design
[ ] App uses standard system UI or well-designed custom UI
[ ] App functions on all supported device sizes (no black bars)
[ ] No use of private APIs
[ ] App does not mimic native iOS system UI in misleading ways
[ ] Extensions (widgets, keyboards, etc.) include sufficient standalone functionality
5.0 Legal
[ ] App complies with all local laws in each territory
[ ] Developer Program License Agreement followed
[ ] No use of copyrighted material without permission
[ ] GDPR and CCPA compliance if targeting EU/California users
Privacy
[ ] Privacy policy URL provided (required for all apps)
[ ] Privacy policy is accessible and clearly written
[ ] App Privacy labels configured in App Store Connect (Data Types questionnaire)
[ ] Each data type categorized correctly (collected vs. tracked vs. linked)
[ ] App Tracking Transparency (ATT) prompt implemented if tracking users across apps
[ ] ATT prompt shown before any tracking begins
[ ]
NSUserTrackingUsageDescription
added to Info.plist if using ATT
[ ] Purpose strings (usage descriptions) provided for all permission requests:
[ ]
NSCameraUsageDescription
[ ]
NSPhotoLibraryUsageDescription
[ ]
NSLocationWhenInUseUsageDescription
[ ]
NSLocationAlwaysAndWhenInUseUsageDescription
(if applicable)
[ ]
NSMicrophoneUsageDescription
[ ]
NSContactsUsageDescription
[ ]
NSCalendarsUsageDescription
[ ]
NSBluetoothAlwaysUsageDescription
[ ]
NSFaceIDUsageDescription
[ ]
NSHealthShareUsageDescription
/
NSHealthUpdateUsageDescription
[ ] Each purpose string clearly explains why the permission is needed (in user-friendly language)
App Transport Security
[ ] ATS enabled (default in modern Xcode projects)
[ ] No blanket
NSAllowsArbitraryLoads = YES
in production
[ ] Any ATS exceptions are justified and documented
[ ] All API endpoints use HTTPS with TLS 1.2+
[ ] Third-party SDKs do not require ATS exceptions (or exceptions are scoped narrowly)
Required Device Capabilities
[ ]
UIRequiredDeviceCapabilities
in Info.plist lists only truly required capabilities
[ ] Do not require capabilities unnecessarily (this restricts compatible devices)
[ ] Common capabilities reviewed:
[ ]
armv7
/
arm64
-- processor architecture
[ ]
camera-flash
-- only if core feature needs it
[ ]
gps
-- only if precise location is essential
[ ]
nfc
-- only if NFC is core to the app
[ ]
arkit
-- only if AR is mandatory
Launch Screen and App Icons
[ ] Launch screen configured (storyboard or Info.plist configuration)
[ ] Launch screen matches initial app state (no logos per HIG unless branding)
[ ] App icon provided as a single 1024x1024 asset in the asset catalog
[ ] App icon does not contain alpha channel / transparency
[ ] App icon is not a photograph of an iPhone or iPad
[ ] App icon renders well at small sizes (no fine details lost)
[ ] Alternate app icons configured if supported (optional)
Entitlements and Capabilities
[ ] Only required entitlements are enabled in Signing & Capabilities
[ ] Push Notifications: APNs certificate or key configured, entitlement enabled
[ ] Sign In with Apple: entitlement enabled if using Apple ID sign-in
[ ] Associated Domains: configured for universal links / web credentials
[ ] App Groups: configured if sharing data between app and extensions
[ ] Background Modes: only required modes selected
[ ] HealthKit: entitlement and usage descriptions set
[ ] iCloud / CloudKit: container configured if using cloud sync
[ ] In-App Purchase: capability enabled, products configured in App Store Connect
[ ] Provisioning profile matches entitlements (no mismatch errors)
Build Configuration
[ ] Deployment target set appropriately (check analytics for user base)
[ ] Build number incremented from last upload
[ ] Release build configuration used (not Debug)
[ ] Bitcode setting matches project requirements (deprecated in Xcode 16+)
[ ] All architectures included (arm64 required)
[ ] dSYM files generated for crash reporting
[ ] No compiler warnings in Release build
[ ] No
#if DEBUG
code leaking into release paths
[ ] All test/staging API URLs replaced with production URLs
[ ] Logging level reduced for production (no verbose console output)
TestFlight Beta Testing
[ ] Internal testing group created (up to 100 testers)
[ ] External testing group created if needed (up to 10,000 testers)
[ ] Beta App Description written
[ ] Beta build uploaded and processed successfully
[ ] Compliance information answered (encryption export regulations)
[ ] If using non-exempt encryption, proper export compliance documentation filed
[ ] Test notes written for each build describing what to test
[ ] At least one full round of beta testing completed
[ ] Critical crash reports from TestFlight addressed
[ ] Beta feedback reviewed and acted upon
Common Rejection Reasons and Fixes
[ ]
Crashes/bugs
: Test every user flow, including edge cases and poor network
[ ]
Broken links
: Verify every URL in the app (support, privacy policy, terms)
[ ]
Placeholder content
: Remove all lorem ipsum, test data, TODO comments visible to users
[ ]
Incomplete information
: App description, screenshots, and metadata must be final
[ ]
Login required but no demo account
: Provide demo credentials in review notes
[ ]
Permissions without features
: Do not request permissions until the feature needs them
[ ]
Third-party sign-in without Sign In with Apple
: If you offer Google/Facebook sign-in, you must also offer Sign In with Apple
[ ]
Subscription issues
: Clearly disclose pricing before paywall; include restore purchases button
[ ]
Minimum functionality
: App must provide lasting value beyond a simple website wrapper
[ ]
Misleading metadata
: Keywords, description, and screenshots must accurately represent the app
Xcode Archive and Upload
[ ] Select "Any iOS Device (arm64)" as build destination
[ ] Product > Archive (builds the release archive)
[ ] Archive appears in Organizer window without errors
[ ] Validate the archive (Organizer > Validate App)
[ ] Resolve any validation warnings or errors
[ ] Distribute App > App Store Connect > Upload
[ ] Upload succeeds without errors
[ ] Build appears in App Store Connect under TestFlight within 15-30 minutes
[ ] Build processing completes (check for processing errors via email)
[ ] Select build in App Store Connect release
[ ] Submit for Review
Post-Submission
[ ] Monitor App Store Connect for review status changes
[ ] Respond to any App Review questions promptly (via Resolution Center)
[ ] Prepare release notes for the version
[ ] Decide release method: manual release, automatic after approval, or phased rollout
[ ] Phased release recommended for major updates (1% > 2% > 5% > 10% > 20% > 50% > 100% over 7 days)
[ ] Monitor crash reports after release via Xcode Organizer or third-party tool
[ ] Monitor App Store reviews and respond to user feedback
---
# Generate real asset catalogs
https://nagarjuna2997.github.io/ios-agent-skill/guides/design-asset-generation.html
← Back to all frameworks & guides
All articles →Design · Reference guideGenerate real asset catalogsRepository guidance for Generate real asset catalogs. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
Icons without paid tools
Optional Figma handoff
Evidence and limits
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Read design tokens
↓2
Generate named assets
↓3
Compile asset catalog
↓4
Inspect appearance variants
02 / ArchitectureResponsibility boundariesBoundary 1
Token documentBoundary 2
Asset generatorBoundary 3
Assets.xcassetsConnected responsibilities, not a required class hierarchy or an execution trace.
The CLI converts a strict JSON token file into a new
Assets.xcassets
. It runs
locally without Figma, a paid service, an AI model or an API key. This is an
implementation of the semantic color guidance in
design tokens
,
not a parser for arbitrary Markdown or Swift examples.
From a source checkout:
npm ci --prefix cli
npm run build --prefix cli
node cli/dist/index.js assets --tokens tokens.json --output App/Assets.xcassets
The output parent directory must exist. An existing catalog is never overwritten;
generate a sibling catalog, review its diff, then merge the intended changes.
The combined package includes this CLI starting with ios-agent-mcp 2.7.0.
Run
npx -y ios-agent-mcp@latest assets --tokens tokens.json --output App/Assets.xcassets
to use it without a source checkout.
{
"version": 1,
"colors": {
"AccentColor": {
"light": "#2457DB",
"dark": "#90B4FF",
"highContrastLight": "#12358F",
"highContrastDark": "#C7DAFF"
}
}
}
Names are ASCII identifiers, unique ignoring case;
AccentColor
is required.
All four appearances are required. Values are sRGB
#RRGGBB
or
#RRGGBBAA
.
There is no guessed dark-mode conversion. High contrast variants should be chosen
and contrast-tested against their actual background. The generator does not
certify contrast, layout accessibility or App Review acceptance.
New scaffolds include
App/design-tokens.json
, named
AccentColor
,
Background
and
TextPrimary
sets. XcodeGen starters set the global accent to
AccentColor
.
Consume named assets with
Color("Background")
and
Color("TextPrimary")
.
Icons without paid tools
new MyApp --xcodegen
keeps the three editable SVG layers and also renders their
ordered composition as an opaque RGB 1024×1024 PNG in
AppIcon.appiconset
. Xcode
uses its single-size iOS icon entry. These are placeholder shapes; customize them
before distributing an app. The generator does not add a person's name or branding.
After editing the SVG paths in any text editor or a free vector editor, regenerate:
node cli/dist/index.js assets --tokens App/design-tokens.json \
--output App/ReviewedAssets.xcassets \
--icon-layers App/MyApp/IconLayers --icon-background '#2457DB'
The existing layer manifest specifies back-to-front order. Inputs are bounded to
16 square SVGs, each at most 1 MiB. Use paths and shapes; text, linked images,
external references and entities are rejected. Convert text to paths in your
editor. The rasterizer is
resvg-js
; the PNG encoder
is
pngjs
. Their dependency licenses are retained.
No web upload or model call is involved.
A flattened PNG is not a native Liquid Glass icon. For that, import the separate
SVG layers into Apple's free Icon Composer, save the
.icon
document and validate
it in Xcode. This generator does
not
fabricate an undocumented
.icon
bundle.
See
Apple's Icon Composer workflow
.
Optional Figma handoff
Use Figma's own integration to read variables if you already use it. Map semantic
color names and light/dark/high-contrast modes to the JSON fields above. Resolve
aliases to explicit sRGB hex values first. The same JSON can be authored by hand;
Figma and its MCP are optional. There is no maintained Figma parser here.
Evidence and limits
The CLI tests verify schema rejection, appearance slots, alpha conversion,
non-overwrite behavior, ordered raster pixels, RGB output and 1024×1024 dimensions.
On macOS, generated colors and the iOS app-icon set were compiled using
xcrun
actool
against the installed simulator SDK. This does not verify a native
.icon
,
a full screenshot capture pipeline, symbol availability or visual accessibility.
Catalog format:
Apple named colors
and
appearance variants
.
---
# iOS Color System -- Complete Guide for Stunning SwiftUI UIs
https://nagarjuna2997.github.io/ios-agent-skill/guides/design-color-system.html
← Back to all frameworks & guides
All articles →Design · Reference guideiOS Color System -- Complete Guide for Stunning SwiftUI UIsRepository guidance for iOS Color System -- Complete Guide for Stunning SwiftUI UIs. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
Overview
1. Apple's Semantic Colors
System Grouped Colors
Flat Background Hierarchy
Fill Colors
2. Material Effects
Combining Materials with Borders
3. Dark Mode Support
Automatic Adaptation
Manual Color Scheme Override
4. Color Extension -- Hex Initializer
5. Five Stunning Pre-Built Color Palettes
Palette 1 -- Ocean Blue (Fintech / Productivity)
Palette 2 -- Sunset Warm (Social / Lifestyle)
Palette 3 -- Midnight Dark (Premium / Luxury)
Palette 4 -- Nature Green (Health / Wellness)
Palette 5 -- Violet Dream (Creative / Entertainment)
Using Palettes with Environment-Aware Adaptive Colors
6. Custom Colors via Asset Catalog
7. Gradient Recipes
Linear Gradient
Radial Gradient
Angular (Conic) Gradient
Mesh Gradient (iOS 18+)
8. Ten Stunning Gradient Combinations
9. Vibrancy and Blur Effects
10. Color Accessibility
Contrast Ratios
Color Blind Friendly Design Tips
Quick Reference
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Choose semantic roles
↓2
Assign appearance variants
↓3
Measure contrast
↓4
Inspect real screens
02 / ArchitectureResponsibility boundariesBoundary 1
Semantic rolesBoundary 2
Light and dark palettesBoundary 3
Accessible UIConnected responsibilities, not a required class hierarchy or an execution trace.
Overview
Color is the single most powerful tool for creating emotional impact in iOS applications. This guide covers every aspect of the SwiftUI color system, from Apple's semantic tokens to custom brand palettes, gradients, materials, and accessibility. Every code example compiles and produces production-quality results.
1. Apple's Semantic Colors
Semantic colors adapt automatically to light mode, dark mode, and increased contrast settings. Always prefer these over hardcoded values.
import SwiftUI
struct SemanticColorsShowcase: View {
var body: some View {
VStack(spacing: 16) {
// Label colors -- automatically adapt to appearance
Text("Primary Label")
.foregroundStyle(.primary)
Text("Secondary Label")
.foregroundStyle(.secondary)
Text("Tertiary Label")
.foregroundStyle(.tertiary)
Text("Quaternary Label")
.foregroundStyle(.quaternary)
Divider()
// Tint / accent color
Button("Accent Color Button") {}
.tint(.accentColor)
// Semantic intent colors
HStack(spacing: 12) {
Circle().fill(.red).frame(width: 32, height: 32) // Destructive
Circle().fill(.orange).frame(width: 32, height: 32) // Warning
Circle().fill(.green).frame(width: 32, height: 32) // Success
Circle().fill(.blue).frame(width: 32, height: 32) // Informational
Circle().fill(.yellow).frame(width: 32, height: 32) // Caution
}
}
.padding(24)
}
}
System Grouped Colors
These create the layered card-on-background look native to iOS Settings and many Apple apps.
struct SystemBackgroundsDemo: View {
var body: some View {
ZStack {
Color(.systemGroupedBackground)
.ignoresSafeArea()
VStack(spacing: 20) {
// Primary surface card
RoundedRectangle(cornerRadius: 16)
.fill(Color(.secondarySystemGroupedBackground))
.frame(height: 120)
.overlay(
Text("Secondary Grouped Background")
.foregroundStyle(.primary)
)
// Nested surface
RoundedRectangle(cornerRadius: 16)
.fill(Color(.tertiarySystemGroupedBackground))
.frame(height: 120)
.overlay(
Text("Tertiary Grouped Background")
.foregroundStyle(.secondary)
)
}
.padding(20)
}
}
}
Flat Background Hierarchy
For non-grouped layouts (full-bleed content rather than inset cards).
struct FlatBackgroundsDemo: View {
var body: some View {
ZStack {
Color(.systemBackground)
.ignoresSafeArea()
VStack(spacing: 0) {
Rectangle()
.fill(Color(.secondarySystemBackground))
.frame(height: 80)
.overlay(Text("Secondary").foregroundStyle(.primary))
Rectangle()
.fill(Color(.tertiarySystemBackground))
.frame(height: 80)
.overlay(Text("Tertiary").foregroundStyle(.primary))
}
}
}
}
Fill Colors
Use fills for shapes and backgrounds within cells.
struct FillColorsDemo: View {
var body: some View {
VStack(spacing: 12) {
RoundedRectangle(cornerRadius: 10)
.fill(Color(.systemFill))
.frame(height: 50)
.overlay(Text("System Fill"))
RoundedRectangle(cornerRadius: 10)
.fill(Color(.secondarySystemFill))
.frame(height: 50)
.overlay(Text("Secondary Fill"))
RoundedRectangle(cornerRadius: 10)
.fill(Color(.tertiarySystemFill))
.frame(height: 50)
.overlay(Text("Tertiary Fill"))
RoundedRectangle(cornerRadius: 10)
.fill(Color(.quaternarySystemFill))
.frame(height: 50)
.overlay(Text("Quaternary Fill"))
}
.padding()
}
}
2. Material Effects
Materials create frosted-glass blur over underlying content. They are essential for modern iOS design.
struct MaterialShowcase: View {
var body: some View {
ZStack {
// Rich background to show blur effect
LinearGradient(
colors: [.purple, .blue, .cyan, .mint],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.ignoresSafeArea()
ScrollView {
VStack(spacing: 16) {
materialCard("Ultra Thin Material", material: .ultraThinMaterial)
materialCard("Thin Material", material: .thinMaterial)
materialCard("Regular Material", material: .regularMaterial)
materialCard("Thick Material", material: .thickMaterial)
materialCard("Ultra Thick Material", material: .ultraThickMaterial)
}
.padding(20)
}
}
}
func materialCard(_ title: String, material: Material) -> some View {
RoundedRectangle(cornerRadius: 20)
.fill(material)
.frame(height: 100)
.overlay(
Text(title)
.font(.headline)
.foregroundStyle(.primary)
)
.shadow(color: .black.opacity(0.1), radius: 10, y: 5)
}
}
Combining Materials with Borders
struct GlassCard: View {
var body: some View {
ZStack {
Image(systemName: "photo.artframe")
.resizable()
.scaledToFill()
.frame(width: 400, height: 600)
.clipped()
VStack(alignment: .leading, spacing: 8) {
Text("Glass Card")
.font(.title2.weight(.bold))
Text("Beautiful frosted glass with a subtle border that catches light.")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.padding(24)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 24))
.overlay(
RoundedRectangle(cornerRadius: 24)
.stroke(
LinearGradient(
colors: [.white.opacity(0.5), .white.opacity(0.1)],
startPoint: .topLeading,
endPoint: .bottomTrailing
),
lineWidth: 1
)
)
.padding(20)
}
}
}
3. Dark Mode Support
Automatic Adaptation
SwiftUI semantic colors adapt automatically. For custom colors, use Asset Catalog entries with "Any" and "Dark" appearances.
Manual Color Scheme Override
struct DarkModeDemo: View {
@Environment(\.colorScheme) var colorScheme
var body: some View {
VStack(spacing: 20) {
Text("Current: \(colorScheme == .dark ? "Dark" : "Light")")
.font(.headline)
// Adaptive custom color
RoundedRectangle(cornerRadius: 16)
.fill(colorScheme == .dark
? Color(hex: "1A1A2E")
: Color(hex: "F8F9FA"))
.frame(height: 100)
.overlay(
Text("Adaptive Card")
.foregroundStyle(colorScheme == .dark ? .white : .black)
)
}
.padding()
}
}
// Force a specific color scheme on any view subtree
struct ForcedSchemeExample: View {
var body: some View {
HStack(spacing: 20) {
CardView(label: "Always Light")
.environment(\.colorScheme, .light)
CardView(label: "Always Dark")
.environment(\.colorScheme, .dark)
}
.padding()
}
}
struct CardView: View {
let label: String
var body: some View {
Text(label)
.padding()
.background(Color(.secondarySystemBackground))
.cornerRadius(12)
}
}
4. Color Extension -- Hex Initializer
This extension is used throughout this guide and is essential for any custom palette work.
import SwiftUI
// The hex initialiser is NOT redefined here.
//
// It lives in `docs/design/design-tokens.md`, and this file used to declare a
// second `init(hex: String)` with a different body. Copy both into one target
// and the compiler stops you: `invalid redeclaration of 'init(hex:)'`. Worse,
// the old body fell back to **black** on a malformed string, which looks like
// a deliberate colour and ships unnoticed.
//
// Copy the canonical one from design-tokens.md. It accepts both
// `Color(hex: 0x6C63FF)` and `Color(hex: "6C63FF")`, and a malformed literal
// traps in DEBUG and renders magenta in release.
5. Five Stunning Pre-Built Color Palettes
Palette 1 -- Ocean Blue (Fintech / Productivity)
Role
Light Hex
Dark Hex
Text on it (light / dark)
Description
Primary
#0A6EBD
#3DA5F4
white 5.28:1 / black 7.89:1
Trust-inspiring blue
Secondary
#1E88E5
#64B5F6
black 5.71:1 / black 9.48:1
Lighter accent blue
Accent
#00BCD4
#4DD0E1
black 9.14:1 / black 11.43:1
Teal action highlight
Background
#F4F7FA
#0D1117
—
Clean paper / deep night
Surface
#FFFFFF
#161B22
—
Card surface
Text
#1A2233
#E6EDF3
—
High-contrast readable text
Error
#D32F2F
#EF5350
white 4.98:1 / black 6.02:1
Clear danger signal
struct OceanBluePalette {
static let primary = Color(hex: "0A6EBD")
static let secondary = Color(hex: "1E88E5")
static let accent = Color(hex: "00BCD4")
static let background = Color(hex: "F4F7FA")
static let surface = Color(hex: "FFFFFF")
static let text = Color(hex: "1A2233")
static let error = Color(hex: "D32F2F")
static let primaryDark = Color(hex: "3DA5F4")
static let secondaryDark = Color(hex: "64B5F6")
static let accentDark = Color(hex: "4DD0E1")
static let backgroundDark = Color(hex: "0D1117")
static let surfaceDark = Color(hex: "161B22")
static let textDark = Color(hex: "E6EDF3")
static let errorDark = Color(hex: "EF5350")
}
Body-text pairs
— Text on Background
14.79:1
light,
16.02:1
dark · Text on Surface
15.90:1
light,
14.64:1
dark. All four clear the 4.5:1 body-text bar.
Palette 2 -- Sunset Warm (Social / Lifestyle)
Role
Light Hex
Dark Hex
Text on it (light / dark)
Description
Primary
#FF6B35
#FF8A5C
black 7.41:1 / black 9.04:1
Warm energetic orange
Secondary
#F7C948
#FFD966
black 13.4:1 / black 15.37:1
Sunny golden yellow
Accent
#E84393
#FD79A8
black 5.66:1 / black 8.48:1
Playful magenta
Background
#FFF8F0
#1A1215
—
Warm cream / warm dark
Surface
#FFFFFF
#2D1F23
—
Card surface
Text
#2D1810
#F5E6D8
—
Dark warm brown / cream
Error
#C0392B
#E74C3C
white 5.44:1 / black 5.5:1
Red alert
struct SunsetWarmPalette {
static let primary = Color(hex: "FF6B35")
static let secondary = Color(hex: "F7C948")
static let accent = Color(hex: "E84393")
static let background = Color(hex: "FFF8F0")
static let surface = Color(hex: "FFFFFF")
static let text = Color(hex: "2D1810")
static let error = Color(hex: "C0392B")
static let primaryDark = Color(hex: "FF8A5C")
static let secondaryDark = Color(hex: "FFD966")
static let accentDark = Color(hex: "FD79A8")
static let backgroundDark = Color(hex: "1A1215")
static let surfaceDark = Color(hex: "2D1F23")
static let textDark = Color(hex: "F5E6D8")
static let errorDark = Color(hex: "E74C3C")
}
Body-text pairs
— Text on Background
15.95:1
light,
15.06:1
dark · Text on Surface
16.80:1
light,
12.91:1
dark. All four clear the 4.5:1 body-text bar.
Palette 3 -- Midnight Dark (Premium / Luxury)
Role
Light Hex
Dark Hex
Text on it (light / dark)
Description
Primary
#6C63FF
#8B83FF
black 4.87:1 / black 6.79:1
Electric indigo
Secondary
#A78BFA
#C4B5FD
black 7.72:1 / black 11.38:1
Soft lavender
Accent
#F472B6
#F9A8D4
black 7.93:1 / black 11.58:1
Rose gold accent
Background
#F5F3FF
#0B0B1A
—
Faint violet / pure dark
Surface
#FFFFFF
#13132B
—
Card surface
Text
#1E1B4B
#E2E0F0
—
Deep indigo / soft light
Error
#DC2626
#F87171
white 4.83:1 / black 7.59:1
Bright red
struct MidnightDarkPalette {
static let primary = Color(hex: "6C63FF")
static let secondary = Color(hex: "A78BFA")
static let accent = Color(hex: "F472B6")
static let background = Color(hex: "F5F3FF")
static let surface = Color(hex: "FFFFFF")
static let text = Color(hex: "1E1B4B")
static let error = Color(hex: "DC2626")
static let primaryDark = Color(hex: "8B83FF")
static let secondaryDark = Color(hex: "C4B5FD")
static let accentDark = Color(hex: "F9A8D4")
static let backgroundDark = Color(hex: "0B0B1A")
static let surfaceDark = Color(hex: "13132B")
static let textDark = Color(hex: "E2E0F0")
static let errorDark = Color(hex: "F87171")
}
Body-text pairs
— Text on Background
14.58:1
light,
15.00:1
dark · Text on Surface
15.99:1
light,
13.98:1
dark. All four clear the 4.5:1 body-text bar.
Palette 4 -- Nature Green (Health / Wellness)
Role
Light Hex
Dark Hex
Text on it (light / dark)
Description
Primary
#2D9F6F
#4ADE80
black 6.3:1 / black 12.05:1
Fresh healing green
Secondary
#22D3EE
#67E8F9
black 11.62:1 / black 14.49:1
Cool sky cyan
Accent
#F59E0B
#FBBF24
black 9.78:1 / black 12.58:1
Warm honey gold
Background
#F0FDF4
#0A1A12
—
Faint mint / forest dark
Surface
#FFFFFF
#112118
—
Card surface
Text
#14352A
#D1FAE5
—
Deep forest / soft mint
Error
#DC2626
#FB7185
white 4.83:1 / black 7.8:1
Alert red
struct NatureGreenPalette {
static let primary = Color(hex: "2D9F6F")
static let secondary = Color(hex: "22D3EE")
static let accent = Color(hex: "F59E0B")
static let background = Color(hex: "F0FDF4")
static let surface = Color(hex: "FFFFFF")
static let text = Color(hex: "14352A")
static let error = Color(hex: "DC2626")
static let primaryDark = Color(hex: "4ADE80")
static let secondaryDark = Color(hex: "67E8F9")
static let accentDark = Color(hex: "FBBF24")
static let backgroundDark = Color(hex: "0A1A12")
static let surfaceDark = Color(hex: "112118")
static let textDark = Color(hex: "D1FAE5")
static let errorDark = Color(hex: "FB7185")
}
Body-text pairs
— Text on Background
12.76:1
light,
15.83:1
dark · Text on Surface
13.36:1
light,
14.76:1
dark. All four clear the 4.5:1 body-text bar.
Palette 5 -- Violet Dream (Creative / Entertainment)
Role
Light Hex
Dark Hex
Text on it (light / dark)
Description
Primary
#8B5CF6
#A78BFA
black 4.96:1 / black 7.72:1
Vibrant violet
Secondary
#EC4899
#F472B6
black 5.95:1 / black 7.93:1
Hot pink
Accent
#06B6D4
#22D3EE
black 8.65:1 / black 11.62:1
Electric cyan
Background
#FAF5FF
#0F0720
—
Lavender mist / deep purple
Surface
#FFFFFF
#1A0F2E
—
Card surface
Text
#2E1065
#EDE9FE
—
Deep purple / pale lavender
Error
#E11D48
#FB7185
white 4.7:1 / black 7.8:1
Rose red
struct VioletDreamPalette {
static let primary = Color(hex: "8B5CF6")
static let secondary = Color(hex: "EC4899")
static let accent = Color(hex: "06B6D4")
static let background = Color(hex: "FAF5FF")
static let surface = Color(hex: "FFFFFF")
static let text = Color(hex: "2E1065")
static let error = Color(hex: "E11D48")
static let primaryDark = Color(hex: "A78BFA")
static let secondaryDark = Color(hex: "F472B6")
static let accentDark = Color(hex: "22D3EE")
static let backgroundDark = Color(hex: "0F0720")
static let surfaceDark = Color(hex: "1A0F2E")
static let textDark = Color(hex: "EDE9FE")
static let errorDark = Color(hex: "FB7185")
}
Using Palettes with Environment-Aware Adaptive Colors
struct AdaptiveColor {
let light: Color
let dark: Color
func resolve(for scheme: ColorScheme) -> Color {
scheme == .dark ? dark : light
}
}
struct AdaptiveCardExample: View {
@Environment(\.colorScheme) var scheme
var primary: Color {
AdaptiveColor(
light: OceanBluePalette.primary,
dark: OceanBluePalette.primaryDark
).resolve(for: scheme)
}
var body: some View {
Text("Adaptive Palette Card")
.font(.headline)
.foregroundStyle(.white)
.padding(24)
.background(primary, in: RoundedRectangle(cornerRadius: 16))
}
}
6. Custom Colors via Asset Catalog
Step-by-step for Xcode:
Open
Assets.xcassets
.
Click the
+
button, choose "Color Set".
Name it (e.g.,
BrandPrimary
).
In the Attributes Inspector, set "Appearances" to "Any, Dark".
Set hex values for each appearance.
Use in SwiftUI:
// After defining "BrandPrimary" in the Asset Catalog:
Text("Brand Styled")
.foregroundStyle(Color("BrandPrimary"))
// Type-safe alternative using an extension:
extension Color {
static let brandPrimary = Color("BrandPrimary")
static let brandSecondary = Color("BrandSecondary")
static let brandAccent = Color("BrandAccent")
}
Text("Type Safe")
.foregroundStyle(.brandPrimary)
7. Gradient Recipes
Linear Gradient
struct LinearGradientExamples: View {
var body: some View {
VStack(spacing: 16) {
// Horizontal gradient
RoundedRectangle(cornerRadius: 20)
.fill(
LinearGradient(
colors: [Color(hex: "6C63FF"), Color(hex: "E84393")],
startPoint: .leading,
endPoint: .trailing
)
)
.frame(height: 100)
// Diagonal gradient with multiple stops
RoundedRectangle(cornerRadius: 20)
.fill(
LinearGradient(
stops: [
.init(color: Color(hex: "667EEA"), location: 0),
.init(color: Color(hex: "764BA2"), location: 0.5),
.init(color: Color(hex: "F093FB"), location: 1),
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(height: 100)
}
.padding()
}
}
Radial Gradient
struct RadialGradientExample: View {
var body: some View {
Circle()
.fill(
RadialGradient(
colors: [
Color(hex: "FF6B35"),
Color(hex: "F7C948"),
Color(hex: "FF6B35").opacity(0.3),
],
center: .center,
startRadius: 20,
endRadius: 150
)
)
.frame(width: 300, height: 300)
.shadow(color: Color(hex: "FF6B35").opacity(0.4), radius: 30, y: 10)
}
}
Angular (Conic) Gradient
struct AngularGradientExample: View {
var body: some View {
Circle()
.fill(
AngularGradient(
colors: [
Color(hex: "8B5CF6"),
Color(hex: "EC4899"),
Color(hex: "06B6D4"),
Color(hex: "8B5CF6"),
],
center: .center
)
)
.frame(width: 200, height: 200)
}
}
Mesh Gradient (iOS 18+)
@available(iOS 18.0, *)
struct MeshGradientExample: View {
var body: some View {
MeshGradient(
width: 3,
height: 3,
points: [
[0.0, 0.0], [0.5, 0.0], [1.0, 0.0],
[0.0, 0.5], [0.5, 0.5], [1.0, 0.5],
[0.0, 1.0], [0.5, 1.0], [1.0, 1.0],
],
colors: [
Color(hex: "6C63FF"), Color(hex: "8B5CF6"), Color(hex: "EC4899"),
Color(hex: "3DA5F4"), Color(hex: "A78BFA"), Color(hex: "F472B6"),
Color(hex: "06B6D4"), Color(hex: "22D3EE"), Color(hex: "F9A8D4"),
]
)
.frame(height: 400)
.clipShape(RoundedRectangle(cornerRadius: 24))
.ignoresSafeArea()
}
}
8. Ten Stunning Gradient Combinations
Each gradient is named and ready to drop into any project.
enum StunningGradients {
/// 1. Oceanic Depths -- deep sea to sky
static let oceanicDepths = LinearGradient(
colors: [Color(hex: "0A2463"), Color(hex: "1E88E5"), Color(hex: "00BCD4")],
startPoint: .topLeading, endPoint: .bottomTrailing
)
/// 2. Sunset Boulevard -- golden hour warmth
static let sunsetBoulevard = LinearGradient(
colors: [Color(hex: "FF6B35"), Color(hex: "F7C948"), Color(hex: "FF8A5C")],
startPoint: .leading, endPoint: .trailing
)
/// 3. Northern Lights -- aurora borealis
static let northernLights = LinearGradient(
colors: [Color(hex: "0F2027"), Color(hex: "203A43"), Color(hex: "2C5364"), Color(hex: "4ADE80")],
startPoint: .top, endPoint: .bottom
)
/// 4. Rose Gold -- luxury feminine
static let roseGold = LinearGradient(
colors: [Color(hex: "F472B6"), Color(hex: "FBBF24"), Color(hex: "F9A8D4")],
startPoint: .topLeading, endPoint: .bottomTrailing
)
/// 5. Electric Violet -- creative energy
static let electricViolet = LinearGradient(
colors: [Color(hex: "8B5CF6"), Color(hex: "6C63FF"), Color(hex: "EC4899")],
startPoint: .topLeading, endPoint: .bottomTrailing
)
/// 6. Midnight City -- dark premium
static let midnightCity = LinearGradient(
colors: [Color(hex: "0B0B1A"), Color(hex: "1A1A2E"), Color(hex: "16213E")],
startPoint: .top, endPoint: .bottom
)
/// 7. Fresh Mint -- health and clarity
static let freshMint = LinearGradient(
colors: [Color(hex: "2D9F6F"), Color(hex: "22D3EE"), Color(hex: "67E8F9")],
startPoint: .leading, endPoint: .trailing
)
/// 8. Cyber Punk -- bold neon
static let cyberPunk = LinearGradient(
colors: [Color(hex: "F72585"), Color(hex: "7209B7"), Color(hex: "3A0CA3"), Color(hex: "4CC9F0")],
startPoint: .topLeading, endPoint: .bottomTrailing
)
/// 9. Warm Ember -- cozy and inviting
static let warmEmber = LinearGradient(
colors: [Color(hex: "D32F2F"), Color(hex: "FF6B35"), Color(hex: "F7C948")],
startPoint: .bottomLeading, endPoint: .topTrailing
)
/// 10. Iridescent Pearl -- subtle luxury shimmer
static let iridescentPearl = LinearGradient(
colors: [
Color(hex: "E8D5F5"), Color(hex: "C4E0F9"),
Color(hex: "D1FAE5"), Color(hex: "FEF3C7"), Color(hex: "E8D5F5"),
],
startPoint: .topLeading, endPoint: .bottomTrailing
)
}
// Usage in a view
struct GradientShowcase: View {
var body: some View {
ScrollView {
VStack(spacing: 16) {
gradientCard("Oceanic Depths", gradient: StunningGradients.oceanicDepths)
gradientCard("Sunset Boulevard", gradient: StunningGradients.sunsetBoulevard)
gradientCard("Northern Lights", gradient: StunningGradients.northernLights)
gradientCard("Rose Gold", gradient: StunningGradients.roseGold)
gradientCard("Electric Violet", gradient: StunningGradients.electricViolet)
gradientCard("Midnight City", gradient: StunningGradients.midnightCity)
gradientCard("Fresh Mint", gradient: StunningGradients.freshMint)
gradientCard("Cyber Punk", gradient: StunningGradients.cyberPunk)
gradientCard("Warm Ember", gradient: StunningGradients.warmEmber)
gradientCard("Iridescent Pearl", gradient: StunningGradients.iridescentPearl)
}
.padding()
}
}
func gradientCard(_ name: String, gradient: LinearGradient) -> some View {
RoundedRectangle(cornerRadius: 20)
.fill(gradient)
.frame(height: 100)
.overlay(
Text(name)
.font(.title3.weight(.bold))
.foregroundStyle(.white)
.shadow(color: .black.opacity(0.3), radius: 4, y: 2)
)
}
}
9. Vibrancy and Blur Effects
struct VibrancyEffectDemo: View {
var body: some View {
ZStack {
// Background image or gradient
LinearGradient(
colors: [Color(hex: "6C63FF"), Color(hex: "EC4899")],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.ignoresSafeArea()
VStack(spacing: 20) {
// Vibrant label on material
Text("Vibrant Title")
.font(.largeTitle.weight(.bold))
.foregroundStyle(.primary)
.padding(20)
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16))
// Hierarchical vibrancy
VStack(alignment: .leading, spacing: 8) {
Label("Primary", systemImage: "star.fill")
.foregroundStyle(.primary)
Label("Secondary", systemImage: "star.leadinghalf.filled")
.foregroundStyle(.secondary)
Label("Tertiary", systemImage: "star")
.foregroundStyle(.tertiary)
}
.font(.headline)
.padding(20)
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 16))
}
}
}
}
10. Color Accessibility
Contrast Ratios
WCAG 2.1 requires at least 4.5:1 contrast for normal text and 3:1 for large text. Use these helper utilities.
extension Color {
/// Calculate relative luminance of a color.
/// Returns a value between 0 (black) and 1 (white).
func relativeLuminance() -> Double {
// Approximate; for exact values resolve the UIColor components.
// This is a conceptual guide -- use UIColor for runtime calculation:
var r: CGFloat = 0; var g: CGFloat = 0; var b: CGFloat = 0; var a: CGFloat = 0
UIColor(self).getRed(&r, green: &g, blue: &b, alpha: &a)
func linearize(_ c: CGFloat) -> Double {
let v = Double(c)
return v <= 0.03928 ? v / 12.92 : pow((v + 0.055) / 1.055, 2.4)
}
return 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b)
}
/// Calculate WCAG contrast ratio between two colors.
func contrastRatio(with other: Color) -> Double {
let l1 = self.relativeLuminance()
let l2 = other.relativeLuminance()
let lighter = max(l1, l2)
let darker = min(l1, l2)
return (lighter + 0.05) / (darker + 0.05)
}
}
struct ContrastChecker: View {
let foreground = Color(hex: "1A2233")
let background = Color(hex: "F4F7FA")
var body: some View {
let ratio = foreground.contrastRatio(with: background)
VStack(spacing: 12) {
Text("Contrast Ratio: \(ratio, specifier: "%.1f"):1")
.font(.headline)
Text(ratio >= 4.5 ? "WCAG AA Pass" : "WCAG AA Fail")
.font(.subheadline)
.foregroundStyle(ratio >= 4.5 ? .green : .red)
Text("Sample Text on Background")
.foregroundStyle(foreground)
.padding()
.background(background, in: RoundedRectangle(cornerRadius: 12))
}
.padding()
}
}
Color Blind Friendly Design Tips
Never rely on color alone to convey meaning -- combine with icons, labels, or patterns.
Use high-contrast pairings that remain distinguishable under protanopia, deuteranopia, and tritanopia.
Test with Xcode Accessibility Inspector or Simulator color filters.
Prefer blue/orange pairings (distinguishable under all common types) over red/green.
struct ColorBlindFriendlyStatus: View {
var body: some View {
HStack(spacing: 16) {
Label("Success", systemImage: "checkmark.circle.fill")
.foregroundStyle(Color(hex: "2D9F6F"))
Label("Warning", systemImage: "exclamationmark.triangle.fill")
.foregroundStyle(Color(hex: "F59E0B"))
Label("Error", systemImage: "xmark.circle.fill")
.foregroundStyle(Color(hex: "DC2626"))
}
.font(.headline)
}
}
Quick Reference
Category
Key Types
Semantic Labels
.primary, .secondary, .tertiary, .quaternary
System Backgrounds
Color(.systemBackground), .secondarySystemBackground, .tertiarySystemBackground
Grouped Backgrounds
Color(.systemGroupedBackground), .secondarySystemGroupedBackground, .tertiarySystemGroupedBackground
Fills
Color(.systemFill) through Color(.quaternarySystemFill)
Materials
.ultraThinMaterial through .ultraThickMaterial
Gradients
LinearGradient, RadialGradient, AngularGradient, MeshGradient
Scheme Override
.environment(\.colorScheme, .dark)
Asset Catalog
Color("AssetName")
Hex Init
Color(hex: "FF5733")
Body-text pairs
— Text on Background
14.20:1
light,
16.51:1
dark · Text on Surface
15.24:1
light,
15.36:1
dark. All four clear the 4.5:1 body-text bar.
---
# Design Tokens, Adaptive Color, and Liquid Glass
https://nagarjuna2997.github.io/ios-agent-skill/guides/design-design-tokens.html
← Back to all frameworks & guides
All articles →Design · Reference guideDesign Tokens, Adaptive Color, and Liquid GlassRepository guidance for Design Tokens, Adaptive Color, and Liquid Glass. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
1. The Three-Tier Token Architecture
Implementation
Injecting the theme
Tier 3 — component tokens as ViewModifiers
Anti-patterns
2. Dark Mode Compliance
Prefer semantic system colors for surfaces and text
Custom brand colors need both variants
Elevation reads differently in each mode
Verify, don't assume
3. Dynamic Type Compliance
Never use a fixed point size
Layouts must reflow, not clip
Cap Dynamic Type only where it is genuinely unavoidable
Compliance checklist
Respect the other accessibility settings too
4. Materials and Liquid Glass
The one rule for any blur effect
Liquid Glass (iOS 26+, refined in iOS 27)
Availability fallback
5. Contrast Verification
Quick Reference
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Define token names
↓2
Map values to appearances
↓3
Generate shared resources
↓4
Reject literal styling
02 / ArchitectureResponsibility boundariesBoundary 1
Token sourceBoundary 2
Generated resourcesBoundary 3
View consumersConnected responsibilities, not a required class hierarchy or an execution trace.
For executable token JSON → asset catalogs and editable SVG → PNG icons, see
asset generation
.
Load this when:
building or reviewing an app's design system, adding a theme,
auditing dark-mode or Dynamic Type compliance, or applying glass/material
effects.
docs/design/color-system.md
gives you
palettes
(hex values, gradients).
This document gives you the
system
: how those values are structured so a single
change propagates everywhere, and how the result stays legible in dark mode, at
accessibility text sizes, and under increased-contrast settings.
1. The Three-Tier Token Architecture
Never let a raw color or number appear at a call site. Tokens flow in one
direction through three tiers:
Tier 1 — Primitive Tier 2 — Semantic Tier 3 — Component
(raw values) (intent) (usage)
blue500 #0A84FF → accent → Button.background
gray900 #1C1C1E → textPrimary → Card.titleColor
space4 16pt → spacing.contentInset → Card.padding
Rules
Views reference
Tier 3 or Tier 2 only
. A view that names
blue500
is a bug.
Tier 1 is
private
to the token module. It has no dark-mode variant — it is
literally just a number.
Tier 2 is where light/dark, high-contrast, and theme switching resolve.
Adding a theme means adding one Tier 2 implementation, not editing views.
Implementation
// DesignSystem/Tokens/Primitives.swift
// Tier 1 — raw values. Never referenced from a View.
enum Primitive {
static let blue500 = Color(hex: 0x0A84FF)
static let blue600 = Color(hex: 0x0060DF)
static let indigo500 = Color(hex: 0x5E5CE6)
static let red500 = Color(hex: 0xFF3B30)
static let green500 = Color(hex: 0x34C759)
static let amber500 = Color(hex: 0xFF9F0A)
// Spacing scale — a 4pt rhythm. Nothing else is permitted.
static let space1: CGFloat = 4
static let space2: CGFloat = 8
static let space3: CGFloat = 12
static let space4: CGFloat = 16
static let space5: CGFloat = 24
static let space6: CGFloat = 32
static let space7: CGFloat = 48
// Radii
static let radiusS: CGFloat = 8
static let radiusM: CGFloat = 16
static let radiusL: CGFloat = 24
}
// THE canonical hex initialiser for this skill. Defined here, in the token
// layer, and nowhere else — `color-system.md` and
// `templates/common-patterns/design-system.swift` reference this file rather
// than redeclaring it. Two files declaring `init(hex: String)` with different
// bodies is not a style disagreement: copy both into one target and the
// compiler rejects it with `invalid redeclaration of 'init(hex:)'`.
extension Color {
/// Hex as an integer literal: `Color(hex: 0x6C63FF)`.
///
/// Preferred over the string form because a typo is a compile error rather
/// than a runtime surprise — `0x6C63FZ` does not build, `"6C63FZ"` does.
init(hex: UInt32, opacity: Double = 1) {
self.init(
.sRGB,
red: Double((hex >> 16) & 0xFF) / 255,
green: Double((hex >> 8) & 0xFF) / 255,
blue: Double( hex & 0xFF) / 255,
opacity: opacity
)
}
/// Hex as a string: `Color(hex: "6C63FF")`, with or without `#`,
/// 6 digits (RGB) or 8 (RRGGBBAA).
///
/// Exists because designers hand over strings and remote themes arrive as
/// JSON. A malformed value traps in DEBUG and renders **magenta** in
/// release — never black. The earlier versions of this initialiser fell
/// back to black, which is indistinguishable from a deliberate colour and
/// so shipped unnoticed; magenta appears nowhere in any of these palettes
/// and is impossible to mistake for intent.
init(hex string: String, opacity: Double = 1) {
let cleaned = string
.trimmingCharacters(in: .whitespacesAndNewlines)
.replacingOccurrences(of: "#", with: "")
var value: UInt64 = 0
let scanned = Scanner(string: cleaned).scanHexInt64(&value)
switch (scanned, cleaned.count) {
case (true, 6):
self.init(hex: UInt32(truncatingIfNeeded: value), opacity: opacity)
case (true, 8):
let alpha = Double(value & 0xFF) / 255
self.init(hex: UInt32(truncatingIfNeeded: value >> 8), opacity: opacity * alpha)
default:
assertionFailure("Malformed hex colour literal: \(string)")
self.init(hex: 0xFF00FF, opacity: opacity)
}
}
}
// DesignSystem/Tokens/Theme.swift
// Tier 2 — semantic intent. This is the swappable layer.
protocol Theme: Sendable {
// Surfaces
var background: Color { get } // the page
var surface: Color { get } // cards, sheets
var surfaceElevated: Color { get } // popovers, menus
// Content — must meet contrast against the surface it sits on
var textPrimary: Color { get }
var textSecondary: Color { get }
var textOnAccent: Color { get }
// Intent
var accent: Color { get }
var accentPressed: Color { get }
var destructive: Color { get }
var success: Color { get }
var warning: Color { get }
// Separators and elevation
var separator: Color { get }
var shadow: Color { get }
}
struct OceanTheme: Theme {
// Apple's semantic colors already resolve light/dark AND increased contrast.
// Prefer them for surfaces and text; reserve custom hex for brand accents.
var background = Color(.systemBackground)
var surface = Color(.secondarySystemBackground)
var surfaceElevated = Color(.tertiarySystemBackground)
var textPrimary = Color(.label)
var textSecondary = Color(.secondaryLabel)
var textOnAccent = Color.white
var accent = Primitive.blue500
var accentPressed = Primitive.blue600
var destructive = Color(.systemRed)
var success = Color(.systemGreen)
var warning = Color(.systemOrange)
var separator = Color(.separator)
var shadow = Color.black.opacity(0.08)
}
// DesignSystem/Tokens/Spacing.swift — Tier 2 for layout
enum Space {
static let hairline = Primitive.space1 // icon-to-label
static let tight = Primitive.space2 // within a control
static let element = Primitive.space3 // between related elements
static let contentInset = Primitive.space4 // card padding, screen margins
static let section = Primitive.space5 // between sections
static let major = Primitive.space6 // above a page title
}
enum Radius {
static let control = Primitive.radiusS // buttons, chips
static let card = Primitive.radiusM // cards, tiles
static let sheet = Primitive.radiusL // modals
}
Injecting the theme
private struct ThemeKey: EnvironmentKey {
static let defaultValue: any Theme = OceanTheme()
}
extension EnvironmentValues {
var theme: any Theme {
get { self[ThemeKey.self] }
set { self[ThemeKey.self] = newValue }
}
}
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
RootView().environment(\.theme, OceanTheme())
}
}
}
Tier 3 — component tokens as ViewModifiers
struct CardStyle: ViewModifier {
@Environment(\.theme) private var theme
func body(content: Content) -> some View {
content
.padding(Space.contentInset)
.background(theme.surface, in: .rect(cornerRadius: Radius.card))
.shadow(color: theme.shadow, radius: 8, y: 4)
}
}
extension View {
func cardStyle() -> some View { modifier(CardStyle()) }
}
// Usage — no raw values anywhere.
VStack(alignment: .leading, spacing: Space.element) {
Text("Monthly total").font(.headline).foregroundStyle(theme.textPrimary)
Text("$1,240").font(.largeTitle.bold()).foregroundStyle(theme.accent)
}
.cardStyle()
Anti-patterns
// WRONG — raw values at the call site. Changing the brand means grepping.
.padding(16)
.background(Color(red: 0.04, green: 0.52, blue: 1.0))
.cornerRadius(16)
// WRONG — a Tier 1 primitive leaking into a view.
.foregroundStyle(Primitive.blue500)
// WRONG — a semantic name that describes appearance, not intent.
var lightGray: Color { … } // what happens in dark mode?
var textSecondary: Color { … } // correct
// RIGHT
.padding(Space.contentInset)
.background(theme.accent, in: .rect(cornerRadius: Radius.card))
2. Dark Mode Compliance
Prefer semantic system colors for surfaces and text
They adapt to light/dark
and
to Increase Contrast and Reduce Transparency —
three accessibility settings for the price of one.
Role
Token
Light
Dark
Page
Color(.systemBackground)
white
black
Card
Color(.secondarySystemBackground)
light gray
dark gray
Elevated
Color(.tertiarySystemBackground)
white
lighter gray
Primary text
Color(.label)
near-black
near-white
Secondary text
Color(.secondaryLabel)
60%
60%
Divider
Color(.separator)
thin gray
thin gray
Custom brand colors need both variants
A brand accent tuned for white backgrounds usually fails on black. Define both
and resolve at render time:
extension Color {
/// Resolves per trait collection — works in both modes without an asset catalog.
static func adaptive(light: Color, dark: Color) -> Color {
Color(UIColor { traits in
traits.userInterfaceStyle == .dark ? UIColor(dark) : UIColor(light)
})
}
}
struct MidnightTheme: Theme {
var accent = Color.adaptive(
light: Primitive.blue500, // vivid on white
dark: Color(hex: 0x64B5FF) // lightened so it stays visible on black
)
// …
}
The asset-catalog equivalent (
Color("Accent")
with Any/Dark appearances) is
preferable when designers own the values; the code form is preferable when the
theme is swappable at runtime.
Elevation reads differently in each mode
Light mode:
elevation = shadow. A card is white on light gray with
.shadow(color: theme.shadow, radius: 8, y: 4)
.
Dark mode:
shadows are nearly invisible on black. Elevation = a
lighter
surface.
secondarySystemBackground
already does this; do not add a heavier
shadow to compensate.
@Environment(\.colorScheme) private var scheme
.shadow(color: theme.shadow, radius: scheme == .dark ? 0 : 8, y: 4)
.overlay( // a hairline stroke reads better in dark
RoundedRectangle(cornerRadius: Radius.card)
.strokeBorder(theme.separator, lineWidth: scheme == .dark ? 1 : 0)
)
Verify, don't assume
#Preview("Light") { ContentView().preferredColorScheme(.light) }
#Preview("Dark") { ContentView().preferredColorScheme(.dark) }
#Preview("Increased Contrast") {
ContentView().environment(\.colorSchemeContrast, .increased)
}
Every screen ships with all three previews. A pairing that only exists in one
mode is not done.
3. Dynamic Type Compliance
Never use a fixed point size
// WRONG — ignores the user's text size entirely.
.font(.system(size: 17))
.frame(height: 44) // clips at accessibility sizes
// RIGHT — semantic styles scale automatically.
.font(.headline)
.frame(minHeight: 44) // a floor, not a ceiling
// RIGHT — a custom font that still scales.
.font(.custom("Inter-SemiBold", size: 17, relativeTo: .headline))
Layouts must reflow, not clip
The accessibility sizes (
.accessibility1
…
.accessibility5
) can triple text
height. Horizontal rows must become vertical stacks.
struct StatRow: View {
@Environment(\.dynamicTypeSize) private var typeSize
let label: String
let value: String
var body: some View {
// ViewThatFits picks the first layout that fits — no manual breakpoint.
ViewThatFits(in: .horizontal) {
HStack(spacing: Space.element) {
Text(label)
Spacer()
Text(value).fontWeight(.semibold)
}
VStack(alignment: .leading, spacing: Space.hairline) {
Text(label)
Text(value).fontWeight(.semibold)
}
}
}
}
// Or branch explicitly when the two layouts differ structurally.
if typeSize.isAccessibilitySize {
VStack(alignment: .leading) { icon; label }
} else {
HStack { icon; label }
}
Cap Dynamic Type only where it is genuinely unavoidable
// Acceptable: a fixed-height chart axis label or a tab bar item.
.dynamicTypeSize(...DynamicTypeSize.accessibility1)
// NOT acceptable: body copy, form fields, buttons, or list rows.
.dynamicTypeSize(.large) // hard-pins every user to one size — never do this
Compliance checklist
[ ] No
.font(.system(size:))
without
relativeTo:
.
[ ] No fixed
.frame(height:)
on a container holding text — use
minHeight
.
[ ] Every
HStack
of label+value has a vertical fallback (
ViewThatFits
or
an
isAccessibilitySize
branch).
[ ] Icons paired with text use
.imageScale(.medium)
or a scaled symbol so
they grow together.
[ ] Tap targets stay ≥ 44×44pt at every size.
[ ] Previewed at
.xSmall
and
.accessibility5
:
#Preview("XS") { ContentView().dynamicTypeSize(.xSmall) }
#Preview("A11y5") { ContentView().dynamicTypeSize(.accessibility5) }
Respect the other accessibility settings too
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@Environment(\.accessibilityReduceTransparency) private var reduceTransparency
.animation(reduceMotion ? nil : .spring(duration: 0.3), value: isExpanded)
.background(reduceTransparency ? AnyShapeStyle(theme.surface)
: AnyShapeStyle(.ultraThinMaterial))
4. Materials and Liquid Glass
The one rule for any blur effect
A material is only correct when there is content behind it.
Applied to a
solid background it renders as flat gray mud — the single most common way a
SwiftUI UI looks unfinished.
// WRONG — nothing behind it. This is just a muddy gray rectangle.
VStack { … }
.background(.ultraThinMaterial)
.background(theme.background)
// RIGHT — content scrolls beneath a floating bar.
ScrollView { content }
.safeAreaInset(edge: .bottom) {
HStack { … }
.padding(Space.contentInset)
.background(.ultraThinMaterial)
}
Material
Blur
Use for
.ultraThinMaterial
lightest
Floating toolbars over content
.thinMaterial
light
Sheet backgrounds, overlays
.regularMaterial
medium
Sidebars, popovers
.thickMaterial
heavy
Modal scrims
.bar
system
Custom nav/tab bars
Liquid Glass (iOS 26+, refined in iOS 27)
Adopting it in an existing app is a different job.
This section is about
applying the material to a view you own. For what an SDK rebuild changes on
its own — custom bar backgrounds that now fight the system, the scroll edge
effect, title-case section headers, layered app icons, and the
UIDesignRequiresCompatibility
escape hatch — see
docs/design/liquid-glass-adoption.md
.
Liquid Glass is a dynamic material that refracts and reflects what is behind it
and responds to motion. It supersedes hand-rolled "glassmorphism" (a blur plus a
white stroke plus a gradient), which you should stop writing.
Availability: guard on iOS 26, not iOS 27.
The Liquid Glass APIs
(
glassEffect
,
GlassEffectContainer
,
glassEffectID
,
.buttonStyle(.glass)
)
were introduced in
iOS 26
. iOS 27 continues and refines the design system,
but it did not reintroduce the API. Writing
if #available(iOS 27, *)
around
glassEffect
would drop every iOS 26 device to the fallback path for no reason
— a silent regression for a large installed base.
Guard on the version where
the symbol became available, never on the newest version you happen to be
building with.
That rule holds for every API, not just this one.
if #available(iOS 26.0, *) {
Text("Now Playing")
.padding(Space.contentInset)
.glassEffect() // .regular, in a Capsule
}
// Shape, tint, and interactivity
.glassEffect(
.regular
.tint(theme.accent.opacity(0.7))
.interactive(), // reacts to touch
in: .rect(cornerRadius: Radius.card)
)
// Buttons
Button("Play") { … }
.buttonStyle(.glass) // standard glass
Button("Subscribe") { … }
.buttonStyle(.glassProminent) // accent-filled glass
Grouping and morphing.
Sibling glass elements must live in a
GlassEffectContainer
so they blend and merge instead of stacking blurs — each
independent
.glassEffect()
is a separate expensive render pass.
@available(iOS 26.0, *)
struct PlayerControls: View {
@Namespace private var namespace
@State private var isExpanded = false
var body: some View {
GlassEffectContainer(spacing: Space.tight) {
HStack(spacing: Space.tight) {
Button { … } label: { Image(systemName: "backward.fill") }
.glassEffect()
.glassEffectID("back", in: namespace)
Button { isExpanded.toggle() } label: {
Image(systemName: isExpanded ? "pause.fill" : "play.fill")
}
.glassEffect()
.glassEffectID("play", in: namespace)
if isExpanded {
Button { … } label: { Image(systemName: "forward.fill") }
.glassEffect()
.glassEffectID("forward", in: namespace)
}
}
}
.animation(.spring(duration: 0.4), value: isExpanded)
}
}
Guidelines
Glass goes on the
navigation layer
— floating controls, toolbars, tab bars,
overlays. Not on content itself, and never on a whole scrolling list.
Never place glass on glass. One layer, over content.
Text on glass uses
Color(.label)
, never a reduced opacity. The material
already lowers effective contrast; do not lower it further.
Keep the count small. Every glass surface is a render pass; a grid of twenty
glass cards will drop frames on older devices.
Availability fallback
Ship one modifier, branch once:
extension View {
/// Liquid Glass on iOS 26+, an equivalent material treatment below it.
func adaptiveGlass(cornerRadius: CGFloat = Radius.card) -> some View {
modifier(AdaptiveGlass(cornerRadius: cornerRadius))
}
}
private struct AdaptiveGlass: ViewModifier {
@Environment(\.accessibilityReduceTransparency) private var reduceTransparency
@Environment(\.theme) private var theme
let cornerRadius: CGFloat
func body(content: Content) -> some View {
if reduceTransparency {
// Accessibility wins over aesthetics — opaque surface, no blur.
content.background(theme.surface, in: .rect(cornerRadius: cornerRadius))
} else if #available(iOS 26.0, *) {
content.glassEffect(.regular, in: .rect(cornerRadius: cornerRadius))
} else {
content
.background(.ultraThinMaterial, in: .rect(cornerRadius: cornerRadius))
.overlay(
RoundedRectangle(cornerRadius: cornerRadius)
.strokeBorder(.white.opacity(0.15), lineWidth: 1)
)
}
}
}
5. Contrast Verification
The readability rules in
SKILL.md
are testable. Compute the ratio rather than
eyeballing it:
extension Color {
/// WCAG relative luminance.
private var relativeLuminance: Double {
let components = UIColor(self).cgColor.components ?? [0, 0, 0]
func channel(_ value: CGFloat) -> Double {
let v = Double(value)
return v <= 0.03928 ? v / 12.92 : pow((v + 0.055) / 1.055, 2.4)
}
return 0.2126 * channel(components[0])
+ 0.7152 * channel(components[safe: 1] ?? components[0])
+ 0.0722 * channel(components[safe: 2] ?? components[0])
}
/// WCAG contrast ratio, 1.0 (identical) to 21.0 (black on white).
func contrastRatio(against other: Color) -> Double {
let a = relativeLuminance, b = other.relativeLuminance
let lighter = max(a, b), darker = min(a, b)
return (lighter + 0.05) / (darker + 0.05)
}
}
private extension Array {
subscript(safe index: Int) -> Element? {
indices.contains(index) ? self[index] : nil
}
}
Then assert it in the test target so a palette change cannot regress
accessibility:
@Test("theme meets WCAG AA in both modes")
func themeContrast() {
let theme = OceanTheme()
#expect(theme.textPrimary.contrastRatio(against: theme.surface) >= 4.5)
#expect(theme.textOnAccent.contrastRatio(against: theme.accent) >= 4.5)
#expect(theme.textSecondary.contrastRatio(against: theme.surface) >= 4.5)
}
Content
Minimum ratio
Body text
4.5:1
Large text (18pt+, or 14pt bold)
3:1
UI controls, icons, focus rings
3:1
Decorative, disabled
no requirement
Quick Reference
Need
Use
Page background
theme.background → Color(.systemBackground)
Card background
theme.surface → Color(.secondarySystemBackground)
Any spacing value
Space.* — never a literal
Any corner radius
Radius.* — never a literal
Brand accent
Color.adaptive(light:dark:) or an asset-catalog color
Floating bar over content
.ultraThinMaterial, or .glassEffect() on iOS 26+
Multiple glass elements
one GlassEffectContainer
Text size
semantic styles, or .custom(_:size:relativeTo:)
Row that must reflow
ViewThatFits or typeSize.isAccessibilitySize
Verifying a pairing
contrastRatio(against:) in a test
---
# iOS Font Catalog — Ultimate Reference
https://nagarjuna2997.github.io/ios-agent-skill/guides/design-fonts-catalog.html
← Back to all frameworks & guides
All articles →Design · Reference guideiOS Font Catalog — Ultimate ReferenceRepository guidance for iOS Font Catalog — Ultimate Reference. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
Table of Contents
1. Apple System Fonts
SF Pro — The Default System Font
SF Pro Display vs SF Pro Text
SF Pro Rounded
SF Mono — Monospaced
SF Compact
New York — Serif Font
Width Variants (iOS 16+)
Complete System Font Design Matrix
All SwiftUI Text Styles
2. Built-in iOS Fonts
Sans-Serif Fonts
Helvetica Neue
Avenir
Avenir Next
Avenir Next Condensed
Gill Sans
Futura
Optima
Verdana
Trebuchet MS
Arial
Arial Rounded MT Bold
Academy Engraved LET
Al Nile
DIN Alternate / DIN Condensed
Helvetica
Serif Fonts
Georgia
Times New Roman
Palatino
Baskerville
Didot
Bodoni 72
Charter
Iowan Old Style
Cochin
Superclarendon
Monospaced Fonts
Courier New
Courier
Menlo
American Typewriter
Display and Decorative Fonts
Rockwell
Copperplate
Papyrus
Marker Felt
Chalkboard SE
Chalkduster
Noteworthy
Zapfino
Party LET
Savoye LET
Script and Handwriting Fonts
Snell Roundhand
Bradley Hand
Kefa
Kohinoor Telugu
Additional Built-in Fonts
Symbol / Dingbat Fonts
Additional Sans-Serif
International and Multi-script Fonts
Chinese
Japanese
Korean
Arabic
Devanagari (Hindi)
Bangla
Gujarati
Telugu
Gurmukhi (Punjabi)
3. Google Fonts -- Top 100 for iOS
Sans-Serif
Serif
Monospaced
Display
Handwriting and Script
4. How to Add Custom Fonts to iOS
Step 1: Obtain Font Files
Step 2: Add Files to Xcode Project
Step 3: Register Fonts in Info.plist
Step 4: Find the Exact Font Name
Step 5: Use in SwiftUI
Step 6: Use in UIKit
Dynamic Type with @ScaledMetric
Complete Custom Font Integration Example
Swift Package Manager Font Loading
Troubleshooting Custom Fonts
5. Font Pairing Recommendations
Pairing 1: SF Pro Display + SF Pro Text
Pairing 2: Playfair Display + Source Sans 3
Pairing 3: Montserrat + Lora
Pairing 4: Poppins + Inter
Pairing 5: Bebas Neue + Roboto
Pairing 6: DM Serif Display + DM Sans
Pairing 7: Space Grotesk + Inter
Pairing 8: Plus Jakarta Sans + Source Serif 4
Pairing 9: Outfit + Lato
Pairing 10: Oswald + Open Sans
Pairing 11: New York + SF Pro
Pairing 12: Archivo Black + Work Sans
Pairing 13: Cormorant Garamond + Montserrat
Pairing 14: Fredoka + Nunito
Pairing 15: Manrope + Merriweather
6. Font Management Utilities
FontManager: Register Custom Fonts Programmatically
App-Specific Type Scale Extension
Font Preview View
Dynamic Type Helper
List All Device Fonts (Utility Function)
7. Variable Fonts
What Are Variable Fonts?
Benefits
Common Axes
Using Variable Fonts in SwiftUI
Animating Variable Font Axes
Axis Tags Reference
Inspecting Variable Font Axes
Popular Variable Fonts for iOS
Variable Font with Dynamic Type
Quick Reference: Font Selection Decision Tree
Quick Reference: PostScript Names Cheat Sheet
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Choose a reading role
↓2
Check font license
↓3
Register bundled font
↓4
Test Dynamic Type
02 / ArchitectureResponsibility boundariesBoundary 1
Font filesBoundary 2
Typography rolesBoundary 3
Scaled textConnected responsibilities, not a required class hierarchy or an execution trace.
The definitive font reference for iOS and SwiftUI development.
Every font name listed here is the exact string to use in
Font.custom()
or
UIFont(name:size:)
.
Table of Contents
Apple System Fonts
Built-in iOS Fonts
Google Fonts — Top 100 for iOS
How to Add Custom Fonts to iOS
Font Pairing Recommendations
Font Management Utilities
Variable Fonts
1. Apple System Fonts
Apple provides a family of proprietary system fonts that are optimized for every Apple platform.
These fonts are accessed through the SwiftUI
.system()
modifier and cannot be referenced by
PostScript name in
Font.custom()
. They are available on-device without bundling.
SF Pro — The Default System Font
SF Pro is the default sans-serif used across iOS, iPadOS, macOS, and tvOS.
The system automatically selects
SF Pro Text
for sizes below 20pt and
SF Pro Display
for sizes at 20pt and above. You never need to handle this manually.
All Weights:
Weight
UIFont.Weight
SwiftUI
Ultra Light
.ultraLight
.ultraLight
Thin
.thin
.thin
Light
.light
.light
Regular
.regular
.regular
Medium
.medium
.medium
Semibold
.semibold
.semibold
Bold
.bold
.bold
Heavy
.heavy
.heavy
Black
.black
.black
SwiftUI Examples:
// Default system font at various text styles
Text("Headline").font(.headline)
Text("Body text").font(.body)
Text("Caption").font(.caption)
// Explicit size and weight
Text("Custom").font(.system(size: 24, weight: .bold))
Text("Light text").font(.system(size: 16, weight: .light))
Text("Heavy text").font(.system(size: 32, weight: .heavy))
// Using text style with weight
Text("Title").font(.system(.title, weight: .semibold))
Text("Footnote").font(.system(.footnote, weight: .medium))
UIKit Examples:
let headline = UIFont.systemFont(ofSize: 24, weight: .bold)
let body = UIFont.systemFont(ofSize: 17, weight: .regular)
let caption = UIFont.systemFont(ofSize: 12, weight: .light)
let preferredBody = UIFont.preferredFont(forTextStyle: .body)
SF Pro Display vs SF Pro Text
The system handles optical size switching automatically:
SF Pro Text
: Optimized for sizes below 20pt. Slightly wider letter spacing, more open counters for legibility at small sizes.
SF Pro Display
: Optimized for sizes 20pt and above. Tighter spacing, refined details that shine at large sizes.
You do NOT need to select these manually. The system font API handles the switch.
// The system chooses Text or Display automatically based on size
Text("Small").font(.system(size: 14)) // Uses SF Pro Text
Text("Large").font(.system(size: 28)) // Uses SF Pro Display
SF Pro Rounded
A rounded variant of SF Pro with softer terminals. Great for friendly, approachable UIs,
settings screens, and apps aimed at younger audiences.
// SwiftUI — Rounded design
Text("Rounded").font(.system(size: 20, weight: .bold, design: .rounded))
Text("Rounded body").font(.system(.body, design: .rounded))
Text("Rounded title").font(.system(.title, design: .rounded, weight: .semibold))
Text("Rounded caption").font(.system(.caption, design: .rounded, weight: .medium))
// UIKit — Rounded design
let descriptor = UIFont.systemFont(ofSize: 20, weight: .bold).fontDescriptor
.withDesign(.rounded)!
let roundedFont = UIFont(descriptor: descriptor, size: 20)
When to use:
Health apps, children's apps, casual games, notification badges, friendly onboarding flows, Apple Fitness-style interfaces.
SF Mono — Monospaced
The system monospaced font. Every character occupies the same horizontal space.
Essential for code editors, terminal UIs, data tables with aligned numbers, and
countdown timers.
// SwiftUI — Monospaced design
Text("0123456789").font(.system(size: 16, weight: .regular, design: .monospaced))
Text("func hello()").font(.system(.body, design: .monospaced))
Text("Code block").font(.system(.callout, design: .monospaced, weight: .semibold))
// Monospaced digit (only digits are monospaced, letters are proportional)
Text("$1,234.56").monospacedDigit()
// UIKit
let descriptor = UIFont.systemFont(ofSize: 14, weight: .regular).fontDescriptor
.withDesign(.monospaced)!
let monoFont = UIFont(descriptor: descriptor, size: 14)
When to use:
Code editors, terminal emulators, data tables, timers, counters, version numbers, financial figures, developer tools.
SF Compact
Designed for Apple Watch and compact UI contexts. Narrower than SF Pro to fit more
content in constrained spaces.
// watchOS uses SF Compact automatically
// On iOS, you can access it through font descriptors if needed
When to use:
watchOS apps (used automatically), widgets, compact UI elements.
New York — Serif Font
Apple's serif typeface. Available in four optical sizes that the system selects automatically.
Gives an editorial, literary, or premium magazine feel.
Optical Sizes:
-
Small
: Optimized for caption and footnote sizes
-
Medium
: Optimized for body text
-
Large
: Optimized for titles and headlines
-
Extra Large
: Optimized for large display text
// SwiftUI — Serif design
Text("Editorial").font(.system(size: 32, weight: .bold, design: .serif))
Text("Article body").font(.system(.body, design: .serif))
Text("Book title").font(.system(.largeTitle, design: .serif, weight: .black))
Text("Byline").font(.system(.subheadline, design: .serif, weight: .light))
Text("Pull quote").font(.system(.title2, design: .serif, weight: .semibold))
// UIKit
let descriptor = UIFont.systemFont(ofSize: 24, weight: .bold).fontDescriptor
.withDesign(.serif)!
let serifFont = UIFont(descriptor: descriptor, size: 24)
When to use:
News apps, book readers, editorial content, magazine layouts, Apple News-style interfaces, premium branding, literary apps.
Width Variants (iOS 16+)
Starting with iOS 16, you can access compressed, condensed, and expanded width variants
of the system font.
// SwiftUI — Width variants (iOS 16+)
Text("Compressed").font(.system(size: 20, weight: .bold).width(.compressed))
Text("Condensed").font(.system(size: 20, weight: .bold).width(.condensed))
Text("Standard").font(.system(size: 20, weight: .bold).width(.standard))
Text("Expanded").font(.system(size: 20, weight: .bold).width(.expanded))
// Combine with design
Text("Rounded Condensed")
.font(.system(size: 20, weight: .semibold, design: .rounded).width(.condensed))
// UIKit — Width traits
var traits = UIFontDescriptor.SymbolicTraits()
let descriptor = UIFont.systemFont(ofSize: 20, weight: .bold).fontDescriptor
let condensedDescriptor = descriptor.withSymbolicTraits(.traitCondensed)!
let condensedFont = UIFont(descriptor: condensedDescriptor, size: 20)
Width options:
| Width | Description |
|--------------|------------------------------------------|
|
.compressed
| Narrowest, fits maximum content |
|
.condensed
| Narrower than standard |
|
.standard
| Default width |
|
.expanded
| Wider, more spacious letterforms |
Complete System Font Design Matrix
// All four system font designs
Text("Default").font(.system(.title, design: .default)) // SF Pro
Text("Rounded").font(.system(.title, design: .rounded)) // SF Pro Rounded
Text("Serif").font(.system(.title, design: .serif)) // New York
Text("Monospaced").font(.system(.title, design: .monospaced)) // SF Mono
All SwiftUI Text Styles
Text("Large Title") .font(.largeTitle) // 34pt bold
Text("Title") .font(.title) // 28pt regular
Text("Title 2") .font(.title2) // 22pt regular
Text("Title 3") .font(.title3) // 20pt regular
Text("Headline") .font(.headline) // 17pt semibold
Text("Subheadline") .font(.subheadline) // 15pt regular
Text("Body") .font(.body) // 17pt regular
Text("Callout") .font(.callout) // 16pt regular
Text("Footnote") .font(.footnote) // 13pt regular
Text("Caption") .font(.caption) // 12pt regular
Text("Caption 2") .font(.caption2) // 11pt regular
2. Built-in iOS Fonts
Every font listed below is preinstalled on iOS. The string shown is the exact PostScript name
to pass to
Font.custom(_:size:)
or
UIFont(name:size:)
.
Sans-Serif Fonts
Helvetica Neue
The classic Swiss typeface. Was the iOS system font before SF Pro (iOS 8 and earlier).
Variant
Font Name
Ultra Light
HelveticaNeue-UltraLight
Ultra Light Italic
HelveticaNeue-UltraLightItalic
Thin
HelveticaNeue-Thin
Thin Italic
HelveticaNeue-ThinItalic
Light
HelveticaNeue-Light
Light Italic
HelveticaNeue-LightItalic
Regular
HelveticaNeue
Italic
HelveticaNeue-Italic
Medium
HelveticaNeue-Medium
Medium Italic
HelveticaNeue-MediumItalic
Bold
HelveticaNeue-Bold
Bold Italic
HelveticaNeue-BoldItalic
Condensed Bold
HelveticaNeue-CondensedBold
Condensed Black
HelveticaNeue-CondensedBlack
Text("Helvetica Neue").font(.custom("HelveticaNeue", size: 17))
Text("Helvetica Bold").font(.custom("HelveticaNeue-Bold", size: 17))
Text("Helvetica Light").font(.custom("HelveticaNeue-Light", size: 24))
Best for:
Clean UI text, legacy app compatibility, neutral typography.
Avenir
A geometric sans-serif with a warm, humanist feel. Excellent readability.
Variant
Font Name
Book
Avenir-Book
Book Oblique
Avenir-BookOblique
Roman
Avenir-Roman
Oblique
Avenir-Oblique
Medium
Avenir-Medium
Medium Oblique
Avenir-MediumOblique
Heavy
Avenir-Heavy
Heavy Oblique
Avenir-HeavyOblique
Black
Avenir-Black
Black Oblique
Avenir-BlackOblique
Light
Avenir-Light
Light Oblique
Avenir-LightOblique
Text("Avenir Body").font(.custom("Avenir-Book", size: 17))
Text("Avenir Heading").font(.custom("Avenir-Heavy", size: 28))
Best for:
Modern app UIs, lifestyle apps, friendly body text.
Avenir Next
The successor to Avenir with improved legibility and a wider weight range.
Variant
Font Name
Ultra Light
AvenirNext-UltraLight
Ultra Light Italic
AvenirNext-UltraLightItalic
Regular
AvenirNext-Regular
Italic
AvenirNext-Italic
Medium
AvenirNext-Medium
Medium Italic
AvenirNext-MediumItalic
Demi Bold
AvenirNext-DemiBold
Demi Bold Italic
AvenirNext-DemiBoldItalic
Bold
AvenirNext-Bold
Bold Italic
AvenirNext-BoldItalic
Heavy
AvenirNext-Heavy
Heavy Italic
AvenirNext-HeavyItalic
Text("Avenir Next").font(.custom("AvenirNext-Regular", size: 17))
Text("Avenir Next Bold").font(.custom("AvenirNext-Bold", size: 24))
Best for:
Professional apps, enterprise UIs, presentations, marketing content.
Avenir Next Condensed
The condensed variant of Avenir Next. Useful when horizontal space is limited.
Variant
Font Name
Ultra Light
AvenirNextCondensed-UltraLight
Ultra Light Italic
AvenirNextCondensed-UltraLightItalic
Regular
AvenirNextCondensed-Regular
Italic
AvenirNextCondensed-Italic
Medium
AvenirNextCondensed-Medium
Medium Italic
AvenirNextCondensed-MediumItalic
Demi Bold
AvenirNextCondensed-DemiBold
Demi Bold Italic
AvenirNextCondensed-DemiBoldItalic
Bold
AvenirNextCondensed-Bold
Bold Italic
AvenirNextCondensed-BoldItalic
Heavy
AvenirNextCondensed-Heavy
Heavy Italic
AvenirNextCondensed-HeavyItalic
Text("Condensed").font(.custom("AvenirNextCondensed-Bold", size: 20))
Best for:
Navigation bars, tab labels, space-constrained UI, tags, badges.
Gill Sans
A classic British humanist sans-serif with distinctive character.
Variant
Font Name
Regular
GillSans
Italic
GillSans-Italic
Light
GillSans-Light
Light Italic
GillSans-LightItalic
Semibold
GillSans-SemiBold
Semibold Italic
GillSans-SemiBoldItalic
Bold
GillSans-Bold
Bold Italic
GillSans-BoldItalic
Ultra Bold
GillSans-UltraBold
Text("Gill Sans").font(.custom("GillSans", size: 17))
Text("Gill Sans Bold").font(.custom("GillSans-Bold", size: 24))
Best for:
British branding, classic design, book covers, elegant headings.
Futura
A geometric sans-serif icon. Clean circles and triangles form its letterforms.
Variant
Font Name
Medium
Futura-Medium
Medium Italic
Futura-MediumItalic
Bold
Futura-Bold
Condensed Medium
Futura-CondensedMedium
Condensed Extra Bold
Futura-CondensedExtraBold
Text("FUTURA").font(.custom("Futura-Bold", size: 32))
Text("Futura body").font(.custom("Futura-Medium", size: 17))
Best for:
Fashion apps, bold statements, modern branding, geometric design systems.
Optima
A humanist sans-serif with subtle stroke contrast, straddling serif and sans-serif.
Variant
Font Name
Regular
Optima-Regular
Italic
Optima-Italic
Bold
Optima-Bold
Bold Italic
Optima-BoldItalic
Extra Black
Optima-ExtraBlack
Text("Optima").font(.custom("Optima-Regular", size: 17))
Best for:
Wellness apps, spa and beauty branding, elegant body text, high-end retail.
Verdana
Designed by Matthew Carter for screen legibility. Wide letterforms, generous x-height.
Variant
Font Name
Regular
Verdana
Italic
Verdana-Italic
Bold
Verdana-Bold
Bold Italic
Verdana-BoldItalic
Text("Verdana").font(.custom("Verdana", size: 17))
Best for:
Maximum screen readability, accessible UIs, form labels.
Trebuchet MS
A humanist sans-serif designed for web and screen use.
Variant
Font Name
Regular
TrebuchetMS
Italic
TrebuchetMS-Italic
Bold
TrebuchetMS-Bold
Bold Italic
Trebuchet-BoldItalic
Text("Trebuchet").font(.custom("TrebuchetMS", size: 17))
Best for:
Web-style UIs, cross-platform consistency.
Arial
The ubiquitous sans-serif. Near-identical to Helvetica in metrics.
Variant
Font Name
Regular
ArialMT
Italic
Arial-ItalicMT
Bold
Arial-BoldMT
Bold Italic
Arial-BoldItalicMT
Text("Arial").font(.custom("ArialMT", size: 17))
Best for:
Cross-platform compatibility, documents, web views.
Arial Rounded MT Bold
A rounded, friendly variant of Arial.
Variant
Font Name
Bold
ArialRoundedMTBold
Text("Rounded").font(.custom("ArialRoundedMTBold", size: 20))
Best for:
Friendly UI elements, buttons, badges.
Academy Engraved LET
Variant
Font Name
Plain
AcademyEngravedLetPlain
Al Nile
Variant
Font Name
Regular
AlNile
Bold
AlNile-Bold
DIN Alternate / DIN Condensed
Variant
Font Name
DIN Alternate Bold
DINAlternate-Bold
DIN Condensed Bold
DINCondensed-Bold
Text("DIN").font(.custom("DINAlternate-Bold", size: 20))
Best for:
Road signs style, technical UIs, data displays, dashboards.
Helvetica
The original Helvetica (not Neue).
Variant
Font Name
Regular
Helvetica
Italic (Oblique)
Helvetica-Oblique
Light
Helvetica-Light
Light Oblique
Helvetica-LightOblique
Bold
Helvetica-Bold
Bold Oblique
Helvetica-BoldOblique
Serif Fonts
Georgia
A screen-optimized serif with generous proportions.
Variant
Font Name
Regular
Georgia
Italic
Georgia-Italic
Bold
Georgia-Bold
Bold Italic
Georgia-BoldItalic
Text("Georgia").font(.custom("Georgia", size: 17))
Text("Georgia Bold").font(.custom("Georgia-Bold", size: 24))
Best for:
Long-form reading, news articles, blog content, editorial apps.
Times New Roman
The classic newspaper serif.
Variant
Font Name
Regular
TimesNewRomanPSMT
Italic
TimesNewRomanPS-ItalicMT
Bold
TimesNewRomanPS-BoldMT
Bold Italic
TimesNewRomanPS-BoldItalicMT
Text("Times").font(.custom("TimesNewRomanPSMT", size: 17))
Best for:
Document viewers, academic apps, traditional editorial.
Palatino
A Renaissance-inspired serif with wide proportions and excellent readability.
Variant
Font Name
Roman
Palatino-Roman
Italic
Palatino-Italic
Bold
Palatino-Bold
Bold Italic
Palatino-BoldItalic
Text("Palatino").font(.custom("Palatino-Roman", size: 17))
Best for:
Book reading apps, literary content, premium editorial.
Baskerville
A transitional serif with sharp contrast and refined details.
Variant
Font Name
Regular
Baskerville
Italic
Baskerville-Italic
Semibold
Baskerville-SemiBold
Semibold Italic
Baskerville-SemiBoldItalic
Bold
Baskerville-Bold
Bold Italic
Baskerville-BoldItalic
Text("Baskerville").font(.custom("Baskerville", size: 17))
Text("Baskerville Bold").font(.custom("Baskerville-Bold", size: 28))
Best for:
Premium branding, luxury apps, classic literature, legal documents.
Didot
A high-contrast modern serif with dramatic thick-thin strokes.
Variant
Font Name
Regular
Didot
Italic
Didot-Italic
Bold
Didot-Bold
Text("DIDOT").font(.custom("Didot-Bold", size: 36))
Best for:
Fashion, luxury branding, magazine covers, high-end product displays.
Bodoni 72
Another high-contrast modern serif. Multiple optical variants available.
Variant
Font Name
Book
BodoniSvtyTwoITCTT-Book
Book Italic
BodoniSvtyTwoITCTT-BookIta
Bold
BodoniSvtyTwoITCTT-Bold
OS Book
BodoniSvtyTwoOSITCTT-Book
OS Book Italic
BodoniSvtyTwoOSITCTT-BookIt
OS Bold
BodoniSvtyTwoOSITCTT-Bold
SC Book
BodoniSvtyTwoSCITCTT-Book
Ornaments
BodoniOrnamentsITCTT
Text("BODONI").font(.custom("BodoniSvtyTwoITCTT-Bold", size: 36))
Text("Small Caps").font(.custom("BodoniSvtyTwoSCITCTT-Book", size: 20))
Best for:
Fashion editorial, luxury branding, poster-style headings, high-contrast display.
Charter
A highly legible serif designed for laser printers and screens.
Variant
Font Name
Roman
Charter-Roman
Italic
Charter-Italic
Bold
Charter-Bold
Bold Italic
Charter-BoldItalic
Black
Charter-Black
Black Italic
Charter-BlackItalic
Text("Charter").font(.custom("Charter-Roman", size: 17))
Best for:
Long-form reading, documentation, e-books, RSS readers.
Iowan Old Style
A refined old-style serif optimized for extended reading on screens.
Variant
Font Name
Roman
IowanOldStyle-Roman
Italic
IowanOldStyle-Italic
Bold
IowanOldStyle-Bold
Bold Italic
IowanOldStyle-BoldItalic
Text("Iowan Old Style").font(.custom("IowanOldStyle-Roman", size: 17))
Best for:
Apple Books-style reading, literary apps, elegant body text.
Cochin
An elegant old-style serif.
Variant
Font Name
Regular
Cochin
Italic
Cochin-Italic
Bold
Cochin-Bold
Bold Italic
Cochin-BoldItalic
Superclarendon
A bold slab serif with strong visual impact.
Variant
Font Name
Regular
Superclarendon-Regular
Italic
Superclarendon-Italic
Light
Superclarendon-Light
Light Italic
Superclarendon-LightItalic
Bold
Superclarendon-Bold
Bold Italic
Superclarendon-BoldItalic
Black
Superclarendon-Black
Black Italic
Superclarendon-BlackItalic
Monospaced Fonts
Courier New
The classic typewriter monospaced font.
Variant
Font Name
Regular
CourierNewPSMT
Italic
CourierNewPS-ItalicMT
Bold
CourierNewPS-BoldMT
Bold Italic
CourierNewPS-BoldItalicMT
Text("Courier").font(.custom("CourierNewPSMT", size: 14))
Best for:
Retro terminal UIs, typewriter aesthetic, screenplay formatters.
Courier
The original Courier (slightly different from Courier New).
Variant
Font Name
Regular
Courier
Oblique
Courier-Oblique
Bold
Courier-Bold
Bold Oblique
Courier-BoldOblique
Menlo
A monospaced font based on Bitstream Vera Sans Mono. The default Xcode font before SF Mono.
Variant
Font Name
Regular
Menlo-Regular
Italic
Menlo-Italic
Bold
Menlo-Bold
Bold Italic
Menlo-BoldItalic
Text("func main()").font(.custom("Menlo-Regular", size: 14))
Text("// Bold comment").font(.custom("Menlo-Bold", size: 14))
Best for:
Code display, terminal emulators, developer tools, log viewers.
American Typewriter
A monospaced font with typewriter charm and serifs.
Variant
Font Name
Regular
AmericanTypewriter
Light
AmericanTypewriter-Light
Semibold
AmericanTypewriter-Semibold
Bold
AmericanTypewriter-Bold
Condensed
AmericanTypewriter-Condensed
Condensed Light
AmericanTypewriter-CondensedLight
Condensed Bold
AmericanTypewriter-CondensedBold
Text("Typewriter").font(.custom("AmericanTypewriter", size: 17))
Best for:
Note-taking apps, journal/diary UIs, retro aesthetics, creative writing tools.
Display and Decorative Fonts
Rockwell
A geometric slab serif with strong presence.
Variant
Font Name
Regular
Rockwell-Regular
Italic
Rockwell-Italic
Bold
Rockwell-Bold
Bold Italic
Rockwell-BoldItalic
Text("ROCKWELL").font(.custom("Rockwell-Bold", size: 32))
Best for:
Bold headings, poster-style layouts, strong brand statements.
Copperplate
An all-caps engraved style font with small-cap lowercase.
Variant
Font Name
Regular
Copperplate
Light
Copperplate-Light
Bold
Copperplate-Bold
Text("COPPERPLATE").font(.custom("Copperplate-Bold", size: 24))
Best for:
Formal invitations, certificates, luxury branding, restaurant menus.
Papyrus
A distressed, hand-drawn style font.
Variant
Font Name
Regular
Papyrus
Condensed
Papyrus-Condensed
Text("Papyrus").font(.custom("Papyrus", size: 20))
Best for:
Themed apps (ancient, natural), generally avoid for professional UIs.
Marker Felt
A felt-tip marker style font.
Variant
Font Name
Thin
MarkerFelt-Thin
Wide
MarkerFelt-Wide
Text("Marker Felt").font(.custom("MarkerFelt-Thin", size: 20))
Best for:
Whiteboard UIs, sketching apps, children's content.
Chalkboard SE
A clean chalkboard-style handwriting font.
Variant
Font Name
Regular
ChalkboardSE-Regular
Light
ChalkboardSE-Light
Bold
ChalkboardSE-Bold
Text("Chalkboard").font(.custom("ChalkboardSE-Regular", size: 17))
Best for:
Education apps, children's apps, informal notes.
Chalkduster
A rougher chalkboard-style font.
Variant
Font Name
Regular
Chalkduster
Text("Chalkduster").font(.custom("Chalkduster", size: 20))
Noteworthy
A casual handwriting font.
Variant
Font Name
Light
Noteworthy-Light
Bold
Noteworthy-Bold
Text("Noteworthy").font(.custom("Noteworthy-Light", size: 17))
Best for:
Personal notes, diary entries, sticky note UIs.
Zapfino
An elaborate calligraphic script with extreme flourishes.
Variant
Font Name
Regular
Zapfino
Text("Zapfino").font(.custom("Zapfino", size: 24))
Best for:
Decorative headers only, wedding apps, formal invitations (use sparingly).
Party LET
A festive, playful display font.
Variant
Font Name
Plain
PartyLetPlain
Savoye LET
An elegant script font.
Variant
Font Name
Plain
SavoyeLetPlain
Text("Savoye LET").font(.custom("SavoyeLetPlain", size: 28))
Best for:
Elegant signatures, wedding invitations, formal flourishes.
Script and Handwriting Fonts
Snell Roundhand
A flowing copperplate script with graceful strokes.
Variant
Font Name
Regular
SnellRoundhand
Bold
SnellRoundhand-Bold
Black
SnellRoundhand-Black
Text("Elegant Script").font(.custom("SnellRoundhand", size: 24))
Best for:
Formal invitations, signatures, elegant accents.
Bradley Hand
A casual handwriting style.
Variant
Font Name
Bold
BradleyHandITCTT-Bold
Text("Handwritten").font(.custom("BradleyHandITCTT-Bold", size: 17))
Best for:
Personal touches, note-style UIs, casual annotations.
Kefa
Variant
Font Name
Regular
Kefa-Regular
Kohinoor Telugu
Variant
Font Name
Regular
KohinoorTelugu-Regular
Medium
KohinoorTelugu-Medium
Light
KohinoorTelugu-Light
Additional Built-in Fonts
Symbol / Dingbat Fonts
Font Family
Font Name
Description
Symbol
Symbol
Greek and math symbols
Zapf Dingbats
ZapfDingbatsITC
Decorative symbols
Additional Sans-Serif
Variant
Font Name
Euphemia UCAS
EuphemiaUCAS
Euphemia UCAS Bold
EuphemiaUCAS-Bold
Euphemia UCAS Italic
EuphemiaUCAS-Italic
Galvji
Galvji
Galvji Bold
Galvji-Bold
Galvji Bold Oblique
Galvji-BoldOblique
Galvji Oblique
Galvji-Oblique
Grantha Sangam MN
GranthaSangamMN-Regular
Grantha Sangam MN Bold
GranthaSangamMN-Bold
Hoefler Text
HoeflerText-Regular
Hoefler Text Italic
HoeflerText-Italic
Hoefler Text Bold
HoeflerText-Black
Hoefler Text Bold Italic
HoeflerText-BlackItalic
Kailasa
Kailasa
Kailasa Bold
Kailasa-Bold
Khmer Sangam MN
KhmerSangamMN
Lao Sangam MN
LaoSangamMN
Malayalam Sangam MN
MalayalamSangamMN
Malayalam Sangam MN Bold
MalayalamSangamMN-Bold
Myanmar Sangam MN
MyanmarSangamMN
Myanmar Sangam MN Bold
MyanmarSangamMN-Bold
Noto Nastaliq Urdu
NotoNastaliqUrdu
Noto Nastaliq Urdu Bold
NotoNastaliqUrdu-Bold
Noto Sans Kannada
NotoSansKannada-Regular
Noto Sans Kannada Bold
NotoSansKannada-Bold
Noto Sans Kannada Light
NotoSansKannada-Light
Noto Sans Myanmar
NotoSansMyanmar-Regular
Noto Sans Myanmar Bold
NotoSansMyanmar-Bold
Noto Sans Myanmar Light
NotoSansMyanmar-Light
Noto Sans Oriya
NotoSansOriya
Noto Sans Oriya Bold
NotoSansOriya-Bold
Sinhala Sangam MN
SinhalaSangamMN
Sinhala Sangam MN Bold
SinhalaSangamMN-Bold
Tamil Sangam MN
TamilSangamMN
Tamil Sangam MN Bold
TamilSangamMN-Bold
Thonburi
Thonburi
Thonburi Light
Thonburi-Light
Thonburi Bold
Thonburi-Bold
International and Multi-script Fonts
Chinese
Variant
Font Name
Script
PingFang SC Regular
PingFangSC-Regular
Simplified Chinese
PingFang SC Medium
PingFangSC-Medium
Simplified Chinese
PingFang SC Semibold
PingFangSC-Semibold
Simplified Chinese
PingFang SC Light
PingFangSC-Light
Simplified Chinese
PingFang SC Thin
PingFangSC-Thin
Simplified Chinese
PingFang SC Ultralight
PingFangSC-Ultralight
Simplified Chinese
PingFang TC Regular
PingFangTC-Regular
Traditional Chinese
PingFang TC Medium
PingFangTC-Medium
Traditional Chinese
PingFang TC Semibold
PingFangTC-Semibold
Traditional Chinese
PingFang TC Light
PingFangTC-Light
Traditional Chinese
PingFang TC Thin
PingFangTC-Thin
Traditional Chinese
PingFang TC Ultralight
PingFangTC-Ultralight
Traditional Chinese
PingFang HK Regular
PingFangHK-Regular
Hong Kong Chinese
PingFang HK Medium
PingFangHK-Medium
Hong Kong Chinese
PingFang HK Semibold
PingFangHK-Semibold
Hong Kong Chinese
PingFang HK Light
PingFangHK-Light
Hong Kong Chinese
PingFang HK Thin
PingFangHK-Thin
Hong Kong Chinese
PingFang HK Ultralight
PingFangHK-Ultralight
Hong Kong Chinese
Japanese
Variant
Font Name
Hiragino Sans W3
HiraginoSans-W3
Hiragino Sans W6
HiraginoSans-W6
Hiragino Sans W7
HiraginoSans-W7
Hiragino Mincho ProN W3
HiraMinProN-W3
Hiragino Mincho ProN W6
HiraMinProN-W6
Text("Japanese text").font(.custom("HiraginoSans-W3", size: 17))
Korean
Variant
Font Name
Apple SD Gothic Neo Regular
AppleSDGothicNeo-Regular
Apple SD Gothic Neo Thin
AppleSDGothicNeo-Thin
Apple SD Gothic Neo UltraLight
AppleSDGothicNeo-UltraLight
Apple SD Gothic Neo Light
AppleSDGothicNeo-Light
Apple SD Gothic Neo Medium
AppleSDGothicNeo-Medium
Apple SD Gothic Neo Semibold
AppleSDGothicNeo-SemiBold
Apple SD Gothic Neo Bold
AppleSDGothicNeo-Bold
Text("Korean text").font(.custom("AppleSDGothicNeo-Regular", size: 17))
Arabic
Variant
Font Name
Geeza Pro Regular
GeezaPro
Geeza Pro Bold
GeezaPro-Bold
Mishafi Regular
DiwanMishafi
Baghdad Regular
Baghdad
Farah
Farah
Damascus
Damascus
Damascus Light
DamascusLight
Damascus Medium
DamascusMedium
Damascus Semibold
DamascusSemiBold
Damascus Bold
DamascusBold
Devanagari (Hindi)
Variant
Font Name
Kohinoor Devanagari Regular
KohinoorDevanagari-Regular
Kohinoor Devanagari Light
KohinoorDevanagari-Light
Kohinoor Devanagari Semibold
KohinoorDevanagari-Semibold
Devanagari Sangam MN
DevanagariSangamMN
Devanagari Sangam MN Bold
DevanagariSangamMN-Bold
Bangla
Variant
Font Name
Kohinoor Bangla Regular
KohinoorBangla-Regular
Kohinoor Bangla Light
KohinoorBangla-Light
Kohinoor Bangla Semibold
KohinoorBangla-Semibold
Gujarati
Variant
Font Name
Kohinoor Gujarati Regular
KohinoorGujarati-Regular
Kohinoor Gujarati Light
KohinoorGujarati-Light
Kohinoor Gujarati Bold
KohinoorGujarati-Bold
Gujarati Sangam MN
GujaratiSangamMN
Gujarati Sangam MN Bold
GujaratiSangamMN-Bold
Telugu
Variant
Font Name
Kohinoor Telugu Regular
KohinoorTelugu-Regular
Kohinoor Telugu Medium
KohinoorTelugu-Medium
Kohinoor Telugu Light
KohinoorTelugu-Light
Gurmukhi (Punjabi)
Variant
Font Name
Mukta Mahee Regular
MuktaMahee-Regular
Mukta Mahee Light
MuktaMahee-Light
Mukta Mahee Bold
MuktaMahee-Bold
Gurmukhi MN
GurmukhiMN
Gurmukhi MN Bold
GurmukhiMN-Bold
3. Google Fonts -- Top 100 for iOS
These are the most popular Google Fonts used in iOS apps. To use any of these, you must
download the font files and add them to your Xcode project (see Section 4).
Sans-Serif
#
Font Name
Weights Available
Best Use Case
1
Inter
100-900
UI text, dashboards, SaaS
2
Roboto
100, 300, 400, 500, 700, 900
Material Design, Android parity
3
Open Sans
300-800
Universal body text
4
Lato
100, 300, 400, 700, 900
Friendly body text, corporate
5
Montserrat
100-900
Modern headings, marketing
6
Poppins
100-900
SaaS, startup, geometric UI
7
Nunito
200-900
Rounded, friendly, children's apps
8
Raleway
100-900
Elegant headings, fashion
9
Source Sans 3
200-900
Technical docs, code-adjacent text
10
Work Sans
100-900
Clean UI, editorial
11
DM Sans
100-900
Minimal UI, dashboard
12
Manrope
200-800
Tech products, developer tools
13
Plus Jakarta Sans
200-800
Fintech, professional
14
Space Grotesk
300-700
Tech, developer, coding apps
15
Outfit
100-900
Modern branding, startup
16
Sora
100-800
Futuristic, crypto, web3
17
Urbanist
100-900
Modern geometric, luxury tech
18
Lexend
100-900
Accessibility, dyslexia-friendly
19
Albert Sans
100-900
Geometric, versatile UI
20
Figtree
300-900
Friendly, approachable UI
21
Geist
100-900
Vercel-style, developer UI
22
Satoshi
300-900
Minimal, modern branding
23
Nunito Sans
200-900
UI text, clean dashboards
24
Karla
200-800
Grotesque, editorial
25
Rubik
300-900
Rounded, playful
26
Barlow
100-900
Industrial, technical
27
Mulish
200-900
Clean, minimal
28
Quicksand
300-700
Rounded, friendly
29
Cabin
400-700
Humanist, warm
30
Josefin Sans
100-700
Elegant, geometric
31
PT Sans
400, 700
Universal text, multilingual
32
Noto Sans
100-900
Global multilingual support
33
Overpass
100-900
Highway signage inspired, clean
34
IBM Plex Sans
100-700
Enterprise, corporate, IBM design
35
Red Hat Display
300-900
Open source branding
36
Exo 2
100-900
Futuristic, geometric
37
Archivo
100-900
Bold headings, editorial
38
Hind
300-700
Devanagari + Latin body text
39
Public Sans
100-900
Government, accessible
40
General Sans
200-700
Modern grotesque, branding
Serif
#
Font Name
Weights Available
Best Use Case
41
Playfair Display
400-900
Editorial headings, magazine
42
Merriweather
300, 400, 700, 900
Long-form reading, blogs
43
Lora
400-700
Book text, literary
44
Source Serif 4
200-900
Technical docs, paired with Source Sans
45
Crimson Text
400, 600, 700
Book text, classic reading
46
Libre Baskerville
400, 700
Elegant body text, traditional
47
EB Garamond
400-800
Book typography, literary
48
Cormorant Garamond
300-700
High fashion, luxury headings
49
DM Serif Display
400
Bold editorial headings
50
Bitter
100-900
Screen reading, warm serif
51
Noto Serif
100-900
Global multilingual serif
52
PT Serif
400, 700
Multilingual reading
53
Spectral
200-800
Long-form, screen-optimized
54
Vollkorn
400-900
Warm, organic reading
55
Fraunces
100-900
Playful serif, retro
56
Instrument Serif
400
Minimal editorial
57
Newsreader
200-800
News, journalism
Monospaced
#
Font Name
Weights Available
Best Use Case
58
Fira Code
300-700
Code editor with ligatures
59
JetBrains Mono
100-800
IDE, code editor, terminal
60
Source Code Pro
200-900
Code display, terminal
61
IBM Plex Mono
100-700
Enterprise code, terminal
62
Space Mono
400, 700
Futuristic, retro-tech
63
Roboto Mono
100-700
Data tables, code blocks
64
Inconsolata
200-900
Code display, clean mono
65
Ubuntu Mono
400, 700
Linux-style terminal
66
Cascadia Code
200-700
Windows Terminal-style
67
Geist Mono
100-900
Vercel developer tools
68
Anonymous Pro
400, 700
Coding, terminal
69
Overpass Mono
300-700
Data, tables
70
Red Hat Mono
300-700
Enterprise code
Display
#
Font Name
Weights Available
Best Use Case
71
Bebas Neue
400
Bold headings, posters
72
Oswald
200-700
Condensed headings, news
73
Anton
400
Impact headings, bold statements
74
Archivo Black
400
Ultra bold display
75
Righteous
400
Retro, rounded display
76
Fredoka
300-700
Playful, children's content
77
Lilita One
400
Fun, casual headings
78
Passion One
400, 700, 900
Sports, energetic
79
Bungee
400
Signage, athletic
80
Abril Fatface
400
High-contrast display
81
Alfa Slab One
400
Slab serif display
82
Lobster
400
Retro script display
83
Permanent Marker
400
Hand-drawn, casual
84
Bangers
400
Comic book, energetic
85
Staatliches
400
Display, condensed sans
86
Comfortaa
300-700
Rounded, futuristic
87
Russo One
400
Tech, gaming
88
Press Start 2P
400
Pixel art, retro gaming
89
Orbitron
400-900
Sci-fi, space, futuristic
90
Teko
300-700
Sports, condensed display
Handwriting and Script
#
Font Name
Weights Available
Best Use Case
91
Dancing Script
400-700
Casual elegance, invitations
92
Pacifico
400
Retro surf, casual branding
93
Caveat
400-700
Handwritten notes, annotations
94
Sacramento
400
Elegant script, signatures
95
Great Vibes
400
Formal calligraphy
96
Satisfy
400
Retro script
97
Kalam
300, 400, 700
Informal handwriting, notes
98
Patrick Hand
400
Casual handwriting
99
Indie Flower
400
Playful handwriting, fun
100
Amatic SC
400, 700
Tall, narrow handwriting
4. How to Add Custom Fonts to iOS
Step 1: Obtain Font Files
Download
.ttf
(TrueType) or
.otf
(OpenType) font files. For Google Fonts,
download from https://fonts.google.com or use a package manager.
Step 2: Add Files to Xcode Project
Drag the font files into your Xcode project navigator.
In the dialog that appears, check
"Copy items if needed"
.
Ensure
"Add to targets"
has your app target checked.
Verify the files appear under
Build Phases > Copy Bundle Resources
.
Step 3: Register Fonts in Info.plist
Add the font file names to your
Info.plist
:
<key>UIAppFonts</key>
<array>
<string>Inter-Regular.ttf</string>
<string>Inter-Medium.ttf</string>
<string>Inter-Bold.ttf</string>
<string>PlayfairDisplay-Bold.ttf</string>
<string>PlayfairDisplay-Regular.ttf</string>
</array>
Or in the Xcode Info tab, add a row:
- Key:
Fonts provided by application
- Type: Array
- Items: each font filename (including extension)
Step 4: Find the Exact Font Name
The filename is NOT always the font name. Use this code to discover exact PostScript names:
// Run this once at app launch to print all available fonts
for family in UIFont.familyNames.sorted() {
print("Family: \(family)")
for name in UIFont.fontNames(forFamilyName: family) {
print(" -- \(name)")
}
}
Or find a specific family:
// Check a specific font family
let names = UIFont.fontNames(forFamilyName: "Inter")
print(names) // ["Inter-Regular", "Inter-Medium", "Inter-Bold", ...]
Step 5: Use in SwiftUI
// Basic usage
Text("Custom Font").font(.custom("Inter-Regular", size: 17))
Text("Bold Custom").font(.custom("Inter-Bold", size: 24))
// With Dynamic Type support (RECOMMENDED)
Text("Dynamic Type").font(.custom("Inter-Regular", size: 17, relativeTo: .body))
Text("Dynamic Title").font(.custom("Inter-Bold", size: 28, relativeTo: .title))
Text("Dynamic Caption").font(.custom("Inter-Regular", size: 12, relativeTo: .caption))
// Fixed size (does not scale with Dynamic Type)
Text("Fixed Size").font(.custom("Inter-Regular", fixedSize: 14))
Step 6: Use in UIKit
// Basic usage
let font = UIFont(name: "Inter-Regular", size: 17)
// With Dynamic Type metrics
let customFont = UIFont(name: "Inter-Regular", size: 17)!
let scaledFont = UIFontMetrics(forTextStyle: .body).scaledFont(for: customFont)
label.font = scaledFont
label.adjustsFontForContentSizeCategory = true
Dynamic Type with @ScaledMetric
Use
@ScaledMetric
for custom font sizes that scale with Dynamic Type settings:
struct ContentView: View {
@ScaledMetric(relativeTo: .body) var bodySize: CGFloat = 17
@ScaledMetric(relativeTo: .title) var titleSize: CGFloat = 28
@ScaledMetric(relativeTo: .caption) var captionSize: CGFloat = 12
@ScaledMetric var iconSize: CGFloat = 24
var body: some View {
VStack {
Text("Title")
.font(.custom("PlayfairDisplay-Bold", size: titleSize))
Text("Body text here")
.font(.custom("Inter-Regular", size: bodySize))
Text("Caption")
.font(.custom("Inter-Regular", size: captionSize))
Image(systemName: "star.fill")
.font(.system(size: iconSize))
}
}
}
Complete Custom Font Integration Example
// App entry point - register fonts
@main
struct MyApp: App {
init() {
// Fonts are auto-registered via Info.plist
// But you can verify they loaded:
#if DEBUG
if UIFont(name: "Inter-Regular", size: 17) == nil {
print("WARNING: Inter-Regular font not found. Check Info.plist and bundle.")
}
#endif
}
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
Swift Package Manager Font Loading
If your fonts come from a Swift Package:
import SwiftUI
extension Font {
static func registerFontsFromBundle(bundle: Bundle) {
let fontURLs = bundle.urls(forResourcesWithExtension: "ttf", subdirectory: nil) ?? []
let otfURLs = bundle.urls(forResourcesWithExtension: "otf", subdirectory: nil) ?? []
for url in fontURLs + otfURLs {
CTFontManagerRegisterFontsForURL(url as CFURL, .process, nil)
}
}
}
Troubleshooting Custom Fonts
Problem
Solution
Font not appearing
Check Info.plist entry matches exact filename
Wrong weight showing
Use PostScript name, not display name
Font not in bundle
Verify target membership in File Inspector
Dynamic Type not working
Use relativeTo: parameter in Font.custom()
Font looks different on device
Simulator and device may render differently; always test both
5. Font Pairing Recommendations
Fifteen proven font pairings for iOS apps. Each pairing includes a heading font,
a body font, the design context, and ready-to-use SwiftUI code.
Pairing 1: SF Pro Display + SF Pro Text
Style:
System default, clean, Apple-native
Use case:
Any iOS app that wants to feel native and polished
VStack(alignment: .leading, spacing: 8) {
Text("Welcome Back")
.font(.system(size: 34, weight: .bold))
Text("Here is what happened while you were away. Your projects have new updates and your team left comments.")
.font(.system(size: 17, weight: .regular))
.foregroundStyle(.secondary)
}
Pairing 2: Playfair Display + Source Sans 3
Style:
Editorial, magazine
Use case:
News apps, editorial content, blog readers
VStack(alignment: .leading, spacing: 8) {
Text("The Art of Typography")
.font(.custom("PlayfairDisplay-Bold", size: 32, relativeTo: .largeTitle))
Text("Typography is the art and technique of arranging type to make written language legible, readable, and appealing when displayed.")
.font(.custom("SourceSans3-Regular", size: 17, relativeTo: .body))
.foregroundStyle(.secondary)
}
Pairing 3: Montserrat + Lora
Style:
Modern heading with classic body
Use case:
Portfolio apps, creative agencies, lifestyle brands
VStack(alignment: .leading, spacing: 8) {
Text("CREATIVE STUDIO")
.font(.custom("Montserrat-Bold", size: 28, relativeTo: .title))
.tracking(2)
Text("We craft digital experiences that blend innovation with timeless design principles.")
.font(.custom("Lora-Regular", size: 17, relativeTo: .body))
}
Pairing 4: Poppins + Inter
Style:
SaaS, modern dashboard
Use case:
Productivity apps, dashboards, admin panels, B2B tools
VStack(alignment: .leading, spacing: 8) {
Text("Dashboard Overview")
.font(.custom("Poppins-SemiBold", size: 24, relativeTo: .title2))
Text("Your key metrics are performing above average this quarter with a 23% increase in engagement.")
.font(.custom("Inter-Regular", size: 15, relativeTo: .body))
.foregroundStyle(.secondary)
}
Pairing 5: Bebas Neue + Roboto
Style:
Bold impact with clean body
Use case:
Sports apps, fitness trackers, event apps, bold branding
VStack(alignment: .leading, spacing: 4) {
Text("GAME DAY")
.font(.custom("BebasNeue-Regular", size: 48, relativeTo: .largeTitle))
Text("Get ready for tonight's matchup. Here are the stats, lineups, and predictions you need.")
.font(.custom("Roboto-Regular", size: 16, relativeTo: .body))
.foregroundStyle(.secondary)
}
Pairing 6: DM Serif Display + DM Sans
Style:
Luxury, minimal elegance
Use case:
Premium products, luxury e-commerce, high-end hospitality
VStack(alignment: .leading, spacing: 8) {
Text("Curated Collection")
.font(.custom("DMSerifDisplay-Regular", size: 32, relativeTo: .largeTitle))
Text("Each piece in our collection has been carefully selected for its craftsmanship and timeless appeal.")
.font(.custom("DMSans-Regular", size: 16, relativeTo: .body))
.foregroundStyle(.secondary)
}
Pairing 7: Space Grotesk + Inter
Style:
Tech, developer-focused
Use case:
Developer tools, coding apps, API documentation
VStack(alignment: .leading, spacing: 8) {
Text("API Reference")
.font(.custom("SpaceGrotesk-Bold", size: 28, relativeTo: .title))
Text("Explore our comprehensive API documentation with interactive examples and detailed guides.")
.font(.custom("Inter-Regular", size: 15, relativeTo: .body))
.foregroundStyle(.secondary)
}
Pairing 8: Plus Jakarta Sans + Source Serif 4
Style:
Professional, trustworthy
Use case:
Fintech, banking, insurance, professional services
VStack(alignment: .leading, spacing: 8) {
Text("Financial Summary")
.font(.custom("PlusJakartaSans-Bold", size: 24, relativeTo: .title2))
Text("Your portfolio has grown 12.4% this quarter, outperforming the market benchmark by 3.2 percentage points.")
.font(.custom("SourceSerif4-Regular", size: 16, relativeTo: .body))
.foregroundStyle(.secondary)
}
Pairing 9: Outfit + Lato
Style:
Startup, approachable
Use case:
Startup landing pages, onboarding flows, consumer apps
VStack(alignment: .leading, spacing: 8) {
Text("Get Started")
.font(.custom("Outfit-SemiBold", size: 28, relativeTo: .title))
Text("Set up your profile in just a few steps and start connecting with people who share your interests.")
.font(.custom("Lato-Regular", size: 16, relativeTo: .body))
.foregroundStyle(.secondary)
}
Pairing 10: Oswald + Open Sans
Style:
News, content-heavy
Use case:
News readers, content aggregators, media apps
VStack(alignment: .leading, spacing: 8) {
Text("BREAKING NEWS")
.font(.custom("Oswald-Bold", size: 28, relativeTo: .title))
Text("Markets rally as economic indicators show stronger-than-expected growth in the third quarter.")
.font(.custom("OpenSans-Regular", size: 16, relativeTo: .body))
.foregroundStyle(.secondary)
}
Pairing 11: New York + SF Pro
Style:
Apple editorial
Use case:
Apple News-style apps, premium editorial, literary content
VStack(alignment: .leading, spacing: 8) {
Text("The Future of Design")
.font(.system(size: 32, weight: .bold, design: .serif))
Text("As technology evolves, so does the language of design. New tools and paradigms are reshaping how we create.")
.font(.system(size: 17, weight: .regular))
.foregroundStyle(.secondary)
}
Pairing 12: Archivo Black + Work Sans
Style:
Bold, sporty
Use case:
Sports brands, fitness apps, bold product pages
VStack(alignment: .leading, spacing: 4) {
Text("PUSH LIMITS")
.font(.custom("ArchivoBlack-Regular", size: 36, relativeTo: .largeTitle))
Text("Track your workouts, set new records, and compete with friends in weekly challenges.")
.font(.custom("WorkSans-Regular", size: 16, relativeTo: .body))
.foregroundStyle(.secondary)
}
Pairing 13: Cormorant Garamond + Montserrat
Style:
High fashion, luxury
Use case:
Fashion apps, luxury brands, art galleries, premium retail
VStack(alignment: .leading, spacing: 12) {
Text("Autumn Collection")
.font(.custom("CormorantGaramond-SemiBold", size: 36, relativeTo: .largeTitle))
Text("EXPLORE THE LATEST ARRIVALS")
.font(.custom("Montserrat-Medium", size: 12, relativeTo: .caption))
.tracking(3)
.foregroundStyle(.secondary)
}
Pairing 14: Fredoka + Nunito
Style:
Kids, playful
Use case:
Children's apps, educational games, family content
VStack(alignment: .leading, spacing: 8) {
Text("Let's Learn!")
.font(.custom("Fredoka-SemiBold", size: 32, relativeTo: .largeTitle))
Text("Choose a fun activity below and start your learning adventure today.")
.font(.custom("Nunito-Regular", size: 17, relativeTo: .body))
.foregroundStyle(.secondary)
}
Pairing 15: Manrope + Merriweather
Style:
Blog, long-form reading
Use case:
Blog readers, RSS apps, knowledge bases, documentation
VStack(alignment: .leading, spacing: 8) {
Text("Understanding Swift Concurrency")
.font(.custom("Manrope-Bold", size: 24, relativeTo: .title2))
Text("Swift concurrency introduces structured approaches to writing asynchronous code, making it safer and more readable than traditional callback patterns.")
.font(.custom("Merriweather-Regular", size: 16, relativeTo: .body))
.foregroundStyle(.secondary)
}
6. Font Management Utilities
FontManager: Register Custom Fonts Programmatically
import SwiftUI
import CoreText
final class FontManager {
static let shared = FontManager()
private var registeredFonts: Set<String> = []
private init() {}
/// Register all custom fonts from the main bundle.
/// Call this in your App init or AppDelegate.
func registerAllFonts() {
registerFonts(from: .main, extensions: ["ttf", "otf"])
}
/// Register fonts from a specific bundle (useful for SPM packages).
func registerFonts(from bundle: Bundle, extensions: [String] = ["ttf", "otf"]) {
for ext in extensions {
guard let urls = bundle.urls(forResourcesWithExtension: ext, subdirectory: nil) else {
continue
}
for url in urls {
registerFont(at: url)
}
}
}
/// Register a single font file by URL.
func registerFont(at url: URL) {
let fontName = url.lastPathComponent
guard !registeredFonts.contains(fontName) else { return }
var error: Unmanaged<CFError>?
let success = CTFontManagerRegisterFontsForURL(url as CFURL, .process, &error)
if success {
registeredFonts.insert(fontName)
} else if let error = error?.takeRetainedValue() {
print("Failed to register font \(fontName): \(error)")
}
}
/// Print all available font families and their font names (debug utility).
func printAllFonts() {
for family in UIFont.familyNames.sorted() {
print("Family: \(family)")
for name in UIFont.fontNames(forFamilyName: family).sorted() {
print(" \(name)")
}
}
}
/// Check if a specific font is available.
func isFontAvailable(_ fontName: String) -> Bool {
return UIFont(name: fontName, size: 12) != nil
}
}
App-Specific Type Scale Extension
Define a consistent type scale for your entire app:
import SwiftUI
extension Font {
// MARK: - Display
static let appDisplayLarge = Font.custom("Inter-Bold", size: 40, relativeTo: .largeTitle)
static let appDisplayMedium = Font.custom("Inter-Bold", size: 34, relativeTo: .largeTitle)
static let appDisplaySmall = Font.custom("Inter-SemiBold", size: 28, relativeTo: .title)
// MARK: - Headings
static let appHeading1 = Font.custom("Inter-Bold", size: 24, relativeTo: .title2)
static let appHeading2 = Font.custom("Inter-SemiBold", size: 20, relativeTo: .title3)
static let appHeading3 = Font.custom("Inter-SemiBold", size: 17, relativeTo: .headline)
// MARK: - Body
static let appBodyLarge = Font.custom("Inter-Regular", size: 17, relativeTo: .body)
static let appBody = Font.custom("Inter-Regular", size: 15, relativeTo: .subheadline)
static let appBodySmall = Font.custom("Inter-Regular", size: 13, relativeTo: .footnote)
// MARK: - Labels
static let appLabel = Font.custom("Inter-Medium", size: 14, relativeTo: .subheadline)
static let appLabelSmall = Font.custom("Inter-Medium", size: 12, relativeTo: .caption)
// MARK: - Caption
static let appCaption = Font.custom("Inter-Regular", size: 12, relativeTo: .caption)
static let appCaptionSmall = Font.custom("Inter-Regular", size: 11, relativeTo: .caption2)
// MARK: - Monospaced
static let appCode = Font.custom("JetBrainsMono-Regular", size: 14, relativeTo: .body)
static let appCodeSmall = Font.custom("JetBrainsMono-Regular", size: 12, relativeTo: .caption)
// MARK: - Special
static let appButton = Font.custom("Inter-SemiBold", size: 16, relativeTo: .body)
static let appTabBar = Font.custom("Inter-Medium", size: 10, relativeTo: .caption2)
static let appBadge = Font.custom("Inter-Bold", size: 11, relativeTo: .caption2)
}
Usage:
VStack(alignment: .leading, spacing: 8) {
Text("Page Title").font(.appHeading1)
Text("Body content goes here.").font(.appBody)
Text("12:34 PM").font(.appCaption)
Text("let x = 42").font(.appCode)
}
Font Preview View
A debug view that displays all registered fonts:
import SwiftUI
struct FontPreviewView: View {
@State private var families: [String] = []
@State private var searchText = ""
@State private var previewText = "The quick brown fox jumps over the lazy dog"
@State private var previewSize: CGFloat = 17
var filteredFamilies: [String] {
if searchText.isEmpty {
return families
}
return families.filter { $0.localizedCaseInsensitiveContains(searchText) }
}
var body: some View {
NavigationStack {
List {
Section {
TextField("Preview text", text: $previewText)
Stepper("Size: \(Int(previewSize))pt", value: $previewSize, in: 8...72)
}
ForEach(filteredFamilies, id: \.self) { family in
Section(header: Text(family)) {
ForEach(UIFont.fontNames(forFamilyName: family).sorted(), id: \.self) { name in
VStack(alignment: .leading, spacing: 4) {
Text(previewText)
.font(.custom(name, size: previewSize))
Text(name)
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.vertical, 2)
}
}
}
}
.navigationTitle("Font Preview")
.searchable(text: $searchText, prompt: "Search fonts...")
.onAppear {
families = UIFont.familyNames.sorted()
}
}
}
}
#Preview {
FontPreviewView()
}
Dynamic Type Helper
Ensure custom fonts respect the user's Dynamic Type preferences:
import SwiftUI
struct DynamicTypeFont: ViewModifier {
let fontName: String
let baseSize: CGFloat
let textStyle: Font.TextStyle
@ScaledMetric private var scaledSize: CGFloat
init(fontName: String, baseSize: CGFloat, textStyle: Font.TextStyle = .body) {
self.fontName = fontName
self.baseSize = baseSize
self.textStyle = textStyle
self._scaledSize = ScaledMetric(wrappedValue: baseSize, relativeTo: textStyle)
}
func body(content: Content) -> some View {
content.font(.custom(fontName, size: scaledSize))
}
}
extension View {
func dynamicFont(_ name: String, size: CGFloat, relativeTo style: Font.TextStyle = .body) -> some View {
modifier(DynamicTypeFont(fontName: name, baseSize: size, textStyle: style))
}
}
// Usage:
// Text("Hello").dynamicFont("Inter-Regular", size: 17, relativeTo: .body)
List All Device Fonts (Utility Function)
import UIKit
func getAllFonts() -> [(family: String, fonts: [String])] {
UIFont.familyNames.sorted().map { family in
(family: family, fonts: UIFont.fontNames(forFamilyName: family).sorted())
}
}
func findFont(containing query: String) -> [String] {
var results: [String] = []
for family in UIFont.familyNames {
for name in UIFont.fontNames(forFamilyName: family) {
if name.localizedCaseInsensitiveContains(query) {
results.append(name)
}
}
}
return results.sorted()
}
// Usage:
// let allFonts = getAllFonts()
// let interFonts = findFont(containing: "Inter")
7. Variable Fonts
What Are Variable Fonts?
A variable font is a single font file that contains an entire family of variations along
one or more design axes (weight, width, slant, optical size). Instead of shipping separate
files for Regular, Medium, Bold, and so on, a single variable font file can interpolate
smoothly between any values on its defined axes.
Benefits
Single file
: One
.ttf
replaces 10-20 individual weight files
Smooth interpolation
: Animate between weights or widths fluidly
Smaller bundle size
: Typically smaller than the equivalent collection of static fonts
Fine-grained control
: Access any weight (e.g., 450, 550) not just named stops
Animation
: Smoothly transition font weight, width, or slant
Common Axes
Axis Code
Name
Typical Range
Description
wght
Weight
100-900
Thin to Black
wdth
Width
75-125
Condensed to Expanded
slnt
Slant
-12 to 0
Upright to slanted
ital
Italic
0 or 1
Roman or Italic
opsz
Optical Size
8-144
Small text to display
Using Variable Fonts in SwiftUI
SwiftUI does not natively support setting arbitrary axis values. You need to drop down
to
UIFont
with font descriptors and bridge back to SwiftUI.
import SwiftUI
import UIKit
extension Font {
/// Create a font from a variable font with a specific weight value.
/// - Parameters:
/// - name: The PostScript name of the variable font
/// - size: Point size
/// - weight: Weight value (100-900, where 400 = regular, 700 = bold)
static func variable(_ name: String, size: CGFloat, weight: CGFloat = 400) -> Font {
let descriptor = UIFontDescriptor(fontAttributes: [
.name: name,
kCTFontVariationAttribute as UIFontDescriptor.AttributeName: [
// Weight axis tag: 2003265652 = 'wght'
2003265652: weight
]
])
let uiFont = UIFont(descriptor: descriptor, size: size)
return Font(uiFont)
}
/// Create a font from a variable font with weight and width axes.
static func variable(
_ name: String,
size: CGFloat,
weight: CGFloat = 400,
width: CGFloat = 100
) -> Font {
let descriptor = UIFontDescriptor(fontAttributes: [
.name: name,
kCTFontVariationAttribute as UIFontDescriptor.AttributeName: [
2003265652: weight, // wght
2003072104: width // wdth
]
])
let uiFont = UIFont(descriptor: descriptor, size: size)
return Font(uiFont)
}
}
Usage:
VStack(spacing: 8) {
Text("Weight 300").font(.variable("Inter", size: 17, weight: 300))
Text("Weight 400").font(.variable("Inter", size: 17, weight: 400))
Text("Weight 500").font(.variable("Inter", size: 17, weight: 500))
Text("Weight 600").font(.variable("Inter", size: 17, weight: 600))
Text("Weight 700").font(.variable("Inter", size: 17, weight: 700))
}
Animating Variable Font Axes
You can animate weight or width changes for smooth transitions:
import SwiftUI
struct AnimatedWeightText: View {
@State private var weight: CGFloat = 400
let fontName: String
let text: String
var body: some View {
VStack(spacing: 20) {
Text(text)
.font(.variable(fontName, size: 32, weight: weight))
.animation(.easeInOut(duration: 0.3), value: weight)
Slider(value: $weight, in: 100...900, step: 1)
.padding(.horizontal)
Text("Weight: \(Int(weight))")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
Axis Tags Reference
When working with variable fonts programmatically, you need numeric axis tags.
These are computed from the 4-character axis tag string:
func axisTag(from string: String) -> Int {
let chars = Array(string.utf8)
guard chars.count == 4 else { return 0 }
return Int(chars[0]) << 24 | Int(chars[1]) << 16 | Int(chars[2]) << 8 | Int(chars[3])
}
// Common axis tags:
// axisTag(from: "wght") = 2003265652
// axisTag(from: "wdth") = 2003072104
// axisTag(from: "slnt") = 1936486004
// axisTag(from: "ital") = 1769234796
// axisTag(from: "opsz") = 1869640570
Inspecting Variable Font Axes
Discover which axes and ranges a variable font supports:
import CoreText
func inspectVariableFont(named fontName: String, size: CGFloat = 17) {
guard let uiFont = UIFont(name: fontName, size: size) else {
print("Font not found: \(fontName)")
return
}
let ctFont = uiFont as CTFont
guard let axes = CTFontCopyVariationAxes(ctFont) as? [[String: Any]] else {
print("\(fontName) is not a variable font (no variation axes).")
return
}
print("Variable font: \(fontName)")
print("Axes:")
for axis in axes {
let name = axis[kCTFontVariationAxisNameKey as String] ?? "Unknown"
let tag = axis[kCTFontVariationAxisIdentifierKey as String] ?? 0
let min = axis[kCTFontVariationAxisMinimumValueKey as String] ?? 0
let max = axis[kCTFontVariationAxisMaximumValueKey as String] ?? 0
let def = axis[kCTFontVariationAxisDefaultValueKey as String] ?? 0
print(" \(name): tag=\(tag), range=\(min)...\(max), default=\(def)")
}
}
Popular Variable Fonts for iOS
Font Name
Axes Available
Weight Range
Width Range
Notes
Inter
wght
100-900
--
Best all-around UI variable font
Roboto Flex
wght, wdth, slnt, opsz
100-1000
75-151
Most versatile variable font
Source Sans 3
wght, ital
200-900
--
Excellent for technical content
Outfit
wght
100-900
--
Modern, geometric
Work Sans
wght, ital
100-900
--
Clean, editorial
DM Sans
wght, ital, opsz
100-1000
--
Minimal, versatile
Manrope
wght
200-800
--
Tech-focused
Plus Jakarta Sans
wght, ital
200-800
--
Professional, fintech
Space Grotesk
wght
300-700
--
Developer tools
Sora
wght
100-800
--
Futuristic, web3
Montserrat
wght, ital
100-900
--
Popular geometric sans
Nunito
wght, ital
200-900
--
Rounded, friendly
Raleway
wght, ital
100-900
--
Elegant sans-serif
Playfair Display
wght, ital
400-900
--
Editorial serif
Lora
wght, ital
400-700
--
Literary serif
Fraunces
wght, opsz, SOFT, WONK
100-900
--
Playful serif with custom axes
Variable Font with Dynamic Type
Combine variable font control with Dynamic Type support:
import SwiftUI
struct VariableDynamicTypeFont: ViewModifier {
let fontName: String
let baseSize: CGFloat
let weight: CGFloat
let textStyle: Font.TextStyle
@ScaledMetric private var scaledSize: CGFloat
init(fontName: String, baseSize: CGFloat, weight: CGFloat, textStyle: Font.TextStyle) {
self.fontName = fontName
self.baseSize = baseSize
self.weight = weight
self.textStyle = textStyle
self._scaledSize = ScaledMetric(wrappedValue: baseSize, relativeTo: textStyle)
}
func body(content: Content) -> some View {
content.font(.variable(fontName, size: scaledSize, weight: weight))
}
}
extension View {
func variableFont(
_ name: String,
size: CGFloat,
weight: CGFloat = 400,
relativeTo style: Font.TextStyle = .body
) -> some View {
modifier(VariableDynamicTypeFont(
fontName: name,
baseSize: size,
weight: weight,
textStyle: style
))
}
}
// Usage:
// Text("Dynamic Variable").variableFont("Inter", size: 17, weight: 500, relativeTo: .body)
Quick Reference: Font Selection Decision Tree
What kind of content?
|
+-- System / Native feel
| +-- Default -> .system()
| +-- Friendly -> .system(design: .rounded)
| +-- Editorial -> .system(design: .serif) (New York)
| +-- Code/Data -> .system(design: .monospaced) (SF Mono)
|
+-- Custom branding
| +-- Need variable weight control?
| | +-- Yes -> Inter, Roboto Flex, Outfit (variable fonts)
| | +-- No -> Static font files
| |
| +-- What style?
| +-- Clean modern -> Inter, DM Sans, Plus Jakarta Sans
| +-- Geometric -> Poppins, Montserrat, Urbanist
| +-- Humanist -> Lato, Open Sans, Source Sans 3
| +-- Tech / Developer -> Space Grotesk, Manrope, Geist
| +-- Rounded / Friendly -> Nunito, Quicksand, Fredoka
| +-- Editorial serif -> Playfair Display, DM Serif Display
| +-- Reading serif -> Merriweather, Lora, EB Garamond
| +-- Monospaced code -> JetBrains Mono, Fira Code
| +-- Bold display -> Bebas Neue, Oswald, Anton
|
+-- Built-in (no bundling needed)
+-- Sans-serif body -> Avenir Next, Helvetica Neue, Gill Sans
+-- Serif body -> Georgia, Palatino, Charter, Iowan Old Style
+-- Luxury display -> Didot, Bodoni
+-- Monospaced -> Menlo, Courier New
+-- Decorative -> Copperplate, Rockwell, Zapfino
Quick Reference: PostScript Names Cheat Sheet
The most commonly needed PostScript names for
Font.custom()
:
// Sans-Serif (built-in)
"HelveticaNeue" "HelveticaNeue-Bold"
"AvenirNext-Regular" "AvenirNext-Bold"
"AvenirNext-DemiBold" "AvenirNext-Medium"
"GillSans" "GillSans-Bold"
"Futura-Medium" "Futura-Bold"
"Optima-Regular" "Optima-Bold"
// Serif (built-in)
"Georgia" "Georgia-Bold"
"TimesNewRomanPSMT" "TimesNewRomanPS-BoldMT"
"Palatino-Roman" "Palatino-Bold"
"Baskerville" "Baskerville-Bold"
"Didot" "Didot-Bold"
"BodoniSvtyTwoITCTT-Bold" "BodoniSvtyTwoSCITCTT-Book"
"Charter-Roman" "Charter-Bold"
"IowanOldStyle-Roman" "IowanOldStyle-Bold"
// Monospaced (built-in)
"Menlo-Regular" "Menlo-Bold"
"CourierNewPSMT" "CourierNewPS-BoldMT"
"AmericanTypewriter" "AmericanTypewriter-Bold"
// Display (built-in)
"Copperplate" "Copperplate-Bold"
"Rockwell-Regular" "Rockwell-Bold"
"DINAlternate-Bold" "DINCondensed-Bold"
// Script (built-in)
"SnellRoundhand" "SnellRoundhand-Bold"
"BradleyHandITCTT-Bold" "Zapfino"
"SavoyeLetPlain" "ChalkboardSE-Regular"
// International (built-in)
"PingFangSC-Regular" "PingFangSC-Semibold"
"HiraginoSans-W3" "HiraginoSans-W6"
"AppleSDGothicNeo-Regular" "AppleSDGothicNeo-Bold"
"KohinoorDevanagari-Regular" "KohinoorBangla-Regular"
This catalog covers the Apple system fonts, all major built-in iOS fonts with exact PostScript names, the top 100 Google Fonts for mobile development, custom font integration guides, proven font pairings, management utilities, and variable font techniques. Every font name string is ready to use directly in Font.custom() or UIFont(name:size:).
---
# Layer-by-Layer Icons with Icon Composer
https://nagarjuna2997.github.io/ios-agent-skill/guides/design-icon-composer.html
← Back to all frameworks & guides
All articles →Design · Reference guideLayer-by-Layer Icons with Icon ComposerRepository guidance for Layer-by-Layer Icons with Icon Composer. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
Context
Pattern
Make the layer plan first
Compose and annotate
Integrate with the app
What an agent must report
Anti-Patterns
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Separate foreground layers
↓2
Export editable artwork
↓3
Assemble in Icon Composer
↓4
Inspect rendered variants
02 / ArchitectureResponsibility boundariesBoundary 1
Layer sourcesBoundary 2
Icon compositionBoundary 3
App icon assetConnected responsibilities, not a required class hierarchy or an execution trace.
Context
Use this for app identity, layered app icons, Liquid Glass icon treatments, and Xcode icon integration. The output should include separately editable artwork, a layer manifest, appearance decisions, and a verified Icon Composer document when the tool is available.
Official sources checked 2026-09-10:
Icon Composer
Create an app icon in Icon Composer
App icon design guidance
Create icons with Icon Composer
Pattern
Make the layer plan first
Define the app’s recognizable symbol and a single visual idea. Separate the background, supporting shape, primary symbol, and optional accent. Use semantic names and stable ordering. For example, a reading app can use a background fill, a book silhouette, a page mark, and a small bookmark accent; each foreground shape remains editable independently.
Prefer clean vector foregrounds. Use SVG or transparent PNG assets for import, exported on the same canvas so positions remain aligned. Keep text outlined. For raster art, retain transparent backgrounds around foreground artwork. Keep imported background art opaque and full bleed. Do not rasterize all pieces into one image.
A useful project handoff contains:
IconLayers/
01-base.svg
02-symbol.svg
03-accent.svg
manifest.json
README.md
These filenames are a recommended project convention, not an Apple file-format requirement. The CLI’s
--xcodegen
starter creates editable layers as a starting point; replace their shapes and colors for the actual brand.
Compose and annotate
Launch Icon Composer from Xcode’s developer tools menu or the standalone app. Start a new document, choose the supported platforms, and set its background fill. Import foreground artwork, organize it into no more than four groups, and arrange depth from back to front. Use its material controls for highlights, refraction, translucency, and shadow rather than painting those effects into every asset.
Tune Default, Dark, and Mono appearances. Inspect at small sizes and against different surrounding backgrounds. Keep the silhouette recognizable when decorative detail disappears. Save the native
.icon
document, reopen it, and check that its layers and appearance settings remain editable.
Integrate with the app
Add the saved icon document to the Xcode project and associate it with the app target’s icon setting. Build with the actual SDK and inspect the installed icon. Use Apple’s asset-catalog image-stack workflow for platforms whose icon format differs, including tvOS and visionOS; do not assume the same Composer workflow applies to every platform.
Export flattened images only for marketing, previews, or compatibility workflows that specifically require them. Retain the editable source and native document alongside those exports.
What an agent must report
List the artwork files, layer ordering, appearance variants checked, native document path if created, and Xcode verification performed. If Icon Composer is unavailable, deliver the SVG/PNG layer pack plus import instructions and explicitly leave native
.icon
verification pending. Do not invent an undocumented
.icon
schema or rename a JSON file to make it appear native.
Anti-Patterns
One flattened image presented as a layered icon project.
System shadows, corner masks, or highlights baked into foreground layers and then applied again by the compositor.
A tiny detailed logo that loses its identity at home-screen size.
A generic starter icon described as finished brand artwork.
Claiming a native
.icon
file was verified without opening it in Icon Composer.
---
# Interaction Standards
https://nagarjuna2997.github.io/ios-agent-skill/guides/design-interaction-standards.html
← Back to all frameworks & guides
All articles →Design · Reference guideInteraction StandardsRepository guidance for Interaction Standards. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
1. Animation Standards
Default Curves and Durations
When to Use Which Animation
Transition Standards
2. Haptic Feedback Rules
Haptic Selection Guide
SensoryFeedback (iOS 17+)
UIImpactFeedbackGenerator (iOS 16 and Below)
Best Practices
3. SF Symbols Guidelines
Size Guidelines
Rendering Modes
Variable Value Symbols
Symbol Effects
Preferred Symbol Weight
4. Button Style Standards
Complete Button Style System
5. Loading, Empty, and Error State Patterns
ViewState Enum
Loading View
Empty State View
Error State View
AsyncContentView Wrapper
6. Localization Approach
String Catalogs Setup
Date and Number Formatting
RTL and Layout Considerations
7. Privacy Manifest
PrivacyInfo.xcprivacy Structure
Required Reason API Categories
Third-Party SDK Manifests
8. Device Support and Adaptive Layout
Size Class Detection
NavigationSplitView for iPad Sidebar
ViewThatFits
iPad-Specific Features
9. Preview Provider Standards
Modern Previews (iOS 17+)
Interactive Previews
Preview with Mock Data
SwiftData Preview Container
Legacy PreviewProvider (iOS 16 and Below)
Quick Reference Summary
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Identify an interaction
↓2
Choose native behavior
↓3
Add accessible feedback
↓4
Test alternate inputs
02 / ArchitectureResponsibility boundariesBoundary 1
User inputBoundary 2
Interaction stateBoundary 3
Feedback and accessibilityConnected responsibilities, not a required class hierarchy or an execution trace.
Comprehensive reference for animations, haptics, symbols, button styles, state patterns, localization, privacy, adaptive layout, and preview standards.
1. Animation Standards
Default Curves and Durations
Category
Duration
Curve
Usage
Micro interaction
0.2s
.easeOut
Toggles, button presses, icon changes
Navigation transition
0.35s
.spring(response: 0.35, dampingFraction: 0.85)
Push, pop, tab switches
Content loading
0.3s
.easeInOut
Skeleton to content, fade-in
Dismissal
0.25s
.easeIn
Sheet dismiss, alert close, toast exit
Bouncy spring
--
.bouncy
Playful UI: reactions, badges, celebrations
Snappy spring
--
.snappy
Responsive controls: sliders, toggles
Smooth spring
--
.smooth
Elegant reveals: cards, overlays
When to Use Which Animation
Use Case
Animation
Rationale
Button tap feedback
.easeOut, 0.2s
Quick acknowledgment, no lingering
Toggle switch
.snappy
Responsive mechanical feel
Card expand/collapse
.spring(response: 0.35, dampingFraction: 0.85)
Natural, physical motion
Pull-to-refresh
.bouncy
Playful rubber-band feel
Modal presentation
.smooth
Elegant, unhurried entrance
Error shake
.default.repeatCount(3)
Attention-grabbing without being jarring
Skeleton shimmer
.easeInOut, 1.2s, repeat
Smooth continuous loop
Item deletion
.easeIn, 0.25s
Quick exit, attention moves forward
List reorder
.snappy
Keeps up with the finger
Hero transition
matchedGeometryEffect
Spatial continuity between screens
Transition Standards
// MARK: - Sheet Presentation (use system default)
.sheet(isPresented: $showSettings) {
SettingsView()
}
// MARK: - Full Screen Cover with Custom Transition
.fullScreenCover(isPresented: $showOnboarding) {
OnboardingView()
.transition(.opacity.combined(with: .move(edge: .bottom)))
}
// MARK: - Navigation Push (system default)
NavigationStack {
List(items) { item in
NavigationLink(value: item) {
ItemRow(item: item)
}
}
.navigationDestination(for: Item.self) { item in
ItemDetailView(item: item)
}
}
// MARK: - Hero Transition with matchedGeometryEffect
struct HeroTransitionExample: View {
@Namespace private var heroNamespace
@State private var isExpanded = false
var body: some View {
if isExpanded {
DetailCard(namespace: heroNamespace)
.onTapGesture {
withAnimation(.spring(response: 0.35, dampingFraction: 0.85)) {
isExpanded = false
}
}
} else {
ThumbnailCard(namespace: heroNamespace)
.onTapGesture {
withAnimation(.spring(response: 0.35, dampingFraction: 0.85)) {
isExpanded = true
}
}
}
}
}
struct ThumbnailCard: View {
var namespace: Namespace.ID
var body: some View {
RoundedRectangle(cornerRadius: 12)
.fill(.blue.gradient)
.matchedGeometryEffect(id: "card", in: namespace)
.frame(width: 120, height: 120)
.overlay {
Text("Tap")
.matchedGeometryEffect(id: "title", in: namespace)
}
}
}
struct DetailCard: View {
var namespace: Namespace.ID
var body: some View {
RoundedRectangle(cornerRadius: 24)
.fill(.blue.gradient)
.matchedGeometryEffect(id: "card", in: namespace)
.frame(maxWidth: .infinity, maxHeight: 400)
.overlay {
Text("Detail View")
.matchedGeometryEffect(id: "title", in: namespace)
}
.padding()
}
}
// MARK: - Custom Asymmetric Transition
extension AnyTransition {
static var slideAndFade: AnyTransition {
.asymmetric(
insertion: .move(edge: .trailing).combined(with: .opacity),
removal: .move(edge: .leading).combined(with: .opacity)
)
}
}
// Usage:
struct CustomTransitionExample: View {
@State private var showContent = false
var body: some View {
VStack {
if showContent {
ContentView()
.transition(.slideAndFade)
}
Button("Toggle") {
withAnimation(.easeInOut(duration: 0.3)) {
showContent.toggle()
}
}
}
}
}
// MARK: - Phased Animation for Multi-Step Effects
struct PhasedAnimationExample: View {
@State private var trigger = false
var body: some View {
Image(systemName: "bell.fill")
.font(.system(size: 32))
.phaseAnimator([false, true], trigger: trigger) { content, phase in
content
.scaleEffect(phase ? 1.2 : 1.0)
.rotationEffect(.degrees(phase ? 15 : 0))
} animation: { phase in
phase ? .bouncy : .snappy
}
.onTapGesture { trigger.toggle() }
}
}
2. Haptic Feedback Rules
Haptic Selection Guide
Haptic Type
When to Use
Examples
.success
Completed action
Save confirmed, message sent, purchase complete
.warning
Destructive action confirmation
Delete dialog appears, irreversible action prompt
.error
Failed action
Validation error, network failure, permission denied
.light
Toggle or selection change
Switch toggled, radio selected, checkbox tapped
.medium
Snap to position
Pull-to-refresh threshold reached, snap point hit
.heavy
Long press activation
Context menu triggered, drag-and-drop pickup
.selection
Scrolling through values
Picker scroll, date wheel spin, segment change
SensoryFeedback (iOS 17+)
// MARK: - SensoryFeedback Modifier (Preferred for iOS 17+)
struct HapticExamples: View {
@State private var isFavorited = false
@State private var taskCompleted = false
@State private var showError = false
@State private var sliderValue = 0.5
var body: some View {
VStack(spacing: 24) {
// Success haptic on task completion
Button("Complete Task") {
taskCompleted = true
}
.sensoryFeedback(.success, trigger: taskCompleted)
// Light haptic on toggle
Toggle("Favorite", isOn: $isFavorited)
.sensoryFeedback(.selection, trigger: isFavorited)
// Error haptic on failure
Button("Submit") {
showError = true
}
.sensoryFeedback(.error, trigger: showError)
// Impact haptic with weight
Button("Heavy Action") { }
.sensoryFeedback(.impact(weight: .heavy), trigger: taskCompleted)
}
}
}
UIImpactFeedbackGenerator (iOS 16 and Below)
// MARK: - Haptic Manager for Pre-iOS 17
final class HapticManager {
static let shared = HapticManager()
private init() {}
func impact(_ style: UIImpactFeedbackGenerator.FeedbackStyle) {
let generator = UIImpactFeedbackGenerator(style: style)
generator.prepare()
generator.impactOccurred()
}
func notification(_ type: UINotificationFeedbackGenerator.FeedbackType) {
let generator = UINotificationFeedbackGenerator()
generator.prepare()
generator.notificationOccurred(type)
}
func selection() {
let generator = UISelectionFeedbackGenerator()
generator.prepare()
generator.selectionChanged()
}
}
// Usage:
Button("Save") {
performSave()
HapticManager.shared.notification(.success)
}
Button("Delete") {
HapticManager.shared.notification(.warning)
showDeleteConfirmation = true
}
Best Practices
Never fire haptics on passive events (scrolling, appearing, background refresh).
Respect the system setting: haptics are automatically suppressed when the user disables them.
Prepare generators early (
.prepare()
) before the moment of feedback for zero-latency response.
Do not chain multiple haptics in rapid succession; one feedback per gesture.
Test on a real device -- the Simulator does not produce haptic output.
3. SF Symbols Guidelines
Size Guidelines
Context
Point Size
Example
Inline with text
17pt
Label icons, list item accessories
Tab bar
24pt
Bottom navigation icons
Buttons
28-32pt
Toolbar actions, floating action buttons
Feature icons
44-64pt
Empty states, onboarding, settings headers
Rendering Modes
Mode
When to Use
Description
.monochrome
Default, single-tone UI elements
One color applied uniformly
.hierarchical
Icons needing depth
Primary color with opacity layers
.palette
Brand-specific multi-color
You control each layer color
.multicolor
System-defined rich icons
Weather, file types, flags
// MARK: - Rendering Modes
struct SymbolRenderingExamples: View {
var body: some View {
VStack(spacing: 20) {
// Monochrome (default)
Image(systemName: "heart.fill")
.symbolRenderingMode(.monochrome)
.foregroundStyle(.red)
// Hierarchical — automatic depth
Image(systemName: "square.stack.3d.up.fill")
.symbolRenderingMode(.hierarchical)
.foregroundStyle(.blue)
.font(.system(size: 44))
// Palette — explicit layer colors
Image(systemName: "person.crop.circle.badge.checkmark")
.symbolRenderingMode(.palette)
.foregroundStyle(.blue, .green)
.font(.system(size: 44))
// Multicolor — system-defined
Image(systemName: "cloud.sun.rain.fill")
.symbolRenderingMode(.multicolor)
.font(.system(size: 44))
}
}
}
Variable Value Symbols
// MARK: - Variable Value (0.0 to 1.0) for Progress
struct VariableSymbolExample: View {
@State private var progress: Double = 0.0
var body: some View {
VStack(spacing: 16) {
Image(systemName: "speaker.wave.3.fill", variableValue: progress)
.font(.system(size: 48))
.foregroundStyle(.blue)
.contentTransition(.symbolEffect(.replace))
Image(systemName: "wifi", variableValue: progress)
.font(.system(size: 48))
.foregroundStyle(.green)
Slider(value: $progress, in: 0...1)
.padding(.horizontal)
}
}
}
Symbol Effects
// MARK: - Symbol Effects (iOS 17+)
struct SymbolEffectsExample: View {
@State private var bellTapped = false
@State private var isActive = false
@State private var downloadComplete = false
var body: some View {
VStack(spacing: 24) {
// Bounce on tap
Image(systemName: "bell.fill")
.font(.system(size: 32))
.symbolEffect(.bounce, value: bellTapped)
.onTapGesture { bellTapped.toggle() }
// Continuous pulse while active
Image(systemName: "antenna.radiowaves.left.and.right")
.font(.system(size: 32))
.symbolEffect(.pulse, isActive: isActive)
// Variable color animation (iterating layers)
Image(systemName: "wifi")
.font(.system(size: 32))
.symbolEffect(.variableColor.iterative, isActive: isActive)
// Appear / disappear
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 32))
.symbolEffect(.appear, isActive: downloadComplete)
// Replace transition between symbols
Image(systemName: isActive ? "pause.fill" : "play.fill")
.contentTransition(.symbolEffect(.replace))
.font(.system(size: 32))
.onTapGesture {
withAnimation { isActive.toggle() }
}
// Breathe effect
Image(systemName: "heart.fill")
.font(.system(size: 32))
.foregroundStyle(.red)
.symbolEffect(.breathe, isActive: isActive)
// Wiggle effect
Image(systemName: "bell.badge.fill")
.font(.system(size: 32))
.symbolEffect(.wiggle, value: bellTapped)
// Rotate effect
Image(systemName: "gear")
.font(.system(size: 32))
.symbolEffect(.rotate, isActive: isActive)
}
}
}
Preferred Symbol Weight
Use
.medium
weight by default to match the system HIG:
Image(systemName: "gear")
.fontWeight(.medium)
4. Button Style Standards
Complete Button Style System
// MARK: - Primary Button Style
struct PrimaryButtonStyle: ButtonStyle {
@Environment(\.isEnabled) private var isEnabled
var isLoading: Bool = false
func makeBody(configuration: Configuration) -> some View {
HStack(spacing: 8) {
if isLoading {
ProgressView()
.tint(.white)
}
configuration.label
}
.font(.body.weight(.semibold))
.foregroundStyle(.white)
.frame(maxWidth: .infinity, minHeight: 50)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(
isEnabled
? AnyShapeStyle(LinearGradient(
colors: [.blue, .blue.opacity(0.8)],
startPoint: .topLeading,
endPoint: .bottomTrailing))
: AnyShapeStyle(.gray.opacity(0.4))
)
)
.scaleEffect(configuration.isPressed ? 0.97 : 1.0)
.opacity(isLoading ? 0.9 : 1.0)
.animation(.easeOut(duration: 0.2), value: configuration.isPressed)
.allowsHitTesting(!isLoading)
}
}
// MARK: - Secondary Button Style
struct SecondaryButtonStyle: ButtonStyle {
@Environment(\.isEnabled) private var isEnabled
var isLoading: Bool = false
func makeBody(configuration: Configuration) -> some View {
HStack(spacing: 8) {
if isLoading {
ProgressView()
.tint(.accentColor)
}
configuration.label
}
.font(.body.weight(.semibold))
.foregroundStyle(isEnabled ? .accentColor : .gray)
.frame(maxWidth: .infinity, minHeight: 50)
.background(
RoundedRectangle(cornerRadius: 12)
.stroke(isEnabled ? Color.accentColor : .gray, lineWidth: 1.5)
)
.scaleEffect(configuration.isPressed ? 0.97 : 1.0)
.animation(.easeOut(duration: 0.2), value: configuration.isPressed)
}
}
// MARK: - Destructive Button Style
struct DestructiveButtonStyle: ButtonStyle {
@Environment(\.isEnabled) private var isEnabled
var isLoading: Bool = false
func makeBody(configuration: Configuration) -> some View {
HStack(spacing: 8) {
if isLoading {
ProgressView()
.tint(.white)
}
configuration.label
}
.font(.body.weight(.semibold))
.foregroundStyle(.white)
.frame(maxWidth: .infinity, minHeight: 50)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(isEnabled ? Color.red : .gray.opacity(0.4))
)
.scaleEffect(configuration.isPressed ? 0.97 : 1.0)
.animation(.easeOut(duration: 0.2), value: configuration.isPressed)
}
}
// MARK: - Ghost Button Style
struct GhostButtonStyle: ButtonStyle {
@Environment(\.isEnabled) private var isEnabled
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.body.weight(.medium))
.foregroundStyle(isEnabled ? .accentColor : .gray)
.padding(.horizontal, 16)
.padding(.vertical, 10)
.scaleEffect(configuration.isPressed ? 0.95 : 1.0)
.opacity(configuration.isPressed ? 0.7 : 1.0)
.animation(.easeOut(duration: 0.2), value: configuration.isPressed)
}
}
// MARK: - Icon Button Style
struct IconButtonStyle: ButtonStyle {
var size: CGFloat = 44
@Environment(\.isEnabled) private var isEnabled
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.system(size: size * 0.45, weight: .medium))
.foregroundStyle(isEnabled ? .accentColor : .gray)
.frame(width: size, height: size)
.background(Circle().fill(.ultraThinMaterial))
.scaleEffect(configuration.isPressed ? 0.9 : 1.0)
.animation(.easeOut(duration: 0.2), value: configuration.isPressed)
}
}
// MARK: - Pill Button Style
struct PillButtonStyle: ButtonStyle {
@Environment(\.isEnabled) private var isEnabled
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.subheadline.weight(.semibold))
.foregroundStyle(isEnabled ? .white : .gray)
.padding(.horizontal, 20)
.padding(.vertical, 10)
.background(
Capsule()
.fill(isEnabled ? Color.accentColor : .gray.opacity(0.3))
)
.scaleEffect(configuration.isPressed ? 0.95 : 1.0)
.animation(.easeOut(duration: 0.2), value: configuration.isPressed)
}
}
// MARK: - Usage Examples
struct ButtonShowcase: View {
@State private var isLoading = false
var body: some View {
VStack(spacing: 16) {
Button { } label: {
Label("Continue", systemImage: "arrow.right")
}
.buttonStyle(PrimaryButtonStyle())
Button { } label: {
Label("Edit Profile", systemImage: "pencil")
}
.buttonStyle(SecondaryButtonStyle())
Button(role: .destructive) { } label: {
Label("Delete Account", systemImage: "trash")
}
.buttonStyle(DestructiveButtonStyle())
Button("Learn More") { }
.buttonStyle(GhostButtonStyle())
Button { } label: {
Image(systemName: "plus")
}
.buttonStyle(IconButtonStyle())
Button { } label: {
Label("Subscribe", systemImage: "star.fill")
}
.buttonStyle(PillButtonStyle())
// Disabled state
Button("Disabled") { }
.buttonStyle(PrimaryButtonStyle())
.disabled(true)
// Loading state
Button("Saving...") { }
.buttonStyle(PrimaryButtonStyle(isLoading: true))
}
.padding()
}
}
5. Loading, Empty, and Error State Patterns
ViewState Enum
// MARK: - Generic View State
enum ViewState<T> {
case loading
case loaded(T)
case empty
case error(Error)
}
Loading View
// MARK: - Shimmer Modifier
struct ShimmerModifier: ViewModifier {
@State private var phase: CGFloat = 0
func body(content: Content) -> some View {
content
.overlay(
LinearGradient(
colors: [.clear, .white.opacity(0.4), .clear],
startPoint: .leading,
endPoint: .trailing
)
.offset(x: phase)
.mask(content)
)
.onAppear {
withAnimation(.easeInOut(duration: 1.2).repeatForever(autoreverses: false)) {
phase = UIScreen.main.bounds.width
}
}
}
}
extension View {
func shimmer() -> some View {
modifier(ShimmerModifier())
}
}
// MARK: - Skeleton Loading View
struct SkeletonRow: View {
var body: some View {
HStack(spacing: 12) {
RoundedRectangle(cornerRadius: 8)
.fill(.gray.opacity(0.2))
.frame(width: 48, height: 48)
VStack(alignment: .leading, spacing: 8) {
RoundedRectangle(cornerRadius: 4)
.fill(.gray.opacity(0.2))
.frame(height: 14)
.frame(maxWidth: 160)
RoundedRectangle(cornerRadius: 4)
.fill(.gray.opacity(0.2))
.frame(height: 12)
.frame(maxWidth: 100)
}
}
.shimmer()
}
}
// MARK: - Spinner Loading
struct LoadingSpinnerView: View {
var message: String = "Loading..."
var body: some View {
VStack(spacing: 16) {
ProgressView()
.controlSize(.large)
Text(message)
.font(.subheadline)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
Empty State View
// MARK: - Empty State Presets
enum EmptyStatePreset {
case noData
case noSearchResults
case noConnection
case firstTimeUse
var icon: String {
switch self {
case .noData: "tray"
case .noSearchResults: "magnifyingglass"
case .noConnection: "wifi.slash"
case .firstTimeUse: "sparkles"
}
}
var title: String {
switch self {
case .noData: "Nothing Here Yet"
case .noSearchResults: "No Results Found"
case .noConnection: "No Connection"
case .firstTimeUse: "Get Started"
}
}
var message: String {
switch self {
case .noData: "Items you add will appear here."
case .noSearchResults: "Try a different search term."
case .noConnection: "Check your internet connection and try again."
case .firstTimeUse: "Tap the button below to create your first item."
}
}
}
struct EmptyStateView: View {
var preset: EmptyStatePreset
var actionTitle: String?
var action: (() -> Void)?
var body: some View {
ContentUnavailableView {
Label(preset.title, systemImage: preset.icon)
} description: {
Text(preset.message)
} actions: {
if let actionTitle, let action {
Button(actionTitle, action: action)
.buttonStyle(.borderedProminent)
}
}
}
}
Error State View
// MARK: - Error State View
struct ErrorStateView: View {
let error: Error
var retryAction: (() -> Void)?
var reportAction: (() -> Void)?
var body: some View {
ContentUnavailableView {
Label("Something Went Wrong", systemImage: "exclamationmark.triangle")
} description: {
Text(error.localizedDescription)
} actions: {
VStack(spacing: 12) {
if let retryAction {
Button("Try Again", action: retryAction)
.buttonStyle(.borderedProminent)
}
if let reportAction {
Button("Report Issue", action: reportAction)
.font(.footnote)
}
}
}
}
}
AsyncContentView Wrapper
// MARK: - Async Content View
struct AsyncContentView<T, LoadedContent: View>: View {
@Binding var state: ViewState<T>
var loadingMessage: String = "Loading..."
var emptyPreset: EmptyStatePreset = .noData
var emptyAction: (() -> Void)?
var retryAction: (() -> Void)?
@ViewBuilder var content: (T) -> LoadedContent
var body: some View {
switch state {
case .loading:
LoadingSpinnerView(message: loadingMessage)
.transition(.opacity)
case .loaded(let data):
content(data)
.transition(.opacity)
case .empty:
EmptyStateView(
preset: emptyPreset,
actionTitle: emptyAction != nil ? "Add Item" : nil,
action: emptyAction
)
.transition(.opacity)
case .error(let error):
ErrorStateView(error: error, retryAction: retryAction)
.transition(.opacity)
}
}
}
// MARK: - Usage with Pull-to-Refresh
struct ItemListView: View {
@State private var state: ViewState<[Item]> = .loading
var body: some View {
AsyncContentView(
state: $state,
emptyPreset: .noData,
retryAction: { Task { await loadItems() } }
) { items in
List(items) { item in
Text(item.name)
}
.refreshable {
await loadItems()
}
}
.animation(.easeInOut(duration: 0.3), value: stateKey)
.task { await loadItems() }
}
private var stateKey: String {
switch state {
case .loading: "loading"
case .loaded: "loaded"
case .empty: "empty"
case .error: "error"
}
}
private func loadItems() async {
state = .loading
do {
let items = try await ItemService.fetchAll()
state = items.isEmpty ? .empty : .loaded(items)
} catch {
state = .error(error)
}
}
}
6. Localization Approach
String Catalogs Setup
All user-facing strings must use
String(localized:)
. Never hardcode display text.
Xcode creates a
Localizable.xcstrings
file (String Catalog) that automatically extracts strings.
// MARK: - Correct: Localized Strings
let title = String(localized: "welcome.title")
let message = String(localized: "welcome.message")
// With default value
let greeting = String(localized: "greeting", defaultValue: "Hello there!")
// MARK: - String Interpolation
let itemCount = 5
let label = String(localized: "\(itemCount) items remaining")
// MARK: - Pluralization (handled in .xcstrings catalog)
// In the String Catalog, define plural variants:
// "item_count" -> one: "%lld item", other: "%lld items"
let countLabel = String(localized: "\(itemCount) items")
// MARK: - Table-based organization
let settingsTitle = String(localized: "title", table: "Settings")
Date and Number Formatting
// MARK: - Locale-Aware Formatting
struct FormattingExamples: View {
let price: Decimal = 49.99
let eventDate = Date()
let progress = 0.756
var body: some View {
VStack(alignment: .leading) {
// Currency — adapts to user locale
Text(price, format: .currency(code: "USD"))
// Date — adapts to locale conventions
Text(eventDate, format: .dateTime.month(.wide).day().year())
// Relative date
Text(eventDate, format: .relative(presentation: .named))
// Percentage
Text(progress, format: .percent.precision(.fractionLength(1)))
// Measurement
Text(Measurement(value: 72, unit: UnitTemperature.fahrenheit),
format: .measurement(width: .abbreviated))
}
}
}
RTL and Layout Considerations
// MARK: - RTL-Safe Layout
struct RTLSafeView: View {
@Environment(\.layoutDirection) var layoutDirection
var body: some View {
HStack {
// Use .leading/.trailing, never .left/.right
Image(systemName: "arrow.forward")
.flipsForRightToLeftLayoutDirection(true)
Text(String(localized: "next"))
}
.frame(maxWidth: .infinity, alignment: .leading) // Flips automatically in RTL
}
}
7. Privacy Manifest
PrivacyInfo.xcprivacy Structure
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Tracking declaration -->
<key>NSPrivacyTracking</key>
<false/>
<key>NSPrivacyTrackingDomains</key>
<array/>
<!-- Required Reason APIs -->
<key>NSPrivacyAccessedAPITypes</key>
<array>
<!-- File timestamp APIs -->
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>C617.1</string> <!-- Access within app container -->
</array>
</dict>
<!-- System boot time APIs -->
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategorySystemBootTime</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>35F9.1</string> <!-- Measure elapsed time -->
</array>
</dict>
<!-- Disk space APIs -->
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryDiskSpace</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>E174.1</string> <!-- Check available disk space -->
</array>
</dict>
<!-- User defaults APIs -->
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>CA92.1</string> <!-- Access within app -->
</array>
</dict>
</array>
<!-- Collected data types -->
<key>NSPrivacyCollectedDataTypes</key>
<array>
<dict>
<key>NSPrivacyCollectedDataType</key>
<string>NSPrivacyCollectedDataTypeCrashData</string>
<key>NSPrivacyCollectedDataTypeLinked</key>
<false/>
<key>NSPrivacyCollectedDataTypeTracking</key>
<false/>
<key>NSPrivacyCollectedDataTypePurposes</key>
<array>
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
</array>
</dict>
</array>
</dict>
</plist>
Required Reason API Categories
Category
Common APIs
Typical Reason Code
File Timestamp
NSFileCreationDate, NSFileModificationDate, NSURLContentModificationDateKey
C617.1 (app container), DDA9.1 (user-presented)
System Boot Time
systemUptime, ProcessInfo.processInfo.systemUptime
35F9.1 (measure elapsed time)
Disk Space
volumeAvailableCapacityKey, volumeAvailableCapacityForImportantUsageKey
E174.1 (check space before write)
User Defaults
UserDefaults
CA92.1 (access within app)
Active Keyboard
activeInputModes
3EC4.1 (customize UI)
Third-Party SDK Manifests
Each third-party SDK must include its own
PrivacyInfo.xcprivacy
. Verify during dependency audits:
// In Package.swift or Podfile, verify SDK authors ship privacy manifests.
// Xcode aggregates all manifests during App Store submission.
// Run: Product > Generate Privacy Report to audit before submission.
8. Device Support and Adaptive Layout
Size Class Detection
// MARK: - Adaptive Layout with Size Classes
struct AdaptiveView: View {
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@Environment(\.verticalSizeClass) private var verticalSizeClass
var body: some View {
if horizontalSizeClass == .regular {
// iPad or iPhone landscape wide
HStack(spacing: 0) {
SidebarView()
.frame(width: 320)
DetailView()
}
} else {
// iPhone portrait
NavigationStack {
ListView()
}
}
}
}
NavigationSplitView for iPad Sidebar
// MARK: - Navigation Split View
struct SplitLayoutView: View {
@State private var selectedItem: Item?
@State private var columnVisibility = NavigationSplitViewVisibility.automatic
var body: some View {
NavigationSplitView(columnVisibility: $columnVisibility) {
List(items, selection: $selectedItem) { item in
NavigationLink(value: item) {
ItemRow(item: item)
}
}
.navigationTitle("Items")
} detail: {
if let selectedItem {
ItemDetailView(item: selectedItem)
} else {
ContentUnavailableView("Select an Item",
systemImage: "sidebar.left",
description: Text("Choose an item from the sidebar."))
}
}
}
}
ViewThatFits
// MARK: - ViewThatFits for Adaptive Components
struct AdaptiveActionBar: View {
var body: some View {
ViewThatFits(in: .horizontal) {
// First choice: full horizontal layout
HStack(spacing: 16) {
Button("Save Draft") { }
.buttonStyle(SecondaryButtonStyle())
Button("Preview") { }
.buttonStyle(SecondaryButtonStyle())
Button("Publish") { }
.buttonStyle(PrimaryButtonStyle())
}
// Fallback: stacked layout
VStack(spacing: 8) {
Button("Publish") { }
.buttonStyle(PrimaryButtonStyle())
HStack(spacing: 8) {
Button("Save Draft") { }
.buttonStyle(SecondaryButtonStyle())
Button("Preview") { }
.buttonStyle(SecondaryButtonStyle())
}
}
}
.padding()
}
}
iPad-Specific Features
// MARK: - Pointer Hover Effect (iPad)
struct HoverableCard: View {
@State private var isHovered = false
var body: some View {
RoundedRectangle(cornerRadius: 12)
.fill(.background)
.shadow(radius: isHovered ? 8 : 2)
.scaleEffect(isHovered ? 1.02 : 1.0)
.onHover { hovering in
withAnimation(.easeOut(duration: 0.2)) {
isHovered = hovering
}
}
.hoverEffect(.lift) // System pointer lift effect
}
}
// MARK: - Keyboard Shortcuts (iPad with hardware keyboard)
struct ShortcutView: View {
var body: some View {
VStack {
Text("Press Cmd+N to create")
}
.keyboardShortcut("n", modifiers: .command)
}
}
9. Preview Provider Standards
Modern Previews (iOS 17+)
// MARK: - Basic Preview
#Preview {
ContentView()
}
// MARK: - Named Preview
#Preview("Dark Mode") {
ContentView()
.preferredColorScheme(.dark)
}
// MARK: - Light and Dark Side by Side
#Preview("Color Schemes") {
VStack {
ContentView()
.preferredColorScheme(.light)
ContentView()
.preferredColorScheme(.dark)
}
}
// MARK: - Dynamic Type Sizes
#Preview("Large Text") {
ContentView()
.dynamicTypeSize(.xxxLarge)
}
#Preview("Accessibility Sizes") {
ContentView()
.dynamicTypeSize(.accessibility3)
}
// MARK: - Device Variations
#Preview("iPhone SE", traits: .fixedLayout(width: 375, height: 667)) {
ContentView()
}
#Preview("iPad", traits: .fixedLayout(width: 1024, height: 768)) {
ContentView()
}
// MARK: - Size That Fits
#Preview("Component", traits: .sizeThatFitsLayout) {
PillButtonExample()
.padding()
}
Interactive Previews
// MARK: - Interactive Preview with @Previewable
#Preview("Toggle Demo") {
@Previewable @State var isOn = false
Toggle("Notifications", isOn: $isOn)
.padding()
}
#Preview("Counter") {
@Previewable @State var count = 0
VStack {
Text("Count: \(count)")
.font(.largeTitle)
Button("Increment") { count += 1 }
.buttonStyle(PrimaryButtonStyle())
}
.padding()
}
Preview with Mock Data
// MARK: - Preview with Mock Data
struct Item: Identifiable {
let id: UUID
let name: String
let subtitle: String
static let samples: [Item] = [
Item(id: UUID(), name: "Morning Run", subtitle: "5.2 km"),
Item(id: UUID(), name: "Yoga Session", subtitle: "45 min"),
Item(id: UUID(), name: "Cycling", subtitle: "12.8 km"),
]
}
#Preview {
List(Item.samples) { item in
VStack(alignment: .leading) {
Text(item.name).font(.headline)
Text(item.subtitle).font(.caption).foregroundStyle(.secondary)
}
}
}
SwiftData Preview Container
// MARK: - SwiftData Preview Container
struct PreviewContainer {
static var shared: ModelContainer {
let config = ModelConfiguration(isStoredInMemoryOnly: true)
let container = try! ModelContainer(
for: Task.self,
configurations: config
)
// Insert sample data
let context = container.mainContext
for task in Task.sampleTasks {
context.insert(task)
}
return container
}
}
#Preview {
TaskListView()
.modelContainer(PreviewContainer.shared)
}
Legacy PreviewProvider (iOS 16 and Below)
// MARK: - Legacy PreviewProvider
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
Group {
ContentView()
.previewDisplayName("Light")
ContentView()
.preferredColorScheme(.dark)
.previewDisplayName("Dark")
ContentView()
.dynamicTypeSize(.xxxLarge)
.previewDisplayName("Large Text")
}
}
}
Quick Reference Summary
Area
Key Rule
Micro interaction
0.2s .easeOut
Navigation
0.35s .spring(response: 0.35, dampingFraction: 0.85)
Dismissal
0.25s .easeIn
Haptic on success
.success via SensoryFeedback
Haptic on toggle
.selection
SF Symbol weight
.medium
SF Symbol inline size
17pt
Button press scale
0.97
Strings
Always String(localized:)
Layout direction
.leading/.trailing, never .left/.right
Privacy
Ship PrivacyInfo.xcprivacy with every target
Previews
Use #Preview macro, test light/dark/large text
---
# Adopting Liquid Glass
https://nagarjuna2997.github.io/ios-agent-skill/guides/design-liquid-glass-adoption.html
← Back to all frameworks & guides
All articles →Design and Motion · Reference guideAdopting Liquid GlassRepository guidance for Liquid Glass. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
1. The decision you make before writing any code
2. What changes with no code, and what you must audit
3. Scroll edge effect
4. Navigation
5. Toolbars and menus
6. Controls and shape
7. Lists, forms, and a silent text change
8. App icons
9. Test matrix
Anti-Patterns
Checklist
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Locate navigation surfaces
↓2
Check SDK availability
↓3
Apply supported materials
↓4
Review legibility
02 / ArchitectureResponsibility boundariesBoundary 1
Content layerBoundary 2
Navigation chromeBoundary 3
Accessibility settingsConnected responsibilities, not a required class hierarchy or an execution trace.
Load this when:
rebuilding an existing app against the iOS 26 SDK or later,
auditing an interface after the rebuild, or deciding whether to take the new
look at all.
This is the migration half.
For applying the material to a custom view —
glassEffect
,
GlassEffectContainer
, the availability guard, the fallback —
see
docs/design/design-tokens.md
§4. That document is about opting
in
on a
view you own. This one is about what happens to the app you already shipped.
1. The decision you make before writing any code
Rebuilding against the iOS 26 SDK
adopts the new design automatically
.
Standard components from SwiftUI, UIKit, and AppKit pick up Liquid Glass with no
code change. That is the whole point, and it is also the risk: an app you have
not re-audited ships a changed interface.
There is one escape hatch:
<!-- Info.plist -->
<key>UIDesignRequiresCompatibility</key>
<true/>
This keeps the app looking as it did when built against the previous SDK, while
still letting you build with the current one.
Treat it as a stopgap, not a decision.
It buys a release cycle to do the
audit properly; it does not remove the work. An app frozen on the compatibility
key looks progressively more dated as the rest of the system moves, and Apple
has historically retired these keys. Set a date to remove it.
Situation
Do this
You can audit the interface this cycle
Rebuild, adopt, work through §2–§7
You must ship urgently and cannot audit
UIDesignRequiresCompatibility, with a tracked removal date
You are on the previous SDK entirely
Nothing yet — but the audit list still tells you what to expect
2. What changes with no code, and what you must audit
The rule that decides most of the work:
the system now owns the background of
controls and navigation.
Custom backgrounds you added to those elements do not
merely look dated — they sit on top of Liquid Glass and the scroll edge effect
and interfere with both.
Audit and, in most cases, delete custom backgrounds on:
NavigationStack
,
NavigationSplitView
, and split-view columns
toolbars (
toolbar(content:)
) and title bars
tab bars
sheets and popovers — including any
visualEffect
view you put behind popover
content, which is now duplicated work
// WRONG — a hand-rolled bar background, from before the system provided one.
// It overlays Liquid Glass and defeats the scroll edge effect, so content
// scrolling underneath loses the contrast the system would have given it.
.toolbarBackground(Color.appSurface, for: .navigationBar)
.toolbarBackground(.visible, for: .navigationBar)
// RIGHT — let the system decide. It adapts to overlap, focus, and scroll
// position in ways a static color cannot.
// (no modifier)
Do not hard-code control metrics.
Controls adopt rounder, larger forms, and
an extra-large size option. Standard controls resize themselves; a control with
a pinned
.frame(width:height:)
will not, and will sit wrong next to the ones
that did.
3. Scroll edge effect
Scroll views obscure content passing beneath bars so controls stay legible.
System bars get this for free.
A custom bar does not
— it gets content
sliding under it at full contrast.
// A custom bottom bar. Register it so the system applies the scroll edge
// effect, rather than leaving your controls to fight the content behind them.
ScrollView {
ArticleList(articles: model.articles)
}
.safeAreaBar(edge: .bottom) {
PlaybackControls(model: model)
}
Use
scrollEdgeEffectStyle(_:for:)
when you need to choose the style rather
than accept the default.
4. Navigation
Navigation is the layer Liquid Glass lives in, so the separation between
navigation and content has to be real. If your content and your chrome are
interleaved, the new material has nothing coherent to float above.
// Tab bar that becomes a sidebar where there is room for one.
TabView {
Tab("Library", systemImage: "books.vertical") { LibraryScreen() }
Tab("Browse", systemImage: "square.grid.2x2") { BrowseScreen() }
// The SEARCH ROLE, not a tab that happens to contain search. The system
// pulls it to the trailing end and styles it as search — matching where
// users have learned to look for it in every other app.
Tab(role: .search) { SearchScreen() }
}
.tabViewStyle(.sidebarAdaptable)
// Let the tab bar recede while reading, and come back on the reverse scroll.
.tabBarMinimizeBehavior(.onScrollDown)
Background extension
makes a hero image read as continuing beneath a
sidebar or inspector, without actually placing content under it — the system
mirrors and blurs the adjacent content so the sidebar stays legible:
NavigationSplitView {
LibrarySidebar(selection: $selection)
} detail: {
ScrollView {
HeroImage(article: article)
.backgroundExtensionEffect()
ArticleBody(article: article)
}
}
After adding either,
check the safe areas of the content beside the sidebar
and inspector
— that is where "peeking through" either works or clips.
5. Toolbars and menus
Toolbar items now group, and the grouping is meaningful: items sharing a
background read as related.
.toolbar {
ToolbarItemGroup(placement: .primaryAction) {
Button("Bookmark", systemImage: "bookmark") { model.bookmark() }
Button("Share", systemImage: "square.and.arrow.up") { model.share() }
}
// Separates groups that must not read as one control.
ToolbarSpacer(.fixed, placement: .primaryAction)
ToolbarItem(placement: .primaryAction) {
Button("Delete", systemImage: "trash", role: .destructive) { model.delete() }
}
}
Three rules that are easy to get wrong:
Do not mix text and icons inside one group.
Across items sharing a
background it reads as an inconsistency, not a distinction.
Every icon-only item needs an accessibility label
, regardless of what is
on screen. Someone using VoiceOver or Voice Control gets nothing from the
glyph.
review_swiftui
and
audit_app_store_readiness
both flag this.
Hide the item, not its content.
A toolbar item whose
view
is hidden
leaves an empty slot the system still lays out.
// WRONG — an empty toolbar item the system reserves space for.
ToolbarItem { if canEdit { EditButton() } }
// RIGHT — the item itself goes away.
ToolbarItem { EditButton() }
.hidden(!canEdit)
6. Controls and shape
Reach for the built-in glass button styles before building anything custom:
Button("Continue") { model.advance() }
.buttonStyle(.glass)
Button("Buy Now") { model.purchase() }
.buttonStyle(.glassProminent)
Nested shapes should be
concentric
with their container — the hardware's
corner radius informs the whole chain of rounded elements:
// Concentric with whatever contains it, rather than a guessed radius that
// looks subtly wrong at one screen size and badly wrong at another.
CardContent(article: article)
.clipShape(ConcentricRectangle())
Be sparing with colour on controls and navigation.
Colour on a glass surface
costs legibility. When you do use it, use a system colour or a custom one with
light, dark, and increased-contrast variants — the same rule as every other
token in
docs/design/design-tokens.md
.
7. Lists, forms, and a silent text change
Rows and sections gained height, padding, and corner radius. The change that
bites is quieter:
Section headers are now title-case, not upper-case.
The system no longer
force-capitalises them. A header you wrote as
"recently played"
— relying on
the old behaviour to render
RECENTLY PLAYED
— now renders exactly as written.
// WRONG — depended on the system shouting it for you.
Section("recently played") { … }
// RIGHT — write the capitalisation you want to see.
Section("Recently Played") { … }
Grep for lowercase
Section(
string literals. Nothing warns about this; it just
ships.
8. App icons
Icons are now layered, and the system applies reflection, refraction, shadow,
blur, and highlights across light, dark, clear, and tinted variants.
Separate your artwork into foreground / middle / background layers.
Do not bake in effects.
Shadows and blurs you paint yourself get composited
on top of the system's, and the result is muddy.
Prefer solid, filled, overlapping semi-transparent shapes over fine detail.
Compose in
Icon Composer
(ships with Xcode; also on Apple Design
Resources), which previews the system effects and appearance variants.
Keep elements centred — the system masks to a rounded rectangle on
iOS/iPadOS/macOS and a circle on watchOS. An irregular icon gets a
system-provided background.
9. Test matrix
Liquid Glass adapts to user settings, and those settings remove or change the
effects you designed around. Standard components adapt on their own;
anything
custom is yours to verify.
Setting
What to check
Reduce Transparency
Custom glass surfaces stay legible when the blur is gone
Reduce Motion
Morphing and fluid transitions degrade to something sensible
Increase Contrast
Custom colours still meet contrast against a glass background
Dark mode
Every custom surface and colour pairing
Accessibility text sizes
Controls and bars reflow rather than clip
The user's Liquid Glass appearance preference
Custom elements follow it
Per platform:
watchOS
— changes are minimal and appear even without rebuilding. Adopt
the watchOS 10 toolbar APIs and standard button styles to pick them up.
tvOS
— controls take on glass
when focused
. Adopt the standard focus
APIs (
focusable(_:)
,
isFocused
) so custom controls match. Only Apple TV 4K
(2nd generation) and newer render the effects; older devices keep the current
appearance, which is a fallback you do not have to write.
iPadOS
— windows resize continuously to a minimum size rather than
snapping between presets. See
docs/tooling/device-hub.md
; rebuilding against
the iOS 27 SDK also opts you into resizability.
Anti-Patterns
// WRONG — glass on every custom control in the app.
// The material exists to draw attention to content. Applied everywhere it
// competes with the content and flattens the hierarchy it was meant to create.
ForEach(filters) { filter in
FilterChip(filter).glassEffect()
}
// RIGHT — reserve it for the few genuinely functional elements.
// WRONG — separate glass effects stacked next to each other.
// Each is its own render pass, and they will not morph into one another.
HStack {
BackButton().glassEffect()
PlayButton().glassEffect()
}
// RIGHT — one container, so they blend and merge.
GlassEffectContainer(spacing: Space.tight) {
HStack {
BackButton().glassEffect().glassEffectID("back", in: namespace)
PlayButton().glassEffect().glassEffectID("play", in: namespace)
}
}
// WRONG — an action sheet with no source.
// It now originates from the control that triggered it. With no anchor it
// appears detached from the thing it acts on.
.confirmationDialog("Delete?", isPresented: $isConfirming) { … }
// RIGHT — anchor it to its source so the relationship is visible.
.confirmationDialog("Delete?", isPresented: $isConfirming, presenting: item) { … }
// WRONG — shipping UIDesignRequiresCompatibility with no removal plan.
// It is a deferral. Left in place the app drifts further from the system every
// release, and the audit it postponed only grows.
// RIGHT — set it, file the work, remove it next cycle.
// WRONG — assuming every adoption API shares one availability floor.
// Liquid Glass arrived in iOS 26, but these APIs did not all land together.
// A blanket #available(iOS 26, *) around a symbol introduced later fails to
// compile; one at iOS 27 around an iOS 26 symbol silently drops every iOS 26
// device to the fallback — the mistake this skill flags most often.
if #available(iOS 26, *) { /* every new API */ }
// RIGHT — check each symbol's own floor in Xcode's documentation and guard on
// THAT version. `check_availability_guards` catches the over-restrictive case.
Checklist
[ ] Rebuilt against the current SDK and reviewed every screen
[ ]
UIDesignRequiresCompatibility
either absent, or present with a removal date
[ ] Custom backgrounds removed from bars, split views, sheets, popovers
[ ] No hard-coded control metrics
[ ] Custom bars registered for the scroll edge effect (
safeAreaBar
)
[ ] Navigation clearly separated from content
[ ] Search uses
Tab(role: .search)
, not an ordinary tab
[ ] Toolbar items grouped meaningfully;
ToolbarSpacer
between unrelated groups
[ ] No group mixing text and icon items
[ ] Every icon-only control has an accessibility label
[ ] Hidden toolbar items hide the item, not the view
[ ] Section headers written in the capitalisation you want to see
[ ] Nested shapes concentric with their containers
[ ] Glass reserved for the few most important elements
[ ] Multiple glass elements share one
GlassEffectContainer
[ ] App icon rebuilt as layers in Icon Composer, with no baked-in effects
[ ] Verified under Reduce Transparency, Reduce Motion, Increase Contrast, dark
mode, and accessibility text sizes
[ ] Each new API guarded on
its own
introduction version
---
# Professional UI/UX System
https://nagarjuna2997.github.io/ios-agent-skill/guides/design-README.html
← Back to all frameworks & guides
All articles →Design · Reference guideProfessional UI/UX SystemRepository guidance for Professional UI/UX System. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
Context
Design Stack
Review Order
Pattern
Anti-Patterns
Production Checklist
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Define the product task
↓2
Choose semantic tokens
↓3
Compose adaptive screens
↓4
Review accessibility
02 / ArchitectureResponsibility boundariesBoundary 1
Product intentBoundary 2
Design systemBoundary 3
SwiftUI surfacesConnected responsibilities, not a required class hierarchy or an execution trace.
Context
Use this hub when a user asks for professional polish, better hierarchy, premium design, improved onboarding, stronger empty/loading/error states, better iPad adaptation, or a screen that feels native instead of merely functional.
Design Stack
Layer
Use
design-tokens.md
Source of truth for color, spacing, radius, typography, shadows
typography-system.md
Semantic text hierarchy and Dynamic Type
color-system.md
Contrast, semantic color roles, dark mode
interaction-standards.md
Touch targets, gestures, haptics, animation purpose
liquid-glass-adoption.md
Modern materials without contrast loss
stunning-ui-patterns.md
Full-screen composition patterns
Review Order
Primary task: can the user see what to do in two seconds?
Hierarchy: do size, weight, color, and position match importance?
Layout: are alignment, rhythm, gutters, and touch targets consistent?
Type: semantic styles first; fixed sizes only with a clear reason.
State: loading, empty, error, success, offline, disabled.
Adaptation: compact/regular widths, iPad resizability, keyboard, pointer.
Accessibility: VoiceOver labels, focus order, Dynamic Type, contrast, Reduce Motion.
Pattern
Prefer a compact state model over scattered booleans:
enum ScreenState<Content: Equatable>: Equatable {
case loading
case empty
case failed(message: String)
case loaded(Content)
}
Each state gets a designed view that keeps the same layout frame when possible. Loading should not resize the final surface; empty states should name the next action; errors should explain recovery.
Anti-Patterns
// WRONG: "premium" means adding gradients and cards everywhere.
Why: polish comes from hierarchy, restraint, rhythm, and state quality.
// RIGHT: define the primary task, reduce competing emphasis, then add motion/material only where it explains state.
// WRONG: a phone layout stretched full-width on iPad.
Why: density and reading length break.
// RIGHT: use navigation split, max readable width, sidebars, inspector panes, or two-column layouts.
Production Checklist
[ ] Primary action is visually dominant and reachable.
[ ] Every repeated spacing/color/type value comes from a token.
[ ] Text wraps at accessibility sizes.
[ ] Tap targets are at least 44x44pt.
[ ] Dark mode and contrast are checked.
[ ] Loading, empty, error, success, and offline states exist where relevant.
[ ] iPad layout is not a stretched phone screen.
[ ] Motion and haptics clarify state, not decoration.
---
# Stunning UI Patterns -- Complete SwiftUI Pattern Library
https://nagarjuna2997.github.io/ios-agent-skill/guides/design-stunning-ui-patterns.html
← Back to all frameworks & guides
All articles →Design and Motion · Reference guideStunning UI Patterns -- Complete SwiftUI Pattern LibraryRepository guidance for Human Interface Guidelines. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
Overview
1. Glass Morphism Card
2. Neumorphic Card
3. Gradient Card with Floating Shadow
4. Animated Onboarding Screen
5. Hero Image Header with Parallax Scroll
6. Bottom Sheet with Snap Points
7. Animated Tab Bar
8. Profile Card
9. Dashboard Cards with Charts
10. Floating Action Button
11. Custom Toggle with Animation
12. Swipeable Card Stack
13. Pull-to-Refresh with Custom Animation
14. Skeleton Loading / Shimmer Effect
15. Toast / Snackbar Notification
16. Expandable Card with matchedGeometryEffect
17. Animated Gradient Background
18. Blurred Header that Changes on Scroll
19. Chip / Tag Flow Layout
20. Rating Stars Component
Bonus: Combining Patterns -- Premium App Screen
Quick Reference
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Choose information hierarchy
↓2
Compose layout and spacing
↓3
Add meaningful motion
↓4
Review all screen states
02 / ArchitectureResponsibility boundariesBoundary 1
Content hierarchyBoundary 2
Reusable componentsBoundary 3
Screen statesConnected responsibilities, not a required class hierarchy or an execution trace.
Overview
This file is a production-ready pattern library of 20 stunning UI components. Every example compiles, uses beautiful colors from the color palettes defined in
color-system.md
, and produces results worthy of a premium App Store feature. Copy, adapt, and compose these into world-class iOS applications.
All examples assume the
Color(hex:)
extension from
color-system.md
is available.
1. Glass Morphism Card
Frosted glass with a luminous border. Use over images or gradients for maximum effect.
import SwiftUI
struct GlassMorphismCard: View {
var body: some View {
ZStack {
// Rich background
LinearGradient(
colors: [Color(hex: "6C63FF"), Color(hex: "EC4899"), Color(hex: "06B6D4")],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.ignoresSafeArea()
VStack(alignment: .leading, spacing: 12) {
HStack {
Image(systemName: "sparkles")
.font(.title2)
.foregroundStyle(.white)
Spacer()
Text("PRO")
.font(.caption.weight(.bold))
.kerning(1.5)
.foregroundStyle(.white)
.padding(.horizontal, 10)
.padding(.vertical, 4)
.background(.white.opacity(0.2), in: Capsule())
}
Text("Premium Plan")
.font(.title2.weight(.bold))
.foregroundStyle(.white)
Text("Unlock all features and get early access to new content every week.")
.font(.subheadline)
.foregroundStyle(.white.opacity(0.8))
.lineSpacing(4)
HStack {
Text("$9.99/mo")
.font(.title3.weight(.bold))
.foregroundStyle(.white)
Spacer()
Text("Subscribe")
.font(.subheadline.weight(.semibold))
.foregroundStyle(Color(hex: "6C63FF"))
.padding(.horizontal, 20)
.padding(.vertical, 10)
.background(.white, in: Capsule())
}
.padding(.top, 8)
}
.padding(24)
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 24))
.overlay(
RoundedRectangle(cornerRadius: 24)
.stroke(
LinearGradient(
colors: [.white.opacity(0.5), .white.opacity(0.05)],
startPoint: .topLeading,
endPoint: .bottomTrailing
),
lineWidth: 1
)
)
.shadow(color: .black.opacity(0.15), radius: 20, y: 10)
.padding(24)
}
}
}
2. Neumorphic Card
Soft shadows creating the illusion of raised or inset elements on a flat surface.
struct NeumorphicCard: View {
@State private var isPressed = false
var body: some View {
let bgColor = Color(hex: "E8EDF2")
ZStack {
bgColor.ignoresSafeArea()
VStack(spacing: 32) {
// Raised card
VStack(alignment: .leading, spacing: 8) {
Image(systemName: "wifi")
.font(.title)
.foregroundStyle(Color(hex: "6C63FF"))
Text("Network Status")
.font(.headline)
Text("Connected -- 120 Mbps")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(24)
.background(bgColor)
.cornerRadius(20)
.shadow(color: .white.opacity(0.7), radius: 10, x: -5, y: -5)
.shadow(color: .black.opacity(0.15), radius: 10, x: 5, y: 5)
// Inset / pressed style
VStack(alignment: .leading, spacing: 8) {
Image(systemName: "bolt.fill")
.font(.title)
.foregroundStyle(Color(hex: "F59E0B"))
Text("Quick Actions")
.font(.headline)
Text("Tap to toggle")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(24)
.background(
RoundedRectangle(cornerRadius: 20)
.fill(bgColor)
.overlay(
RoundedRectangle(cornerRadius: 20)
.stroke(Color.black.opacity(0.05), lineWidth: 1)
)
.innerShadow(bgColor)
)
// Neumorphic button
Button {
withAnimation(.spring(response: 0.3)) {
isPressed.toggle()
}
} label: {
Image(systemName: "power")
.font(.title)
.foregroundStyle(isPressed ? Color(hex: "2D9F6F") : .gray)
.frame(width: 80, height: 80)
.background(bgColor)
.cornerRadius(40)
.shadow(
color: isPressed ? .clear : .white.opacity(0.7),
radius: isPressed ? 0 : 8, x: -4, y: -4
)
.shadow(
color: isPressed ? .clear : .black.opacity(0.15),
radius: isPressed ? 0 : 8, x: 4, y: 4
)
.overlay(
RoundedRectangle(cornerRadius: 40)
.stroke(Color.black.opacity(isPressed ? 0.08 : 0), lineWidth: 1)
)
}
}
.padding(24)
}
}
}
// Helper modifier for inner shadow
extension View {
func innerShadow(_ bgColor: Color) -> some View {
self.overlay(
RoundedRectangle(cornerRadius: 20)
.stroke(Color.black.opacity(0.08), lineWidth: 1)
.shadow(color: .black.opacity(0.1), radius: 3, x: 2, y: 2)
.clipShape(RoundedRectangle(cornerRadius: 20))
)
.overlay(
RoundedRectangle(cornerRadius: 20)
.stroke(Color.white.opacity(0.5), lineWidth: 1)
.shadow(color: .white.opacity(0.5), radius: 3, x: -2, y: -2)
.clipShape(RoundedRectangle(cornerRadius: 20))
)
}
}
3. Gradient Card with Floating Shadow
The shadow color matches the card gradient, creating a luminous glow beneath.
struct GradientShadowCard: View {
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Image(systemName: "chart.line.uptrend.xyaxis")
.font(.title2)
Text("Revenue")
.font(.headline)
Spacer()
Text("+24.5%")
.font(.subheadline.weight(.bold))
}
.foregroundStyle(.white)
Text("$48,290")
.font(.system(size: 36, weight: .bold, design: .rounded))
.foregroundStyle(.white)
Text("Compared to $38,800 last month")
.font(.caption)
.foregroundStyle(.white.opacity(0.7))
}
.padding(24)
.background(
LinearGradient(
colors: [Color(hex: "6C63FF"), Color(hex: "8B5CF6")],
startPoint: .topLeading,
endPoint: .bottomTrailing
),
in: RoundedRectangle(cornerRadius: 20)
)
// Floating colored shadow
.shadow(color: Color(hex: "6C63FF").opacity(0.4), radius: 20, y: 12)
.padding(24)
}
}
4. Animated Onboarding Screen
TabView with custom animated page indicators and fluid transitions.
struct OnboardingScreen: View {
@State private var currentPage = 0
let pages: [(icon: String, title: String, subtitle: String, colors: [Color])] = [
("sparkles", "Welcome", "Discover a new way to organize your life.", [Color(hex: "6C63FF"), Color(hex: "A78BFA")]),
("bolt.fill", "Lightning Fast", "Everything you need, instantly at your fingertips.", [Color(hex: "EC4899"), Color(hex: "F472B6")]),
("heart.fill", "Made with Love", "Crafted by a team that cares about every detail.", [Color(hex: "2D9F6F"), Color(hex: "22D3EE")]),
]
var body: some View {
ZStack {
// Animated background gradient
LinearGradient(
colors: pages[currentPage].colors,
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.ignoresSafeArea()
.animation(.easeInOut(duration: 0.6), value: currentPage)
VStack(spacing: 0) {
TabView(selection: $currentPage) {
ForEach(0..<pages.count, id: \.self) { index in
VStack(spacing: 24) {
Image(systemName: pages[index].icon)
.font(.system(size: 80))
.foregroundStyle(.white)
.shadow(color: .white.opacity(0.3), radius: 20)
Text(pages[index].title)
.font(.largeTitle.weight(.bold))
.foregroundStyle(.white)
Text(pages[index].subtitle)
.font(.body)
.foregroundStyle(.white.opacity(0.8))
.multilineTextAlignment(.center)
.padding(.horizontal, 40)
}
.tag(index)
}
}
.tabViewStyle(.page(indexDisplayMode: .never))
// Custom page indicator
HStack(spacing: 10) {
ForEach(0..<pages.count, id: \.self) { index in
Capsule()
.fill(.white.opacity(currentPage == index ? 1 : 0.4))
.frame(
width: currentPage == index ? 28 : 8,
height: 8
)
.animation(.spring(response: 0.4, dampingFraction: 0.7), value: currentPage)
}
}
.padding(.bottom, 32)
// CTA button
Button {
if currentPage < pages.count - 1 {
withAnimation { currentPage += 1 }
}
} label: {
Text(currentPage == pages.count - 1 ? "Get Started" : "Continue")
.font(.headline)
.foregroundStyle(pages[currentPage].colors[0])
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
.background(.white, in: RoundedRectangle(cornerRadius: 16))
}
.padding(.horizontal, 24)
.padding(.bottom, 40)
}
}
}
}
5. Hero Image Header with Parallax Scroll
struct ParallaxHeroHeader: View {
@State private var scrollOffset: CGFloat = 0
var body: some View {
ScrollView {
VStack(spacing: 0) {
GeometryReader { geo in
let minY = geo.frame(in: .global).minY
ZStack(alignment: .bottomLeading) {
// Parallax image
Rectangle()
.fill(
LinearGradient(
colors: [Color(hex: "0A2463"), Color(hex: "1E88E5")],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.overlay(
Image(systemName: "mountain.2.fill")
.resizable()
.scaledToFit()
.foregroundStyle(.white.opacity(0.15))
.padding(40)
)
.offset(y: minY > 0 ? -minY * 0.5 : 0)
// Gradient overlay for text legibility
LinearGradient(
colors: [.clear, .black.opacity(0.6)],
startPoint: .top,
endPoint: .bottom
)
// Title content
VStack(alignment: .leading, spacing: 8) {
Text("EXPLORE")
.font(.caption.weight(.bold))
.kerning(2)
.foregroundStyle(.white.opacity(0.7))
Text("Mountains of\nSwiftUI")
.font(.largeTitle.weight(.bold))
.foregroundStyle(.white)
}
.padding(24)
}
.frame(height: max(350 + (minY > 0 ? minY : 0), 350))
.clipped()
}
.frame(height: 350)
// Content below hero
VStack(spacing: 16) {
ForEach(0..<10, id: \.self) { i in
HStack(spacing: 16) {
RoundedRectangle(cornerRadius: 12)
.fill(Color(hex: "1E88E5").opacity(0.1))
.frame(width: 60, height: 60)
.overlay(
Image(systemName: "photo")
.foregroundStyle(Color(hex: "1E88E5"))
)
VStack(alignment: .leading, spacing: 4) {
Text("Article \(i + 1)")
.font(.headline)
Text("A beautiful description of this content piece.")
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer()
}
.padding(16)
.background(Color(.secondarySystemGroupedBackground))
.cornerRadius(16)
}
}
.padding(16)
}
}
.ignoresSafeArea(edges: .top)
}
}
6. Bottom Sheet with Snap Points
struct BottomSheetDemo: View {
@State private var sheetOffset: CGFloat = 500
@GestureState private var dragOffset: CGFloat = 0
private let snapPoints: [CGFloat] = [100, 350, 600]
var body: some View {
ZStack {
Color(hex: "0B0B1A").ignoresSafeArea()
VStack {
Text("Drag the sheet up")
.foregroundStyle(.white)
.font(.headline)
}
// Sheet
VStack(spacing: 0) {
// Handle
Capsule()
.fill(Color(.systemGray3))
.frame(width: 40, height: 5)
.padding(.top, 10)
.padding(.bottom, 16)
// Sheet content
VStack(alignment: .leading, spacing: 16) {
Text("Discover Nearby")
.font(.title2.weight(.bold))
ForEach(0..<5, id: \.self) { i in
HStack(spacing: 12) {
Circle()
.fill(
LinearGradient(
colors: [Color(hex: "8B5CF6"), Color(hex: "EC4899")],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(width: 44, height: 44)
.overlay(
Image(systemName: "mappin")
.foregroundStyle(.white)
)
VStack(alignment: .leading) {
Text("Location \(i + 1)")
.font(.subheadline.weight(.semibold))
Text("\(Double.random(in: 0.1...5.0), specifier: "%.1f") km away")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.foregroundStyle(.tertiary)
}
}
}
.padding(.horizontal, 24)
Spacer()
}
.frame(maxWidth: .infinity)
.background(Color(.systemBackground))
.cornerRadius(24)
.shadow(color: .black.opacity(0.2), radius: 20, y: -5)
.offset(y: sheetOffset + dragOffset)
.gesture(
DragGesture()
.updating($dragOffset) { value, state, _ in
state = value.translation.height
}
.onEnded { value in
let projected = sheetOffset + value.translation.height
let nearest = snapPoints.min(by: {
abs($0 - projected) < abs($1 - projected)
}) ?? snapPoints[1]
withAnimation(.spring(response: 0.4, dampingFraction: 0.8)) {
sheetOffset = nearest
}
}
)
}
}
}
7. Animated Tab Bar
struct AnimatedTabBar: View {
@State private var selectedTab = 0
@Namespace private var tabAnimation
let tabs: [(icon: String, label: String)] = [
("house.fill", "Home"),
("magnifyingglass", "Search"),
("plus.circle.fill", "Add"),
("heart.fill", "Saved"),
("person.fill", "Profile"),
]
var body: some View {
VStack {
Spacer()
Text("Tab \(selectedTab)")
.font(.largeTitle.weight(.bold))
.foregroundStyle(.primary)
Spacer()
// Tab bar
HStack {
ForEach(0..<tabs.count, id: \.self) { index in
Button {
withAnimation(.spring(response: 0.35, dampingFraction: 0.7)) {
selectedTab = index
}
} label: {
VStack(spacing: 4) {
ZStack {
if selectedTab == index {
Capsule()
.fill(Color(hex: "6C63FF").opacity(0.15))
.frame(width: 56, height: 32)
.matchedGeometryEffect(id: "tabBG", in: tabAnimation)
}
Image(systemName: tabs[index].icon)
.font(.system(size: index == 2 ? 28 : 20))
.foregroundStyle(
selectedTab == index
? Color(hex: "6C63FF")
: .gray
)
.scaleEffect(selectedTab == index ? 1.15 : 1.0)
}
.frame(height: 32)
Text(tabs[index].label)
.font(.system(size: 10, weight: .medium))
.foregroundStyle(
selectedTab == index
? Color(hex: "6C63FF")
: .gray
)
}
.frame(maxWidth: .infinity)
}
}
}
.padding(.top, 8)
.padding(.bottom, 4)
.background(
Rectangle()
.fill(.ultraThinMaterial)
.ignoresSafeArea(edges: .bottom)
)
}
}
}
8. Profile Card
struct ProfileCard: View {
var body: some View {
VStack(spacing: 0) {
// Gradient header
ZStack(alignment: .bottom) {
LinearGradient(
colors: [Color(hex: "8B5CF6"), Color(hex: "EC4899")],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.frame(height: 140)
// Avatar
Circle()
.fill(Color(.systemBackground))
.frame(width: 88, height: 88)
.overlay(
Circle()
.fill(
LinearGradient(
colors: [Color(hex: "6C63FF"), Color(hex: "A78BFA")],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(width: 80, height: 80)
.overlay(
Text("JA")
.font(.title.weight(.bold))
.foregroundStyle(.white)
)
)
.offset(y: 44)
}
VStack(spacing: 12) {
Text("Jane Appleseed")
.font(.title3.weight(.bold))
.padding(.top, 48)
Text("Senior iOS Engineer")
.font(.subheadline)
.foregroundStyle(.secondary)
HStack(spacing: 32) {
statItem(value: "234", label: "Posts")
statItem(value: "12.4K", label: "Followers")
statItem(value: "891", label: "Following")
}
.padding(.top, 8)
HStack(spacing: 12) {
Button {} label: {
Text("Follow")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.background(Color(hex: "6C63FF"), in: RoundedRectangle(cornerRadius: 12))
}
Button {} label: {
Text("Message")
.font(.subheadline.weight(.semibold))
.foregroundStyle(Color(hex: "6C63FF"))
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.background(Color(hex: "6C63FF").opacity(0.1), in: RoundedRectangle(cornerRadius: 12))
}
}
.padding(.top, 8)
}
.padding(24)
}
.background(Color(.systemBackground))
.cornerRadius(24)
.shadow(color: .black.opacity(0.1), radius: 16, y: 8)
.padding(20)
}
func statItem(value: String, label: String) -> some View {
VStack(spacing: 2) {
Text(value)
.font(.headline)
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
9. Dashboard Cards with Charts
struct CircularProgressCard: View {
let progress: Double
let title: String
let subtitle: String
let color: Color
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.subheadline.weight(.semibold))
Text(subtitle)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
ZStack {
Circle()
.stroke(color.opacity(0.15), lineWidth: 6)
Circle()
.trim(from: 0, to: progress)
.stroke(color, style: StrokeStyle(lineWidth: 6, lineCap: .round))
.rotationEffect(.degrees(-90))
Text("\(Int(progress * 100))%")
.font(.caption2.weight(.bold))
.foregroundStyle(color)
}
.frame(width: 48, height: 48)
}
// Mini bar chart
HStack(alignment: .bottom, spacing: 4) {
ForEach(0..<7, id: \.self) { _ in
let height = CGFloat.random(in: 12...40)
RoundedRectangle(cornerRadius: 3)
.fill(color.opacity(Double.random(in: 0.3...1.0)))
.frame(height: height)
}
}
.frame(height: 40)
}
.padding(20)
.background(Color(.secondarySystemGroupedBackground))
.cornerRadius(20)
}
}
struct DashboardView: View {
var body: some View {
ScrollView {
LazyVGrid(columns: [
GridItem(.flexible(), spacing: 12),
GridItem(.flexible(), spacing: 12),
], spacing: 12) {
CircularProgressCard(
progress: 0.78,
title: "Steps",
subtitle: "7,800 / 10,000",
color: Color(hex: "2D9F6F")
)
CircularProgressCard(
progress: 0.45,
title: "Calories",
subtitle: "1,350 / 3,000",
color: Color(hex: "FF6B35")
)
CircularProgressCard(
progress: 0.92,
title: "Sleep",
subtitle: "7.4 / 8.0 hrs",
color: Color(hex: "8B5CF6")
)
CircularProgressCard(
progress: 0.60,
title: "Water",
subtitle: "1.8 / 3.0 L",
color: Color(hex: "0A6EBD")
)
}
.padding(16)
}
}
}
10. Floating Action Button
struct FloatingActionButton: View {
@State private var isExpanded = false
let actions: [(icon: String, color: Color, label: String)] = [
("camera.fill", Color(hex: "EC4899"), "Photo"),
("doc.fill", Color(hex: "F59E0B"), "Document"),
("link", Color(hex: "0A6EBD"), "Link"),
]
var body: some View {
ZStack(alignment: .bottomTrailing) {
Color.clear // Takes full space
VStack(spacing: 12) {
if isExpanded {
ForEach(Array(actions.enumerated()), id: \.offset) { index, action in
HStack(spacing: 12) {
Text(action.label)
.font(.subheadline.weight(.medium))
.foregroundStyle(.primary)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(.ultraThinMaterial, in: Capsule())
Button {} label: {
Image(systemName: action.icon)
.font(.system(size: 18))
.foregroundStyle(.white)
.frame(width: 48, height: 48)
.background(action.color, in: Circle())
.shadow(color: action.color.opacity(0.3), radius: 8, y: 4)
}
}
.transition(.asymmetric(
insertion: .scale.combined(with: .opacity).combined(with: .offset(y: 20)),
removal: .scale.combined(with: .opacity)
))
}
}
Button {
withAnimation(.spring(response: 0.35, dampingFraction: 0.7)) {
isExpanded.toggle()
}
} label: {
Image(systemName: "plus")
.font(.title2.weight(.semibold))
.foregroundStyle(.white)
.frame(width: 60, height: 60)
.background(
LinearGradient(
colors: [Color(hex: "6C63FF"), Color(hex: "8B5CF6")],
startPoint: .topLeading,
endPoint: .bottomTrailing
),
in: Circle()
)
.shadow(color: Color(hex: "6C63FF").opacity(0.4), radius: 12, y: 6)
.rotationEffect(.degrees(isExpanded ? 45 : 0))
}
}
.padding(24)
}
}
}
11. Custom Toggle with Animation
struct PremiumToggle: View {
@Binding var isOn: Bool
var body: some View {
Button {
withAnimation(.spring(response: 0.3, dampingFraction: 0.6)) {
isOn.toggle()
}
} label: {
ZStack(alignment: isOn ? .trailing : .leading) {
Capsule()
.fill(
isOn
? LinearGradient(
colors: [Color(hex: "6C63FF"), Color(hex: "8B5CF6")],
startPoint: .leading, endPoint: .trailing
)
: LinearGradient(
colors: [Color(hex: "E2E8F0"), Color(hex: "CBD5E1")],
startPoint: .leading, endPoint: .trailing
)
)
.frame(width: 56, height: 32)
Circle()
.fill(.white)
.frame(width: 26, height: 26)
.shadow(color: .black.opacity(0.15), radius: 4, y: 2)
.padding(3)
}
}
.buttonStyle(.plain)
}
}
struct ToggleShowcase: View {
@State private var darkMode = false
@State private var notifications = true
var body: some View {
VStack(spacing: 16) {
HStack {
Label("Dark Mode", systemImage: "moon.fill")
Spacer()
PremiumToggle(isOn: $darkMode)
}
Divider()
HStack {
Label("Notifications", systemImage: "bell.fill")
Spacer()
PremiumToggle(isOn: $notifications)
}
}
.padding(20)
.background(Color(.secondarySystemGroupedBackground))
.cornerRadius(16)
.padding(20)
}
}
12. Swipeable Card Stack
struct SwipeableCardStack: View {
@State private var cards: [CardData] = [
CardData(title: "Explore Tokyo", color: Color(hex: "6C63FF"), icon: "airplane"),
CardData(title: "Visit Paris", color: Color(hex: "EC4899"), icon: "building.columns.fill"),
CardData(title: "Surf Bali", color: Color(hex: "06B6D4"), icon: "water.waves"),
CardData(title: "Hike Patagonia", color: Color(hex: "2D9F6F"), icon: "mountain.2.fill"),
CardData(title: "Safari Kenya", color: Color(hex: "F59E0B"), icon: "leaf.fill"),
]
struct CardData: Identifiable {
let id = UUID()
let title: String
let color: Color
let icon: String
}
var body: some View {
ZStack {
ForEach(Array(cards.enumerated().reversed()), id: \.element.id) { index, card in
SwipeCard(card: card) {
withAnimation(.spring(response: 0.4, dampingFraction: 0.8)) {
cards.removeAll { $0.id == card.id }
}
}
.scaleEffect(1.0 - CGFloat(index) * 0.04)
.offset(y: CGFloat(index) * 8)
.allowsHitTesting(index == 0)
}
}
.padding(32)
}
}
struct SwipeCard: View {
let card: SwipeableCardStack.CardData
let onRemove: () -> Void
@State private var offset: CGSize = .zero
@State private var rotation: Double = 0
var body: some View {
VStack(spacing: 20) {
Image(systemName: card.icon)
.font(.system(size: 60))
.foregroundStyle(.white)
Text(card.title)
.font(.title.weight(.bold))
.foregroundStyle(.white)
HStack(spacing: 40) {
Image(systemName: "xmark")
.font(.title2.weight(.bold))
.foregroundStyle(.white.opacity(offset.width < -20 ? 1 : 0.3))
Image(systemName: "heart.fill")
.font(.title2.weight(.bold))
.foregroundStyle(.white.opacity(offset.width > 20 ? 1 : 0.3))
}
}
.frame(maxWidth: .infinity)
.frame(height: 400)
.background(
LinearGradient(
colors: [card.color, card.color.opacity(0.7)],
startPoint: .topLeading,
endPoint: .bottomTrailing
),
in: RoundedRectangle(cornerRadius: 24)
)
.shadow(color: card.color.opacity(0.3), radius: 16, y: 8)
.offset(offset)
.rotationEffect(.degrees(rotation))
.gesture(
DragGesture()
.onChanged { value in
offset = value.translation
rotation = Double(value.translation.width / 20)
}
.onEnded { value in
if abs(value.translation.width) > 120 {
withAnimation(.easeOut(duration: 0.3)) {
offset = CGSize(
width: value.translation.width > 0 ? 500 : -500,
height: 0
)
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
onRemove()
}
} else {
withAnimation(.spring(response: 0.4, dampingFraction: 0.6)) {
offset = .zero
rotation = 0
}
}
}
)
}
}
13. Pull-to-Refresh with Custom Animation
struct CustomRefreshView: View {
@State private var items = (1...20).map { "Item \($0)" }
@State private var isRefreshing = false
var body: some View {
NavigationStack {
List {
ForEach(items, id: \.self) { item in
HStack(spacing: 12) {
Circle()
.fill(
LinearGradient(
colors: [Color(hex: "6C63FF"), Color(hex: "A78BFA")],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(width: 40, height: 40)
.overlay(
Image(systemName: "star.fill")
.foregroundStyle(.white)
.font(.caption)
)
Text(item)
.font(.body)
}
.padding(.vertical, 4)
}
}
.refreshable {
isRefreshing = true
try? await Task.sleep(for: .seconds(2))
items.shuffle()
isRefreshing = false
}
.navigationTitle("Feed")
}
}
}
14. Skeleton Loading / Shimmer Effect
struct ShimmerEffect: ViewModifier {
@State private var phase: CGFloat = 0
func body(content: Content) -> some View {
content
.overlay(
LinearGradient(
stops: [
.init(color: .clear, location: phase - 0.2),
.init(color: .white.opacity(0.5), location: phase),
.init(color: .clear, location: phase + 0.2),
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.mask(content)
)
.onAppear {
withAnimation(.linear(duration: 1.5).repeatForever(autoreverses: false)) {
phase = 1.2
}
}
}
}
extension View {
func shimmer() -> some View {
modifier(ShimmerEffect())
}
}
struct SkeletonLoadingCard: View {
var body: some View {
VStack(alignment: .leading, spacing: 12) {
// Avatar + name skeleton
HStack(spacing: 12) {
Circle()
.fill(Color(.systemGray5))
.frame(width: 44, height: 44)
VStack(alignment: .leading, spacing: 6) {
RoundedRectangle(cornerRadius: 4)
.fill(Color(.systemGray5))
.frame(width: 120, height: 14)
RoundedRectangle(cornerRadius: 4)
.fill(Color(.systemGray5))
.frame(width: 80, height: 10)
}
}
// Image placeholder
RoundedRectangle(cornerRadius: 12)
.fill(Color(.systemGray5))
.frame(height: 180)
// Text lines
RoundedRectangle(cornerRadius: 4)
.fill(Color(.systemGray5))
.frame(height: 14)
RoundedRectangle(cornerRadius: 4)
.fill(Color(.systemGray5))
.frame(width: 250, height: 14)
RoundedRectangle(cornerRadius: 4)
.fill(Color(.systemGray5))
.frame(width: 180, height: 14)
}
.padding(16)
.shimmer()
}
}
struct SkeletonDemo: View {
@State private var isLoading = true
var body: some View {
VStack {
if isLoading {
SkeletonLoadingCard()
SkeletonLoadingCard()
} else {
Text("Content loaded!")
.font(.title)
}
}
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
withAnimation { isLoading = false }
}
}
}
}
15. Toast / Snackbar Notification
struct ToastModifier: ViewModifier {
@Binding var isShowing: Bool
let message: String
let icon: String
let color: Color
func body(content: Content) -> some View {
ZStack(alignment: .top) {
content
if isShowing {
HStack(spacing: 12) {
Image(systemName: icon)
.font(.body.weight(.semibold))
.foregroundStyle(.white)
Text(message)
.font(.subheadline.weight(.medium))
.foregroundStyle(.white)
Spacer()
Button {
withAnimation(.spring(response: 0.3)) { isShowing = false }
} label: {
Image(systemName: "xmark")
.font(.caption.weight(.bold))
.foregroundStyle(.white.opacity(0.7))
}
}
.padding(16)
.background(color, in: RoundedRectangle(cornerRadius: 14))
.shadow(color: color.opacity(0.3), radius: 12, y: 6)
.padding(.horizontal, 16)
.padding(.top, 8)
.transition(.move(edge: .top).combined(with: .opacity))
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
withAnimation(.spring(response: 0.3)) { isShowing = false }
}
}
}
}
.animation(.spring(response: 0.4, dampingFraction: 0.8), value: isShowing)
}
}
extension View {
func toast(isShowing: Binding<Bool>, message: String, icon: String = "checkmark.circle.fill", color: Color = Color(hex: "2D9F6F")) -> some View {
modifier(ToastModifier(isShowing: isShowing, message: message, icon: icon, color: color))
}
}
struct ToastDemo: View {
@State private var showSuccess = false
@State private var showError = false
var body: some View {
VStack(spacing: 16) {
Button("Show Success") {
showSuccess = true
}
.buttonStyle(.borderedProminent)
Button("Show Error") {
showError = true
}
.buttonStyle(.bordered)
}
.toast(isShowing: $showSuccess, message: "Saved successfully!")
.toast(isShowing: $showError, message: "Something went wrong.", icon: "exclamationmark.circle.fill", color: Color(hex: "DC2626"))
}
}
16. Expandable Card with matchedGeometryEffect
struct ExpandableCardDemo: View {
@Namespace private var animation
@State private var selectedCard: Int? = nil
let cards = [
(title: "Design", icon: "paintbrush.fill", color: Color(hex: "8B5CF6")),
(title: "Develop", icon: "chevron.left.forwardslash.chevron.right", color: Color(hex: "0A6EBD")),
(title: "Deploy", icon: "rocket.fill", color: Color(hex: "2D9F6F")),
]
var body: some View {
ZStack {
// Grid of collapsed cards
if selectedCard == nil {
VStack(spacing: 12) {
ForEach(0..<cards.count, id: \.self) { index in
let card = cards[index]
HStack(spacing: 16) {
Image(systemName: card.icon)
.font(.title2)
.foregroundStyle(.white)
.frame(width: 48, height: 48)
.background(card.color, in: RoundedRectangle(cornerRadius: 12))
.matchedGeometryEffect(id: "icon\(index)", in: animation)
Text(card.title)
.font(.headline)
.matchedGeometryEffect(id: "title\(index)", in: animation)
Spacer()
Image(systemName: "chevron.right")
.foregroundStyle(.tertiary)
}
.padding(16)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(Color(.secondarySystemGroupedBackground))
.matchedGeometryEffect(id: "bg\(index)", in: animation)
)
.onTapGesture {
withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) {
selectedCard = index
}
}
}
}
.padding(16)
}
// Expanded card
if let selected = selectedCard {
let card = cards[selected]
VStack(spacing: 20) {
HStack {
Button {
withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) {
selectedCard = nil
}
} label: {
Image(systemName: "xmark")
.font(.headline)
.foregroundStyle(.secondary)
.frame(width: 36, height: 36)
.background(.ultraThinMaterial, in: Circle())
}
Spacer()
}
Image(systemName: card.icon)
.font(.system(size: 48))
.foregroundStyle(.white)
.frame(width: 96, height: 96)
.background(card.color, in: RoundedRectangle(cornerRadius: 24))
.matchedGeometryEffect(id: "icon\(selected)", in: animation)
Text(card.title)
.font(.largeTitle.weight(.bold))
.matchedGeometryEffect(id: "title\(selected)", in: animation)
Text("This is the expanded view for the \(card.title) card. Here you can place detailed content, forms, or any additional UI elements.")
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 20)
Spacer()
}
.padding(24)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(
RoundedRectangle(cornerRadius: 24)
.fill(Color(.secondarySystemGroupedBackground))
.matchedGeometryEffect(id: "bg\(selected)", in: animation)
.ignoresSafeArea()
)
}
}
}
}
17. Animated Gradient Background
struct AnimatedGradientBackground: View {
@State private var animateGradient = false
var body: some View {
LinearGradient(
colors: [
Color(hex: "6C63FF"),
Color(hex: "EC4899"),
Color(hex: "06B6D4"),
Color(hex: "8B5CF6"),
],
startPoint: animateGradient ? .topLeading : .bottomLeading,
endPoint: animateGradient ? .bottomTrailing : .topTrailing
)
.ignoresSafeArea()
.onAppear {
withAnimation(.easeInOut(duration: 5).repeatForever(autoreverses: true)) {
animateGradient.toggle()
}
}
.overlay(
VStack(spacing: 16) {
Text("Welcome Back")
.font(.largeTitle.weight(.bold))
.foregroundStyle(.white)
Text("Your animated gradient background")
.font(.subheadline)
.foregroundStyle(.white.opacity(0.8))
}
)
}
}
18. Blurred Header that Changes on Scroll
struct BlurredScrollHeader: View {
@State private var scrollOffset: CGFloat = 0
private let headerTitle = "Discover"
var body: some View {
ZStack(alignment: .top) {
ScrollView {
VStack(spacing: 0) {
// Spacer for the header
Color.clear.frame(height: 100)
// Content
LazyVStack(spacing: 12) {
ForEach(0..<25, id: \.self) { i in
HStack(spacing: 12) {
RoundedRectangle(cornerRadius: 10)
.fill(
LinearGradient(
colors: [
Color(hex: "6C63FF").opacity(Double(i % 5 + 1) / 5.0),
Color(hex: "EC4899").opacity(Double(i % 5 + 1) / 5.0),
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(width: 56, height: 56)
.overlay(
Image(systemName: "music.note")
.foregroundStyle(.white)
)
VStack(alignment: .leading, spacing: 4) {
Text("Track \(i + 1)")
.font(.headline)
Text("Artist Name")
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer()
Text("3:4\(i % 10)")
.font(.caption)
.foregroundStyle(.tertiary)
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
}
}
}
.background(
GeometryReader { geo in
Color.clear.preference(
key: ScrollOffsetKey.self,
value: geo.frame(in: .named("scroll")).minY
)
}
)
}
.coordinateSpace(name: "scroll")
.onPreferenceChange(ScrollOffsetKey.self) { value in
scrollOffset = value
}
// Floating header
VStack(spacing: 0) {
HStack {
Text(headerTitle)
.font(scrollOffset < -20 ? .headline : .largeTitle.weight(.bold))
.animation(.easeInOut(duration: 0.2), value: scrollOffset < -20)
Spacer()
Image(systemName: "magnifyingglass")
.font(.title3)
.foregroundStyle(.primary)
}
.padding(.horizontal, 16)
.padding(.top, 56)
.padding(.bottom, 12)
.background(
Rectangle()
.fill(.ultraThinMaterial)
.opacity(scrollOffset < -10 ? 1 : 0)
.ignoresSafeArea(edges: .top)
)
}
}
}
}
struct ScrollOffsetKey: PreferenceKey {
static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
}
19. Chip / Tag Flow Layout
struct FlowLayout: Layout {
var spacing: CGFloat = 8
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
let result = layout(proposal: proposal, subviews: subviews)
return result.size
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
let result = layout(proposal: proposal, subviews: subviews)
for (index, position) in result.positions.enumerated() {
subviews[index].place(
at: CGPoint(x: bounds.minX + position.x, y: bounds.minY + position.y),
proposal: ProposedViewSize(result.sizes[index])
)
}
}
private func layout(proposal: ProposedViewSize, subviews: Subviews) -> LayoutResult {
let maxWidth = proposal.width ?? .infinity
var positions: [CGPoint] = []
var sizes: [CGSize] = []
var x: CGFloat = 0
var y: CGFloat = 0
var rowHeight: CGFloat = 0
for subview in subviews {
let size = subview.sizeThatFits(.unspecified)
sizes.append(size)
if x + size.width > maxWidth && x > 0 {
x = 0
y += rowHeight + spacing
rowHeight = 0
}
positions.append(CGPoint(x: x, y: y))
rowHeight = max(rowHeight, size.height)
x += size.width + spacing
}
return LayoutResult(
size: CGSize(width: maxWidth, height: y + rowHeight),
positions: positions,
sizes: sizes
)
}
struct LayoutResult {
var size: CGSize
var positions: [CGPoint]
var sizes: [CGSize]
}
}
struct ChipView: View {
let label: String
let color: Color
@State private var isSelected = false
var body: some View {
Button {
withAnimation(.spring(response: 0.3)) {
isSelected.toggle()
}
} label: {
Text(label)
.font(.subheadline.weight(.medium))
.foregroundStyle(isSelected ? .white : color)
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(
isSelected ? AnyShapeStyle(color) : AnyShapeStyle(color.opacity(0.1)),
in: Capsule()
)
.overlay(
Capsule()
.stroke(color.opacity(isSelected ? 0 : 0.3), lineWidth: 1)
)
}
.buttonStyle(.plain)
}
}
struct ChipFlowDemo: View {
let tags = [
("SwiftUI", Color(hex: "6C63FF")),
("iOS 18", Color(hex: "EC4899")),
("Design", Color(hex: "2D9F6F")),
("Animation", Color(hex: "FF6B35")),
("Accessibility", Color(hex: "0A6EBD")),
("Performance", Color(hex: "8B5CF6")),
("Dark Mode", Color(hex: "1E1B4B")),
("Typography", Color(hex: "F59E0B")),
("Color System", Color(hex: "06B6D4")),
("Layout", Color(hex: "DC2626")),
]
var body: some View {
FlowLayout(spacing: 8) {
ForEach(tags, id: \.0) { tag in
ChipView(label: tag.0, color: tag.1)
}
}
.padding(20)
}
}
20. Rating Stars Component
struct RatingStars: View {
@Binding var rating: Int
let maxRating: Int
let starSize: CGFloat
let activeColor: Color
let inactiveColor: Color
init(
rating: Binding<Int>,
maxRating: Int = 5,
starSize: CGFloat = 28,
activeColor: Color = Color(hex: "F59E0B"),
inactiveColor: Color = Color(hex: "E2E8F0")
) {
self._rating = rating
self.maxRating = maxRating
self.starSize = starSize
self.activeColor = activeColor
self.inactiveColor = inactiveColor
}
var body: some View {
HStack(spacing: 6) {
ForEach(1...maxRating, id: \.self) { index in
Image(systemName: index <= rating ? "star.fill" : "star")
.font(.system(size: starSize))
.foregroundStyle(index <= rating ? activeColor : inactiveColor)
.symbolEffect(.bounce, value: rating)
.onTapGesture {
withAnimation(.spring(response: 0.3, dampingFraction: 0.5)) {
rating = index
}
}
}
}
}
}
struct RatingDemo: View {
@State private var rating1 = 3
@State private var rating2 = 4
var body: some View {
VStack(spacing: 32) {
VStack(spacing: 8) {
Text("Rate your experience")
.font(.headline)
RatingStars(rating: $rating1)
Text("\(rating1) out of 5")
.font(.caption)
.foregroundStyle(.secondary)
}
VStack(spacing: 8) {
Text("Custom style")
.font(.headline)
RatingStars(
rating: $rating2,
starSize: 36,
activeColor: Color(hex: "EC4899"),
inactiveColor: Color(hex: "FCE7F3")
)
}
}
.padding(24)
}
}
Bonus: Combining Patterns -- Premium App Screen
A full composition showing how multiple patterns work together.
struct PremiumHomeScreen: View {
@State private var selectedTab = 0
@State private var showToast = false
var body: some View {
ZStack {
Color(.systemGroupedBackground)
.ignoresSafeArea()
ScrollView {
VStack(spacing: 20) {
// Hero gradient header
ZStack(alignment: .bottomLeading) {
LinearGradient(
colors: [Color(hex: "6C63FF"), Color(hex: "8B5CF6"), Color(hex: "EC4899")],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.frame(height: 220)
.cornerRadius(24)
VStack(alignment: .leading, spacing: 8) {
Text("Good Morning")
.font(.subheadline)
.foregroundStyle(.white.opacity(0.8))
Text("Jane")
.font(.largeTitle.weight(.bold))
.foregroundStyle(.white)
}
.padding(24)
}
.padding(.horizontal, 16)
// Chip tags
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(["All", "Design", "Code", "Health", "Finance"], id: \.self) { tag in
Text(tag)
.font(.subheadline.weight(.medium))
.foregroundStyle(tag == "All" ? .white : .primary)
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(
tag == "All"
? AnyShapeStyle(Color(hex: "6C63FF"))
: AnyShapeStyle(Color(.secondarySystemGroupedBackground)),
in: Capsule()
)
}
}
.padding(.horizontal, 16)
}
// Dashboard grid
LazyVGrid(columns: [
GridItem(.flexible(), spacing: 12),
GridItem(.flexible(), spacing: 12),
], spacing: 12) {
CircularProgressCard(
progress: 0.72,
title: "Tasks",
subtitle: "18 / 25",
color: Color(hex: "6C63FF")
)
CircularProgressCard(
progress: 0.45,
title: "Goals",
subtitle: "3 / 7",
color: Color(hex: "2D9F6F")
)
}
.padding(.horizontal, 16)
// Glass card
VStack(alignment: .leading, spacing: 12) {
HStack {
Image(systemName: "sparkles")
.foregroundStyle(Color(hex: "F59E0B"))
Text("Featured")
.font(.headline)
Spacer()
Text("NEW")
.font(.caption2.weight(.bold))
.foregroundStyle(.white)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(Color(hex: "EC4899"), in: Capsule())
}
Text("Unlock premium features and take your productivity to the next level.")
.font(.subheadline)
.foregroundStyle(.secondary)
Button {
showToast = true
} label: {
Text("Upgrade Now")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.background(
LinearGradient(
colors: [Color(hex: "6C63FF"), Color(hex: "8B5CF6")],
startPoint: .leading,
endPoint: .trailing
),
in: RoundedRectangle(cornerRadius: 12)
)
}
}
.padding(20)
.background(Color(.secondarySystemGroupedBackground))
.cornerRadius(20)
.padding(.horizontal, 16)
// Bottom spacing for tab bar
Color.clear.frame(height: 80)
}
}
}
.toast(isShowing: $showToast, message: "Welcome to Premium!")
}
}
Quick Reference
Pattern
Key Techniques
Glass Morphism
.ultraThinMaterial, gradient border stroke
Neumorphism
Dual shadows (light + dark), same-as-background fill
Gradient Shadow
Shadow color matching card gradient
Onboarding
TabView(.page), custom indicators, matchedGeometryEffect
Parallax Hero
GeometryReader, offset based on minY
Bottom Sheet
DragGesture, snap points, spring animation
Animated Tab Bar
matchedGeometryEffect, @Namespace
Profile Card
Gradient header, overlapping avatar, stat row
Dashboard Cards
Circle().trim(), mini bar charts
FAB
Expand/collapse with spring, rotation
Custom Toggle
ZStack alignment toggle, spring animation
Swipe Cards
DragGesture, rotation, threshold-based removal
Pull-to-Refresh
.refreshable async modifier
Skeleton/Shimmer
ViewModifier, animated LinearGradient overlay
Toast
ViewModifier, auto-dismiss, slide transition
Expandable Card
matchedGeometryEffect, @Namespace
Animated Gradient
repeatForever animation on gradient points
Blurred Scroll Header
PreferenceKey, .ultraThinMaterial opacity
Chip Flow Layout
Custom Layout protocol, Capsule backgrounds
Rating Stars
symbolEffect(.bounce), tap gesture
---
# Third-Party Animation Integration
https://nagarjuna2997.github.io/ios-agent-skill/guides/design-third-party-animations.html
← Back to all frameworks & guides
All articles →Design · Reference guideThird-Party Animation IntegrationRepository guidance for Third-Party Animation Integration. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
Lottie Integration
Adding Lottie via SPM
UIKit: LottieAnimationView Basics
SwiftUI: UIViewRepresentable Wrapper
Complete SwiftUI LottieView Wrapper with Binding Controls
Playing Specific Frame Ranges
Color Value Providers for Dynamic Theming
Rive Integration
Adding Rive via SPM
RiveViewModel Basics
SwiftUI Integration
State Machines: Inputs, Triggers, Booleans, Numbers
Artboard and Animation Selection
Complete Interactive Rive Toggle Example
When to Use What
Decision Table
Summary Guidelines
File Size Comparison
Performance Characteristics
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Decide whether native is enough
↓2
Check dependency and license
↓3
Integrate an isolated effect
↓4
Test reduced motion
02 / ArchitectureResponsibility boundariesBoundary 1
Animation assetsBoundary 2
Integration boundaryBoundary 3
Native screenConnected responsibilities, not a required class hierarchy or an execution trace.
Guide for integrating Lottie and Rive animation libraries into iOS projects, with SwiftUI wrappers and usage patterns.
Lottie Integration
Lottie
renders Adobe After Effects animations exported as JSON via the Bodymovin plugin. It is the industry standard for complex vector animations on iOS.
Adding Lottie via SPM
In Xcode: File > Add Package Dependencies, then enter:
https://github.com/airbnb/lottie-ios.git
Select the
Lottie
library and add it to your target. Use the latest stable version (4.x+).
UIKit: LottieAnimationView Basics
import Lottie
let animationView = LottieAnimationView(name: "loading") // Loads loading.json from bundle
animationView.contentMode = .scaleAspectFit
animationView.loopMode = .loop
animationView.animationSpeed = 1.0
animationView.frame = CGRect(x: 0, y: 0, width: 200, height: 200)
view.addSubview(animationView)
animationView.play()
SwiftUI: UIViewRepresentable Wrapper
Lottie 4.x ships with a built-in
LottieView
for SwiftUI. If you need more control, build a custom wrapper:
import SwiftUI
import Lottie
struct LottieAnimationUIView: UIViewRepresentable {
let animationName: String
var loopMode: LottieLoopMode = .loop
var animationSpeed: CGFloat = 1.0
@Binding var isPlaying: Bool
func makeUIView(context: Context) -> LottieAnimationView {
let view = LottieAnimationView(name: animationName)
view.contentMode = .scaleAspectFit
view.loopMode = loopMode
view.animationSpeed = animationSpeed
return view
}
func updateUIView(_ uiView: LottieAnimationView, context: Context) {
uiView.loopMode = loopMode
uiView.animationSpeed = animationSpeed
if isPlaying {
if !uiView.isAnimationPlaying {
uiView.play()
}
} else {
uiView.pause()
}
}
}
Complete SwiftUI LottieView Wrapper with Binding Controls
import SwiftUI
import Lottie
struct LottiePlayerView: UIViewRepresentable {
let animationName: String
var loopMode: LottieLoopMode = .loop
var animationSpeed: CGFloat = 1.0
@Binding var playbackState: PlaybackState
enum PlaybackState {
case playing
case paused
case stopped
}
func makeCoordinator() -> Coordinator {
Coordinator()
}
func makeUIView(context: Context) -> LottieAnimationView {
let animationView = LottieAnimationView(name: animationName)
animationView.contentMode = .scaleAspectFit
animationView.loopMode = loopMode
animationView.animationSpeed = animationSpeed
context.coordinator.animationView = animationView
return animationView
}
func updateUIView(_ uiView: LottieAnimationView, context: Context) {
uiView.loopMode = loopMode
uiView.animationSpeed = animationSpeed
switch playbackState {
case .playing:
if !uiView.isAnimationPlaying {
uiView.play()
}
case .paused:
uiView.pause()
case .stopped:
uiView.stop()
}
}
class Coordinator {
weak var animationView: LottieAnimationView?
}
}
// Usage
struct LottieDemo: View {
@State private var playbackState: LottiePlayerView.PlaybackState = .playing
@State private var speed: CGFloat = 1.0
var body: some View {
VStack(spacing: 24) {
LottiePlayerView(
animationName: "confetti",
loopMode: .loop,
animationSpeed: speed,
playbackState: $playbackState
)
.frame(width: 300, height: 300)
HStack(spacing: 16) {
Button("Play") { playbackState = .playing }
.buttonStyle(.borderedProminent)
Button("Pause") { playbackState = .paused }
.buttonStyle(.bordered)
Button("Stop") { playbackState = .stopped }
.buttonStyle(.bordered)
}
VStack {
Text("Speed: \(speed, specifier: "%.1f")x")
.font(.subheadline)
Slider(value: $speed, in: 0.1...3.0, step: 0.1)
}
.padding(.horizontal)
}
.padding()
}
}
Playing Specific Frame Ranges
import Lottie
// Play frames 0 through 60
let animationView = LottieAnimationView(name: "multiSection")
animationView.play(fromFrame: 0, toFrame: 60, loopMode: .playOnce)
// Play a named marker range (markers set in After Effects)
animationView.play(fromMarker: "start", toMarker: "end", loopMode: .loop)
// Jump to a specific progress (0.0 to 1.0)
animationView.currentProgress = 0.5
Color Value Providers for Dynamic Theming
import Lottie
let animationView = LottieAnimationView(name: "icon")
// Override a specific color in the animation
let colorProvider = ColorValueProvider(UIColor.systemBlue.lottieColorValue)
animationView.setValueProvider(
colorProvider,
keypath: AnimationKeypath(keypath: "**.Fill 1.Color")
)
// Use a dynamic color block
let dynamicProvider = ColorValueProvider { _ in
return UIColor.tintColor.lottieColorValue
}
animationView.setValueProvider(
dynamicProvider,
keypath: AnimationKeypath(keypath: "**.Stroke 1.Color")
)
Rive Integration
Rive
is a real-time animation platform that supports state machines, making animations interactive and responsive to user input. Rive files are typically smaller than Lottie JSON.
Adding Rive via SPM
In Xcode: File > Add Package Dependencies, then enter:
https://github.com/rive-app/rive-ios.git
Select the
RiveRuntime
library and add it to your target.
RiveViewModel Basics
import RiveRuntime
// Load a .riv file from the bundle
let riveViewModel = RiveViewModel(fileName: "animated_icon")
// In UIKit
let riveView = riveViewModel.createRiveView()
view.addSubview(riveView)
SwiftUI Integration
Rive provides a built-in SwiftUI view through
RiveViewModel
:
import SwiftUI
import RiveRuntime
struct RiveAnimationView: View {
var viewModel = RiveViewModel(fileName: "loading_spinner")
var body: some View {
viewModel.view()
.frame(width: 200, height: 200)
}
}
State Machines: Inputs, Triggers, Booleans, Numbers
Rive state machines let you control animation states through inputs defined in the Rive editor.
import SwiftUI
import RiveRuntime
struct RiveStateMachineDemo: View {
var viewModel = RiveViewModel(fileName: "interactive_button", stateMachineName: "State Machine 1")
var body: some View {
VStack(spacing: 20) {
viewModel.view()
.frame(width: 300, height: 200)
// Trigger a one-shot input
Button("Fire Trigger") {
viewModel.triggerInput("pressed")
}
// Toggle a boolean input
Button("Toggle Hover") {
viewModel.setInput("isHovered", value: true)
}
// Set a numeric input
Button("Set Progress") {
viewModel.setInput("progress", value: 0.75)
}
}
}
}
Artboard and Animation Selection
import RiveRuntime
// Select a specific artboard and animation
let viewModel = RiveViewModel(
fileName: "multi_artboard",
artboardName: "IconArtboard",
animationName: "idle"
)
// Switch animation at runtime
viewModel.play(animationName: "active")
viewModel.pause()
viewModel.stop()
Complete Interactive Rive Toggle Example
import SwiftUI
import RiveRuntime
struct RiveToggle: View {
@State private var isOn = false
var viewModel = RiveViewModel(fileName: "toggle_switch", stateMachineName: "Toggle Machine")
var body: some View {
VStack(spacing: 24) {
viewModel.view()
.frame(width: 120, height: 60)
.onTapGesture {
isOn.toggle()
viewModel.setInput("isOn", value: isOn)
}
Text(isOn ? "Enabled" : "Disabled")
.font(.headline)
.foregroundStyle(isOn ? .green : .secondary)
}
}
}
// A more complete settings screen with Rive toggles
struct RiveSettingsView: View {
@State private var notificationsOn = true
@State private var darkModeOn = false
var notificationsVM = RiveViewModel(fileName: "toggle_switch", stateMachineName: "Toggle Machine")
var darkModeVM = RiveViewModel(fileName: "toggle_switch", stateMachineName: "Toggle Machine")
var body: some View {
NavigationStack {
List {
HStack {
Label("Notifications", systemImage: "bell.fill")
Spacer()
notificationsVM.view()
.frame(width: 60, height: 30)
.onTapGesture {
notificationsOn.toggle()
notificationsVM.setInput("isOn", value: notificationsOn)
}
}
HStack {
Label("Dark Mode", systemImage: "moon.fill")
Spacer()
darkModeVM.view()
.frame(width: 60, height: 30)
.onTapGesture {
darkModeOn.toggle()
darkModeVM.setInput("isOn", value: darkModeOn)
}
}
}
.navigationTitle("Settings")
}
}
}
When to Use What
Decision Table
Use Case
Recommended
Why
Complex vector animations from After Effects
Lottie
Direct Bodymovin export, massive community library
One-shot success/error/loading animations
Lottie
Easy to drop in, many free animations on LottieFiles
Splash screen or onboarding animations
Lottie
Smooth, high-fidelity playback
Interactive toggles, buttons, switches
Rive
State machines handle input-driven transitions
Animations that respond to data (progress, score)
Rive
Number inputs drive animations smoothly
Character animations with multiple states
Rive
State machine graph handles complex state logic
Simple fade, scale, slide transitions
Native SwiftUI
No dependency needed, GPU-accelerated
Layout-driven animations (list reorder, insert/remove)
Native SwiftUI
Built-in transition and matchedGeometryEffect
Spring physics and gesture-driven animations
Native SwiftUI
UIViewPropertyAnimator or SwiftUI springs
Animated app icons or dynamic backgrounds
Rive
Tiny file size, runtime compositing
Accessibility-sensitive animations
Native SwiftUI
Respects Reduce Motion automatically
Summary Guidelines
Choose Lottie when:
- You have a designer using After Effects who exports via Bodymovin
- You need to drop in pre-made animations from LottieFiles.com
- The animation is purely visual (no user interaction controls it)
- You need frame-accurate playback of complex vector art
- File size is not a primary concern (Lottie JSON can be large)
Choose Rive when:
- Animations need to react to user input (taps, drags, state changes)
- You want a single file with multiple animation states and transitions
- File size matters (Rive binary format is typically 5-10x smaller than Lottie JSON)
- Your designer uses the Rive editor (not After Effects)
- You need runtime color/property changes without value providers
Choose native SwiftUI/UIKit when:
- Animations are tied to state changes (show/hide, expand/collapse)
- You need gesture-driven interactive animations
- The animation is simple (fade, scale, slide, spring)
- You want zero third-party dependencies
- You need full accessibility support (Reduce Motion, VoiceOver)
- Performance is critical (native animations use Core Animation directly)
File Size Comparison
Format
Typical Size
Notes
Lottie JSON
10-500 KB
Can be compressed with dotLottie (.lottie)
Lottie dotLottie
2-100 KB
Compressed format, supported in lottie-ios 4.x
Rive (.riv)
2-50 KB
Binary format, very compact
Native code
0 KB
No additional assets needed
Performance Characteristics
Library
CPU Usage
GPU Usage
Memory
Best For
Lottie (Main Thread)
Medium-High
Low
Medium
Simple animations
Lottie (Core Animation)
Low
Medium
Low
Complex looping animations
Rive
Low
Medium
Low
Interactive animations
Native SwiftUI
Very Low
Low
Very Low
UI transitions
Core Animation
Very Low
Medium
Low
Custom layer animations
Lottie supports two rendering engines: the default Main Thread renderer and the Core Animation renderer. For looping animations, use Core Animation rendering (
LottieAnimationView.configuration = .init(renderingEngine: .coreAnimation)
) for better performance.
---
# iOS Typography System -- Complete Guide for Stunning…
https://nagarjuna2997.github.io/ios-agent-skill/guides/design-typography-system.html
← Back to all frameworks & guides
All articles →Design · Reference guideiOS Typography System -- Complete Guide for Stunning SwiftUI TextRepository guidance for iOS Typography System -- Complete Guide for Stunning SwiftUI Text. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
Overview
1. Apple's Built-In Text Styles
Combining Style with Weight
2. Font Designs
3. Custom Fonts
Registering Custom Fonts
Using Custom Fonts in SwiftUI
Font Extension for Clean Usage
4. SF Symbols Integration
Basic Usage
Symbol Rendering Modes
Variable Value Symbols
Symbol Effects (iOS 17+)
5. Dynamic Type Support
@ScaledMetric
minimumScaleFactor
6. Typography Hierarchy Best Practices
7. Text Effects
Gradient Text
Shadow Text
Outlined Text (Stroke)
Animated Text
8. Markdown Support in Text
9. AttributedString
AttributedString with Links and Dates
10. Stunning Text Treatment Compositions
Hero Header with Gradient and Blur
Pill Tag with Custom Typography
Statistic Display with Mixed Typography
Quick Reference
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Define text roles
↓2
Apply scalable styles
↓3
Test long localized text
↓4
Verify largest text sizes
02 / ArchitectureResponsibility boundariesBoundary 1
Semantic stylesBoundary 2
Font metricsBoundary 3
Adaptive layoutConnected responsibilities, not a required class hierarchy or an execution trace.
Overview
Typography accounts for roughly 80% of a UI's visual surface. Getting it right is the single most impactful design decision. This guide covers Apple's built-in type system, custom fonts, SF Symbols, Dynamic Type, and advanced text effects -- all with compilable SwiftUI code.
1. Apple's Built-In Text Styles
These styles scale automatically with Dynamic Type and ensure consistency across the system.
import SwiftUI
struct TextStyleCatalog: View {
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
Text("Large Title").font(.largeTitle) // 34pt, used for top-level headers
Text("Title").font(.title) // 28pt, screen titles
Text("Title 2").font(.title2) // 22pt, section headers
Text("Title 3").font(.title3) // 20pt, sub-section headers
Text("Headline").font(.headline) // 17pt semibold, row labels
Text("Subheadline").font(.subheadline) // 15pt, secondary row labels
Text("Body").font(.body) // 17pt, primary content
Text("Callout").font(.callout) // 16pt, annotation text
Text("Footnote").font(.footnote) // 13pt, timestamps, captions
Text("Caption").font(.caption) // 12pt, legal text
Text("Caption 2").font(.caption2) // 11pt, smallest readable
}
.padding(24)
}
}
}
Combining Style with Weight
struct WeightedTextExamples: View {
var body: some View {
VStack(alignment: .leading, spacing: 10) {
Text("Ultralight Title").font(.largeTitle.weight(.ultraLight))
Text("Thin Title").font(.largeTitle.weight(.thin))
Text("Light Title").font(.largeTitle.weight(.light))
Text("Regular Title").font(.largeTitle.weight(.regular))
Text("Medium Title").font(.largeTitle.weight(.medium))
Text("Semibold Title").font(.largeTitle.weight(.semibold))
Text("Bold Title").font(.largeTitle.weight(.bold))
Text("Heavy Title").font(.largeTitle.weight(.heavy))
Text("Black Title").font(.largeTitle.weight(.black))
}
.padding(24)
}
}
2. Font Designs
Apple provides four design variants of San Francisco.
struct FontDesignShowcase: View {
var body: some View {
VStack(alignment: .leading, spacing: 20) {
VStack(alignment: .leading, spacing: 4) {
Text("Default (SF Pro)")
.font(.system(.title2, design: .default, weight: .bold))
Text("Clean and neutral -- ideal for most apps")
.font(.subheadline)
.foregroundStyle(.secondary)
}
VStack(alignment: .leading, spacing: 4) {
Text("Rounded (SF Rounded)")
.font(.system(.title2, design: .rounded, weight: .bold))
Text("Friendly and approachable -- great for wellness, kids, casual")
.font(.subheadline)
.foregroundStyle(.secondary)
}
VStack(alignment: .leading, spacing: 4) {
Text("Serif (New York)")
.font(.system(.title2, design: .serif, weight: .bold))
Text("Editorial and elegant -- perfect for news, reading, luxury")
.font(.subheadline)
.foregroundStyle(.secondary)
}
VStack(alignment: .leading, spacing: 4) {
Text("Monospaced (SF Mono)")
.font(.system(.title2, design: .monospaced, weight: .bold))
Text("Technical and precise -- code editors, data, developer tools")
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
.padding(24)
}
}
3. Custom Fonts
Registering Custom Fonts
Add
.ttf
or
.otf
files to your Xcode project.
Ensure they are added to the target's "Copy Bundle Resources" build phase.
Add each font filename to
Info.plist
under the key
UIAppFonts
(also called "Fonts provided by application").
<!-- Info.plist entry -->
<key>UIAppFonts</key>
<array>
<string>Satoshi-Regular.otf</string>
<string>Satoshi-Bold.otf</string>
<string>Satoshi-Medium.otf</string>
</array>
Using Custom Fonts in SwiftUI
struct CustomFontDemo: View {
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Custom Regular")
.font(.custom("Satoshi-Regular", size: 17))
Text("Custom Bold")
.font(.custom("Satoshi-Bold", size: 28))
// Relative to a text style (scales with Dynamic Type)
Text("Custom with Dynamic Type")
.font(.custom("Satoshi-Medium", size: 17, relativeTo: .body))
}
.padding(24)
}
}
Font Extension for Clean Usage
extension Font {
static func satoshi(_ weight: SatoshiWeight, size: CGFloat) -> Font {
.custom(weight.rawValue, size: size)
}
static func satoshiRelative(_ weight: SatoshiWeight, size: CGFloat, relativeTo style: TextStyle) -> Font {
.custom(weight.rawValue, size: size, relativeTo: style)
}
enum SatoshiWeight: String {
case regular = "Satoshi-Regular"
case medium = "Satoshi-Medium"
case bold = "Satoshi-Bold"
}
}
// Usage:
// Text("Hello").font(.satoshi(.bold, size: 24))
4. SF Symbols Integration
SF Symbols 5+ includes over 5,000 symbols that integrate seamlessly with text.
Basic Usage
struct SFSymbolsBasic: View {
var body: some View {
VStack(spacing: 16) {
// Inline with text (symbols match font metrics automatically)
Label("Favorites", systemImage: "heart.fill")
.font(.title2)
// Standalone image with font size
Image(systemName: "arrow.up.right.circle.fill")
.font(.system(size: 48))
.foregroundStyle(.blue)
// Symbol alongside text baseline
HStack(alignment: .firstTextBaseline) {
Image(systemName: "clock.fill")
Text("5 minutes ago")
}
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
}
Symbol Rendering Modes
struct SymbolRenderingModes: View {
var body: some View {
VStack(spacing: 24) {
// Monochrome -- single color
Image(systemName: "cloud.sun.rain.fill")
.symbolRenderingMode(.monochrome)
.font(.system(size: 48))
.foregroundStyle(.blue)
// Hierarchical -- primary color with automatic opacity layers
Image(systemName: "cloud.sun.rain.fill")
.symbolRenderingMode(.hierarchical)
.font(.system(size: 48))
.foregroundStyle(.blue)
// Palette -- explicit colors for each layer
Image(systemName: "cloud.sun.rain.fill")
.symbolRenderingMode(.palette)
.font(.system(size: 48))
.foregroundStyle(.gray, .yellow, .blue)
// Multicolor -- system-defined colors
Image(systemName: "cloud.sun.rain.fill")
.symbolRenderingMode(.multicolor)
.font(.system(size: 48))
}
}
}
Variable Value Symbols
struct VariableSymbolDemo: View {
@State private var progress: Double = 0.7
var body: some View {
VStack(spacing: 20) {
Image(systemName: "speaker.wave.3.fill", variableValue: progress)
.font(.system(size: 48))
.foregroundStyle(.blue)
.contentTransition(.symbolEffect(.automatic))
Slider(value: $progress, in: 0...1)
.padding(.horizontal, 40)
Image(systemName: "wifi", variableValue: progress)
.font(.system(size: 48))
.foregroundStyle(.green)
}
.padding()
}
}
Symbol Effects (iOS 17+)
struct SymbolEffectsDemo: View {
@State private var isFavorite = false
@State private var bounceCount = 0
var body: some View {
VStack(spacing: 32) {
// Bounce effect
Image(systemName: "bell.fill")
.font(.system(size: 44))
.foregroundStyle(.orange)
.symbolEffect(.bounce, value: bounceCount)
.onTapGesture { bounceCount += 1 }
// Pulse effect (continuous)
Image(systemName: "heart.fill")
.font(.system(size: 44))
.foregroundStyle(.red)
.symbolEffect(.pulse)
// Replace transition
Button {
isFavorite.toggle()
} label: {
Image(systemName: isFavorite ? "heart.fill" : "heart")
.font(.system(size: 44))
.foregroundStyle(isFavorite ? .red : .gray)
.contentTransition(.symbolEffect(.replace))
}
// Breathe effect (continuous)
Image(systemName: "lungs.fill")
.font(.system(size: 44))
.foregroundStyle(.teal)
.symbolEffect(.breathe)
// Scale effect
Image(systemName: "star.fill")
.font(.system(size: 44))
.foregroundStyle(.yellow)
.symbolEffect(.scale.up, isActive: isFavorite)
}
.padding()
}
}
5. Dynamic Type Support
@ScaledMetric
Scale arbitrary numeric values proportionally to the user's Dynamic Type setting.
struct ScaledMetricDemo: View {
@ScaledMetric(relativeTo: .title) var iconSize: CGFloat = 28
@ScaledMetric(relativeTo: .body) var spacing: CGFloat = 12
@ScaledMetric(relativeTo: .body) var cardPadding: CGFloat = 16
var body: some View {
HStack(spacing: spacing) {
Image(systemName: "person.circle.fill")
.font(.system(size: iconSize))
.foregroundStyle(.blue)
VStack(alignment: .leading, spacing: 4) {
Text("John Appleseed")
.font(.headline)
Text("iOS Developer")
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
.padding(cardPadding)
.background(Color(.secondarySystemBackground))
.cornerRadius(16)
}
}
minimumScaleFactor
Prevent text from being clipped while still supporting Dynamic Type.
struct ScaleFactorDemo: View {
var body: some View {
Text("This very long title will shrink instead of truncating")
.font(.title)
.minimumScaleFactor(0.5)
.lineLimit(1)
.padding()
}
}
6. Typography Hierarchy Best Practices
A clear hierarchy uses no more than 3-4 sizes with weight variation.
struct TypographyHierarchyCard: View {
var body: some View {
VStack(alignment: .leading, spacing: 0) {
// Overline -- smallest, uppercase, colored
Text("FEATURED")
.font(.caption.weight(.bold))
.foregroundStyle(Color(hex: "6C63FF"))
.kerning(1.5)
.padding(.bottom, 8)
// Title -- largest element on card
Text("The Art of Typography")
.font(.title2.weight(.bold))
.foregroundStyle(.primary)
.padding(.bottom, 4)
// Subtitle -- contextual info
Text("Design Systems -- March 2026")
.font(.subheadline)
.foregroundStyle(.secondary)
.padding(.bottom, 16)
// Body -- main content
Text("Great typography establishes visual hierarchy, guides the reader, and creates emotional resonance. In iOS, the San Francisco font family provides all the tools needed for world-class type.")
.font(.body)
.foregroundStyle(.primary)
.lineSpacing(4)
.padding(.bottom, 16)
// Action -- button text
Text("Read More")
.font(.subheadline.weight(.semibold))
.foregroundStyle(Color(hex: "6C63FF"))
}
.padding(24)
.background(Color(.secondarySystemGroupedBackground))
.cornerRadius(20)
.padding(.horizontal, 16)
}
}
7. Text Effects
Gradient Text
struct GradientTextView: View {
var body: some View {
Text("Gradient Text")
.font(.system(size: 48, weight: .black, design: .rounded))
.foregroundStyle(
LinearGradient(
colors: [Color(hex: "8B5CF6"), Color(hex: "EC4899"), Color(hex: "06B6D4")],
startPoint: .leading,
endPoint: .trailing
)
)
}
}
Shadow Text
struct ShadowTextView: View {
var body: some View {
VStack(spacing: 32) {
// Subtle shadow
Text("Soft Shadow")
.font(.largeTitle.weight(.bold))
.foregroundStyle(.white)
.shadow(color: .black.opacity(0.3), radius: 8, y: 4)
// Glow effect
Text("Neon Glow")
.font(.largeTitle.weight(.black))
.foregroundStyle(Color(hex: "06B6D4"))
.shadow(color: Color(hex: "06B6D4").opacity(0.6), radius: 12)
.shadow(color: Color(hex: "06B6D4").opacity(0.3), radius: 24)
}
.padding(40)
.background(.black)
}
}
Outlined Text (Stroke)
struct OutlinedTextView: View {
var body: some View {
ZStack {
// Stroke layer
Text("BOLD")
.font(.system(size: 72, weight: .black))
.foregroundStyle(.clear)
.overlay(
Text("BOLD")
.font(.system(size: 72, weight: .black))
.foregroundStyle(
LinearGradient(
colors: [Color(hex: "6C63FF"), Color(hex: "EC4899")],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.mask(
Text("BOLD")
.font(.system(size: 72, weight: .black))
)
)
}
}
}
// Alternative approach using strokeBorder on custom shape
struct StrokedText: View {
var body: some View {
Text("OUTLINE")
.font(.system(size: 64, weight: .black))
.foregroundStyle(.clear)
.overlay(
Text("OUTLINE")
.font(.system(size: 64, weight: .black))
.foregroundStyle(
.linearGradient(
colors: [Color(hex: "FF6B35"), Color(hex: "F7C948")],
startPoint: .top,
endPoint: .bottom
)
)
)
}
}
Animated Text
struct AnimatedCounterText: View {
@State private var value: Double = 0
var body: some View {
VStack(spacing: 16) {
Text("\(value, specifier: "%.0f")")
.font(.system(size: 72, weight: .black, design: .rounded))
.foregroundStyle(
LinearGradient(
colors: [Color(hex: "2D9F6F"), Color(hex: "22D3EE")],
startPoint: .top,
endPoint: .bottom
)
)
.contentTransition(.numericText())
Button("Animate") {
withAnimation(.spring(duration: 0.8, bounce: 0.2)) {
value = Double.random(in: 0...9999)
}
}
.buttonStyle(.borderedProminent)
}
}
}
struct TypewriterText: View {
let fullText: String
@State private var displayedText = ""
@State private var charIndex = 0
var body: some View {
Text(displayedText)
.font(.title2.weight(.medium))
.onAppear {
Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { timer in
if charIndex < fullText.count {
let index = fullText.index(fullText.startIndex, offsetBy: charIndex)
displayedText += String(fullText[index])
charIndex += 1
} else {
timer.invalidate()
}
}
}
}
}
8. Markdown Support in Text
SwiftUI Text views parse Markdown automatically starting in iOS 15.
struct MarkdownTextDemo: View {
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("This is **bold** and this is *italic*.")
Text("Visit [Apple](https://apple.com) for more.")
Text("Use `code` in your text.")
Text("~~Strikethrough~~ is supported too.")
// Combine Markdown with font styling
Text("**Premium Plan** -- $9.99/month")
.font(.headline)
// Multiline Markdown
Text("""
# Features
- **Fast** performance
- *Beautiful* design
- `Clean` code
""")
}
.padding(24)
}
}
9. AttributedString
For rich text that goes beyond Markdown, use
AttributedString
.
struct AttributedStringDemo: View {
var attributedGreeting: AttributedString {
var hello = AttributedString("Hello ")
hello.font = .title.weight(.light)
hello.foregroundColor = .secondary
var name = AttributedString("World")
name.font = .title.weight(.bold)
name.foregroundColor = .primary
var emoji = AttributedString(" !")
emoji.font = .title
return hello + name + emoji
}
var highlightedText: AttributedString {
var full = AttributedString("SwiftUI makes building beautiful apps incredibly fast and enjoyable.")
full.font = .body
if let range = full.range(of: "beautiful") {
full[range].foregroundColor = Color(hex: "8B5CF6")
full[range].font = .body.weight(.bold)
}
if let range = full.range(of: "fast") {
full[range].foregroundColor = Color(hex: "2D9F6F")
full[range].font = .body.weight(.bold)
}
return full
}
var body: some View {
VStack(alignment: .leading, spacing: 20) {
Text(attributedGreeting)
Text(highlightedText)
.lineSpacing(4)
}
.padding(24)
}
}
AttributedString with Links and Dates
struct RichAttributedText: View {
var formattedText: AttributedString {
var text = AttributedString("Updated ")
text.font = .footnote
text.foregroundColor = .secondary
var date = AttributedString(Date.now, format: .dateTime.month().day().year())
date.font = .footnote.weight(.semibold)
date.foregroundColor = .primary
var separator = AttributedString(" -- ")
separator.font = .footnote
var link = AttributedString("View Source")
link.font = .footnote.weight(.medium)
link.foregroundColor = Color(hex: "0A6EBD")
link.link = URL(string: "https://developer.apple.com")
return text + date + separator + link
}
var body: some View {
Text(formattedText)
.padding()
}
}
10. Stunning Text Treatment Compositions
Hero Header with Gradient and Blur
struct HeroTextHeader: View {
var body: some View {
ZStack {
LinearGradient(
colors: [Color(hex: "0B0B1A"), Color(hex: "1A1A2E")],
startPoint: .top,
endPoint: .bottom
)
.ignoresSafeArea()
VStack(spacing: 12) {
Text("Introducing")
.font(.title3.weight(.medium))
.foregroundStyle(.white.opacity(0.6))
.kerning(3)
.textCase(.uppercase)
Text("Premium")
.font(.system(size: 64, weight: .black, design: .serif))
.foregroundStyle(
LinearGradient(
colors: [Color(hex: "F472B6"), Color(hex: "A78BFA"), Color(hex: "6C63FF")],
startPoint: .leading,
endPoint: .trailing
)
)
Text("Crafted with care for those who\nappreciate the finer details.")
.font(.body)
.foregroundStyle(.white.opacity(0.5))
.multilineTextAlignment(.center)
.lineSpacing(4)
}
}
.frame(height: 350)
}
}
Pill Tag with Custom Typography
struct StyledTag: View {
let text: String
let color: Color
var body: some View {
Text(text.uppercased())
.font(.caption2.weight(.bold))
.kerning(1.2)
.foregroundStyle(.white)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(color, in: Capsule())
}
}
struct TagRow: View {
var body: some View {
HStack(spacing: 8) {
StyledTag(text: "SwiftUI", color: Color(hex: "6C63FF"))
StyledTag(text: "iOS 18", color: Color(hex: "EC4899"))
StyledTag(text: "New", color: Color(hex: "2D9F6F"))
}
}
}
Statistic Display with Mixed Typography
struct StatDisplay: View {
let value: String
let unit: String
let label: String
var body: some View {
VStack(spacing: 4) {
HStack(alignment: .firstTextBaseline, spacing: 2) {
Text(value)
.font(.system(size: 40, weight: .bold, design: .rounded))
.foregroundStyle(.primary)
Text(unit)
.font(.title3.weight(.medium))
.foregroundStyle(.secondary)
}
Text(label)
.font(.caption.weight(.medium))
.foregroundStyle(.tertiary)
.textCase(.uppercase)
.kerning(1)
}
}
}
struct StatsRow: View {
var body: some View {
HStack(spacing: 32) {
StatDisplay(value: "2.4", unit: "M", label: "Downloads")
StatDisplay(value: "4.9", unit: "", label: "Rating")
StatDisplay(value: "128", unit: "K", label: "Reviews")
}
.padding(24)
.background(Color(.secondarySystemGroupedBackground))
.cornerRadius(20)
}
}
Quick Reference
Category
Key APIs
Text Styles
.largeTitle, .title, .title2, .title3, .headline, .subheadline, .body, .callout, .footnote, .caption, .caption2
Font Designs
.default, .rounded, .serif, .monospaced
Weights
.ultraLight through .black (9 levels)
Custom Fonts
.custom("Name", size:), .custom("Name", size:, relativeTo:)
SF Symbols
.symbolRenderingMode(), .symbolEffect(), variableValue:
Dynamic Type
@ScaledMetric, .minimumScaleFactor(), relativeTo:
Text Effects
.foregroundStyle() with gradients, .shadow(), .contentTransition()
Rich Text
Markdown in Text(), AttributedString
Kerning/Tracking
.kerning(), .tracking()
Line Spacing
.lineSpacing(), .lineLimit()
---
# ActivityKit & Live Activities
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-activitykit.html
← Back to all frameworks & guides
All articles →Core UI and Apps · Reference guideActivityKit & Live ActivitiesRepository guidance for ActivityKit. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
ActivityAttributes and ActivityContent
Starting a Live Activity
Updating a Live Activity
Ending a Live Activity
Dynamic Island Presentations
Lock Screen and Supporting Views
Push-to-Update with Push Tokens
ActivityKit Push Notification Payload
Timer and Progress Live Activities
StaleDate and Dismissal Policy
Complete Delivery Tracking Example
Widget Bundle Registration
Key Considerations
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Check activity availability
↓2
Request an activity
↓3
Update activity content
↓4
End the activity
02 / ArchitectureResponsibility boundariesBoundary 1
App stateBoundary 2
Activity contentBoundary 3
Live Activity presentationConnected responsibilities, not a required class hierarchy or an execution trace.
ActivityKit enables Live Activities that display real-time, glanceable content on the Lock Screen and Dynamic Island. Live Activities are ideal for tracking ongoing events like deliveries, sports scores, workouts, and ride-sharing trips.
ActivityAttributes and ActivityContent
ActivityAttributes define the static and dynamic data for a Live Activity. The nested
ContentState
contains data that changes over time.
import ActivityKit
import Foundation
// Define the attributes for a delivery tracking Live Activity
struct DeliveryAttributes: ActivityAttributes {
// Static data — set when the activity starts, never changes
let orderNumber: String
let restaurantName: String
let estimatedDeliveryTime: Date
// Dynamic data — updated throughout the activity lifecycle
struct ContentState: Codable, Hashable {
let status: DeliveryStatus
let driverName: String
let currentStep: Int
let totalSteps: Int
let estimatedMinutesRemaining: Int
}
}
enum DeliveryStatus: String, Codable, Hashable {
case preparing
case pickedUp
case onTheWay
case nearbyDropoff
case delivered
var displayText: String {
switch self {
case .preparing: return "Preparing"
case .pickedUp: return "Picked Up"
case .onTheWay: return "On the Way"
case .nearbyDropoff: return "Almost There"
case .delivered: return "Delivered"
}
}
var systemImage: String {
switch self {
case .preparing: return "fork.knife"
case .pickedUp: return "bag.fill"
case .onTheWay: return "car.fill"
case .nearbyDropoff: return "mappin.and.ellipse"
case .delivered: return "checkmark.circle.fill"
}
}
}
Starting a Live Activity
Request a Live Activity by providing initial content and a stale date. The system enforces a limit of one active Live Activity per app on iPhone.
import ActivityKit
class DeliveryTracker {
var currentActivity: Activity<DeliveryAttributes>?
func startTracking(orderNumber: String, restaurant: String, eta: Date) throws {
// Check if Live Activities are enabled in Settings
guard ActivityAuthorizationInfo().areActivitiesEnabled else {
throw DeliveryError.activitiesDisabled
}
let attributes = DeliveryAttributes(
orderNumber: orderNumber,
restaurantName: restaurant,
estimatedDeliveryTime: eta
)
let initialState = DeliveryAttributes.ContentState(
status: .preparing,
driverName: "",
currentStep: 1,
totalSteps: 4,
estimatedMinutesRemaining: 45
)
let content = ActivityContent(
state: initialState,
staleDate: Calendar.current.date(byAdding: .minute, value: 15, to: .now),
relevanceScore: 75
)
do {
currentActivity = try Activity.request(
attributes: attributes,
content: content,
pushType: .token // Enable push-to-update; use nil for local-only
)
print("Live Activity started: \(currentActivity?.id ?? "nil")")
} catch {
throw DeliveryError.failedToStart(error)
}
}
}
Updating a Live Activity
Update the dynamic content state at any time while the activity is active.
extension DeliveryTracker {
func updateStatus(to status: DeliveryStatus, driver: String, step: Int, minutes: Int) async {
guard let activity = currentActivity else { return }
let updatedState = DeliveryAttributes.ContentState(
status: status,
driverName: driver,
currentStep: step,
totalSteps: 4,
estimatedMinutesRemaining: minutes
)
// Set a new stale date with each update
let staleDate = Calendar.current.date(byAdding: .minute, value: 10, to: .now)
let updatedContent = ActivityContent(
state: updatedState,
staleDate: staleDate,
relevanceScore: status == .nearbyDropoff ? 100 : 75
)
await activity.update(updatedContent)
}
}
Ending a Live Activity
End activities with a final content state. The
dismissalPolicy
controls how long the ended activity remains visible on the Lock Screen.
extension DeliveryTracker {
func markDelivered() async {
guard let activity = currentActivity else { return }
let finalState = DeliveryAttributes.ContentState(
status: .delivered,
driverName: "Marcus",
currentStep: 4,
totalSteps: 4,
estimatedMinutesRemaining: 0
)
let finalContent = ActivityContent(
state: finalState,
staleDate: nil
)
// .default: system decides when to remove (up to 4 hours)
// .immediate: remove right away
// .after(Date): remove after specified date
await activity.end(finalContent, dismissalPolicy: .default)
currentActivity = nil
}
func cancelActivity() async {
guard let activity = currentActivity else { return }
await activity.end(nil, dismissalPolicy: .immediate)
currentActivity = nil
}
}
Dynamic Island Presentations
Live Activities appear in three Dynamic Island presentations. All three must be implemented in the widget bundle.
import WidgetKit
import SwiftUI
struct DeliveryLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: DeliveryAttributes.self) { context in
// LOCK SCREEN / STANDBY presentation
LockScreenView(context: context)
} dynamicIsland: { context in
DynamicIsland {
// EXPANDED — shown when user long-presses the Dynamic Island
DynamicIslandExpandedRegion(.leading) {
Image(systemName: context.state.status.systemImage)
.font(.title2)
.foregroundStyle(.blue)
}
DynamicIslandExpandedRegion(.trailing) {
Text("\(context.state.estimatedMinutesRemaining) min")
.font(.headline)
.foregroundStyle(.secondary)
}
DynamicIslandExpandedRegion(.center) {
VStack(spacing: 4) {
Text(context.state.status.displayText)
.font(.headline)
Text(context.attributes.restaurantName)
.font(.caption)
.foregroundStyle(.secondary)
}
}
DynamicIslandExpandedRegion(.bottom) {
DeliveryProgressBar(
currentStep: context.state.currentStep,
totalSteps: context.state.totalSteps
)
.padding(.top, 4)
}
} compactLeading: {
// COMPACT LEADING — left side of the pill
Image(systemName: context.state.status.systemImage)
.foregroundStyle(.blue)
} compactTrailing: {
// COMPACT TRAILING — right side of the pill
Text("\(context.state.estimatedMinutesRemaining)m")
.font(.caption2)
.foregroundStyle(.secondary)
} minimal: {
// MINIMAL — shown when multiple Live Activities are active
Image(systemName: context.state.status.systemImage)
.foregroundStyle(.blue)
}
.keylineTint(.blue)
}
}
}
Lock Screen and Supporting Views
struct LockScreenView: View {
let context: ActivityViewContext<DeliveryAttributes>
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
VStack(alignment: .leading) {
Text(context.attributes.restaurantName)
.font(.headline)
Text("Order #\(context.attributes.orderNumber)")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Text(context.state.status.displayText)
.font(.subheadline.bold())
.padding(.horizontal, 10)
.padding(.vertical, 4)
.background(.blue.opacity(0.2))
.clipShape(Capsule())
}
DeliveryProgressBar(
currentStep: context.state.currentStep,
totalSteps: context.state.totalSteps
)
HStack {
if !context.state.driverName.isEmpty {
Label(context.state.driverName, systemImage: "person.fill")
.font(.caption)
}
Spacer()
if context.state.estimatedMinutesRemaining > 0 {
Text("~\(context.state.estimatedMinutesRemaining) min remaining")
.font(.caption)
.foregroundStyle(.secondary)
}
}
// Show a message when content is stale
if context.isStale {
Label("Updating...", systemImage: "arrow.clockwise")
.font(.caption2)
.foregroundStyle(.orange)
}
}
.padding()
.activityBackgroundTint(.black.opacity(0.7))
.activitySystemActionForegroundColor(.white)
}
}
struct DeliveryProgressBar: View {
let currentStep: Int
let totalSteps: Int
var body: some View {
HStack(spacing: 4) {
ForEach(1...totalSteps, id: \.self) { step in
Capsule()
.fill(step <= currentStep ? Color.blue : Color.gray.opacity(0.3))
.frame(height: 4)
}
}
}
}
Push-to-Update with Push Tokens
Register for push tokens to update Live Activities from your server. The token can change, so observe it continuously.
extension DeliveryTracker {
func observePushToken() {
guard let activity = currentActivity else { return }
Task {
for await pushToken in activity.pushTokenUpdates {
let tokenString = pushToken.map { String(format: "%02x", $0) }.joined()
print("Push token: \(tokenString)")
// Send token to your server
await sendTokenToServer(token: tokenString, activityID: activity.id)
}
}
}
private func sendTokenToServer(token: String, activityID: String) async {
guard let url = URL(string: "https://api.example.com/live-activity/register") else { return }
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let body: [String: String] = [
"activityId": activityID,
"pushToken": token
]
request.httpBody = try? JSONEncoder().encode(body)
_ = try? await URLSession.shared.data(for: request)
}
}
ActivityKit Push Notification Payload
Send this JSON payload from your server via APNs to update or end a Live Activity.
// APNs headers:
// apns-topic: <BundleID>.push-type.liveactivity
// apns-push-type: liveactivity
// Update payload
{
"aps": {
"timestamp": 1699900000,
"event": "update",
"content-state": {
"status": "onTheWay",
"driverName": "Marcus",
"currentStep": 3,
"totalSteps": 4,
"estimatedMinutesRemaining": 12
},
"stale-date": 1699900600,
"dismissal-date": 1699904200,
"alert": {
"title": "Delivery Update",
"body": "Your order is on the way!"
},
"sound": "default",
"relevance-score": 100
}
}
// End payload
{
"aps": {
"timestamp": 1699901000,
"event": "end",
"dismissal-date": 1699904600,
"content-state": {
"status": "delivered",
"driverName": "Marcus",
"currentStep": 4,
"totalSteps": 4,
"estimatedMinutesRemaining": 0
},
"alert": {
"title": "Order Delivered",
"body": "Your food has arrived. Enjoy!"
}
}
}
Timer and Progress Live Activities
Use
Text
with date-relative formatting for automatic countdown timers that the system updates without push notifications.
struct TimerLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: TimerAttributes.self) { context in
HStack {
VStack(alignment: .leading) {
Text(context.attributes.timerName)
.font(.headline)
// Automatic countdown timer — system updates this every second
Text(context.state.endTime, style: .timer)
.font(.system(.title, design: .monospaced))
.foregroundStyle(.blue)
}
Spacer()
// Relative time display: "in 5 min"
Text(context.state.endTime, style: .relative)
.font(.caption)
.foregroundStyle(.secondary)
}
.padding()
.activityBackgroundTint(.black)
} dynamicIsland: { context in
DynamicIsland {
DynamicIslandExpandedRegion(.center) {
Text(context.state.endTime, style: .timer)
.font(.system(.title, design: .monospaced))
}
} compactLeading: {
Image(systemName: "timer")
} compactTrailing: {
Text(context.state.endTime, style: .timer)
.frame(width: 50)
.font(.caption2.monospacedDigit())
} minimal: {
// Progress ring for minimal view
ProgressView(
timerInterval: context.state.startTime...context.state.endTime,
countsDown: true
) {
EmptyView()
}
.progressViewStyle(.circular)
.tint(.blue)
}
}
}
}
struct TimerAttributes: ActivityAttributes {
let timerName: String
struct ContentState: Codable, Hashable {
let startTime: Date
let endTime: Date
}
}
StaleDate and Dismissal Policy
Control how stale data is displayed and when ended activities are removed.
// Observe activity state changes across the app lifecycle
func observeAllActivities() {
Task {
// Monitor activities for this attribute type
for await activity in Activity<DeliveryAttributes>.activityUpdates {
print("New activity: \(activity.id)")
Task {
for await state in activity.activityStateUpdates {
switch state {
case .active:
print("Activity is active")
case .stale:
// Content has passed its staleDate — refresh it
print("Activity is stale — requesting update")
await refreshActivityFromServer(activity)
case .dismissed:
print("Activity was dismissed by user or system")
case .ended:
print("Activity has ended")
@unknown default:
break
}
}
}
}
}
}
func refreshActivityFromServer(_ activity: Activity<DeliveryAttributes>) async {
guard let freshState = await fetchLatestState(for: activity.attributes.orderNumber) else {
return
}
let content = ActivityContent(
state: freshState,
staleDate: Calendar.current.date(byAdding: .minute, value: 10, to: .now)
)
await activity.update(content)
}
func fetchLatestState(for orderNumber: String) async -> DeliveryAttributes.ContentState? {
// Fetch from your API
return nil
}
Complete Delivery Tracking Example
A full Livewire-style manager that coordinates the entire Live Activity lifecycle.
import ActivityKit
import Foundation
@MainActor
@Observable
class LiveDeliveryManager {
var isTracking = false
var currentStatus: DeliveryStatus = .preparing
private var activity: Activity<DeliveryAttributes>?
private var tokenObservationTask: Task<Void, Never>?
var areActivitiesEnabled: Bool {
ActivityAuthorizationInfo().areActivitiesEnabled
}
func startDelivery(order: String, restaurant: String, eta: Date) async throws {
guard areActivitiesEnabled else {
throw DeliveryError.activitiesDisabled
}
// End any existing activity first
if activity != nil {
await endDelivery()
}
let attributes = DeliveryAttributes(
orderNumber: order,
restaurantName: restaurant,
estimatedDeliveryTime: eta
)
let initialState = DeliveryAttributes.ContentState(
status: .preparing,
driverName: "",
currentStep: 1,
totalSteps: 4,
estimatedMinutesRemaining: 45
)
let content = ActivityContent(
state: initialState,
staleDate: Calendar.current.date(byAdding: .minute, value: 15, to: .now),
relevanceScore: 50
)
activity = try Activity.request(
attributes: attributes,
content: content,
pushType: .token
)
isTracking = true
currentStatus = .preparing
startObservingPushToken()
startObservingState()
}
func update(status: DeliveryStatus, driver: String, step: Int, minutes: Int) async {
guard let activity else { return }
let state = DeliveryAttributes.ContentState(
status: status,
driverName: driver,
currentStep: step,
totalSteps: 4,
estimatedMinutesRemaining: minutes
)
let content = ActivityContent(
state: state,
staleDate: Calendar.current.date(byAdding: .minute, value: 10, to: .now),
relevanceScore: status == .nearbyDropoff ? 100 : 75
)
await activity.update(content)
currentStatus = status
}
func endDelivery() async {
guard let activity else { return }
let finalState = DeliveryAttributes.ContentState(
status: .delivered,
driverName: "Driver",
currentStep: 4,
totalSteps: 4,
estimatedMinutesRemaining: 0
)
let content = ActivityContent(state: finalState, staleDate: nil)
await activity.end(content, dismissalPolicy: .after(
Calendar.current.date(byAdding: .hour, value: 1, to: .now)!
))
tokenObservationTask?.cancel()
self.activity = nil
isTracking = false
currentStatus = .delivered
}
private func startObservingPushToken() {
guard let activity else { return }
tokenObservationTask?.cancel()
tokenObservationTask = Task {
for await token in activity.pushTokenUpdates {
let tokenString = token.map { String(format: "%02x", $0) }.joined()
await registerToken(tokenString, activityID: activity.id)
}
}
}
private func startObservingState() {
guard let activity else { return }
Task {
for await state in activity.activityStateUpdates {
switch state {
case .dismissed, .ended:
self.isTracking = false
self.activity = nil
default:
break
}
}
}
}
private func registerToken(_ token: String, activityID: String) async {
// Send to your backend
print("Registering token \(token) for activity \(activityID)")
}
}
enum DeliveryError: LocalizedError {
case activitiesDisabled
case failedToStart(Error)
var errorDescription: String? {
switch self {
case .activitiesDisabled:
return "Live Activities are disabled in Settings."
case .failedToStart(let error):
return "Failed to start activity: \(error.localizedDescription)"
}
}
}
Widget Bundle Registration
Register the Live Activity widget alongside your other widgets.
import WidgetKit
import SwiftUI
@main
struct AppWidgets: WidgetBundle {
var body: some Widget {
DeliveryLiveActivity()
TimerLiveActivity()
// Other widgets...
}
}
Key Considerations
Size limit
: Live Activity UI is rendered at a fixed size; keep content concise.
Update frequency
: The system may throttle updates. Budget approximately one update per hour for push updates; local updates have a higher budget.
Stale date
: Always set a stale date so your UI can show a refresh indicator when data is old.
Push payload size
: The APNs payload for Live Activities must be under 4 KB.
Background
: Live Activities use
activityBackgroundTint
and
activitySystemActionForegroundColor
— standard SwiftUI background modifiers do not work.
Availability
: ActivityKit requires iOS 16.1+. Dynamic Island requires iPhone 14 Pro and later.
Info.plist
: Add
NSSupportsLiveActivities
set to
YES
in your app target's Info.plist.
---
# App Clips
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-app-clips.html
← Back to all frameworks & guides
All articles →Core UI and Apps · Reference guideApp ClipsRepository guidance for App Clips. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
App Clip Target Setup in Xcode
Invocation URLs and Advanced Matching
NFC Tag and QR Code Triggers
App Clip Card Configuration
Size Limitations (15 MB)
App Group Data Handoff to Full App
Location Confirmation with CLAppClipCodeLocation
SKOverlay for Full App Promotion
Complete App Clip Example
Key Considerations
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Resolve invocation URL
↓2
Load the focused experience
↓3
Complete the short task
↓4
Offer full-app continuity
02 / ArchitectureResponsibility boundariesBoundary 1
Invocation linkBoundary 2
App Clip targetBoundary 3
Shared domain logicConnected responsibilities, not a required class hierarchy or an execution trace.
App Clips are lightweight versions of your app that users can discover and launch instantly from NFC tags, QR codes, Safari Smart Banners, Maps, and Messages. They provide focused functionality without requiring a full app download, with a strict 15 MB size limit.
App Clip Target Setup in Xcode
An App Clip is a separate target in your Xcode project that shares code with the main app through shared frameworks or file membership.
// 1. In Xcode: File > New > Target > App Clip
// 2. The App Clip target gets its own bundle identifier:
// Main app: com.example.myapp
// App Clip: com.example.myapp.Clip
// 3. App Clip entry point — same as a regular SwiftUI app
import SwiftUI
@main
struct MyAppClip: App {
var body: some Scene {
WindowGroup {
AppClipRootView()
.onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in
// Handle the invocation URL
handleInvocation(activity)
}
}
}
private func handleInvocation(_ activity: NSUserActivity) {
guard let url = activity.webpageURL else { return }
// Parse the URL to determine what to show
// e.g., https://example.com/store/123 → show store 123
AppClipRouter.shared.route(to: url)
}
}
// 4. Share code between the main app and App Clip
// Use a shared framework or add files to both targets
// In Build Settings, set:
// _APP_CLIP = 1 (for the App Clip target)
// Conditional compilation for target-specific code
#if APPCLIP
let isAppClip = true
#else
let isAppClip = false
#endif
Invocation URLs and Advanced Matching
Configure invocation URLs in App Store Connect. Each URL maps to a specific App Clip experience.
import SwiftUI
// URL routing for the App Clip
@Observable
class AppClipRouter {
static let shared = AppClipRouter()
var currentExperience: AppClipExperience = .default
enum AppClipExperience {
case `default`
case store(storeID: String)
case product(productID: String)
case orderPickup(orderID: String)
}
func route(to url: URL) {
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
currentExperience = .default
return
}
let pathComponents = components.path.split(separator: "/").map(String.init)
// Match URL patterns:
// https://example.com/store/123 → Store experience
// https://example.com/product/abc → Product experience
// https://example.com/order/pickup/789 → Order pickup
switch (pathComponents.first, pathComponents.dropFirst().first) {
case ("store", let storeID?):
currentExperience = .store(storeID: storeID)
case ("product", let productID?):
currentExperience = .product(productID: productID)
case ("order", _):
if let orderID = pathComponents.last, pathComponents.contains("pickup") {
currentExperience = .orderPickup(orderID: orderID)
}
default:
currentExperience = .default
}
}
}
// Root view that renders based on the invocation URL
struct AppClipRootView: View {
var router = AppClipRouter.shared
var body: some View {
Group {
switch router.currentExperience {
case .default:
DefaultExperienceView()
case .store(let storeID):
StoreExperienceView(storeID: storeID)
case .product(let productID):
ProductExperienceView(productID: productID)
case .orderPickup(let orderID):
OrderPickupView(orderID: orderID)
}
}
}
}
NFC Tag and QR Code Triggers
App Clips can be triggered by NFC tags and QR codes that encode your registered invocation URL.
// NFC Tag Configuration:
// 1. Write an NDEF record to the NFC tag containing your invocation URL
// 2. The URL must match a registered App Clip experience in App Store Connect
// 3. Use Apple App Clip Codes (designed NFC + visual codes) for best UX
// QR Code Generation (for server or marketing team):
// The QR code simply encodes the invocation URL
// Example: https://appclip.example.com/store/downtown
// Reading NFC tag data within the App Clip (if needed)
import CoreNFC
class NFCReader: NSObject, NFCNDEFReaderSessionDelegate {
var session: NFCNDEFReaderSession?
func startScanning() {
guard NFCNDEFReaderSession.readingAvailable else {
print("NFC not available on this device")
return
}
session = NFCNDEFReaderSession(
delegate: self,
queue: nil,
invalidateAfterFirstRead: true
)
session?.alertMessage = "Hold your iPhone near the tag."
session?.begin()
}
func readerSession(_ session: NFCNDEFReaderSession, didDetectNDEFs messages: [NFCNDEFMessage]) {
for message in messages {
for record in message.records {
if let url = record.wellKnownTypeURIPayload() {
print("NFC URL: \(url)")
Task { @MainActor in
AppClipRouter.shared.route(to: url)
}
}
}
}
}
func readerSession(_ session: NFCNDEFReaderSession, didInvalidateWithError error: Error) {
print("NFC session invalidated: \(error.localizedDescription)")
}
}
App Clip Card Configuration
The App Clip Card is the system UI that appears before the App Clip launches. Configure it in App Store Connect.
// App Clip Card metadata is set in App Store Connect, not in code:
// - Header image: 3000 x 2000 px recommended
// - Title: Your app name or experience title
// - Subtitle: Brief description (up to 56 characters)
// - Call-to-action button: "Open" (default) or custom text
// In your app, provide metadata for the card via the associated website
// Add this to your webpage's <head>:
//
// <meta name="apple-itunes-app"
// content="app-id=123456789,
// app-clip-bundle-id=com.example.myapp.Clip,
// app-clip-display=card">
// Smart App Banner for Safari (also triggers App Clip Card)
// <meta name="apple-itunes-app" content="app-id=123456789">
// Programmatically check if running as App Clip
import StoreKit
struct AppClipBanner: View {
@State private var showingFullAppOverlay = false
var body: some View {
VStack {
Text("You're using the App Clip")
.font(.headline)
Text("Download the full app for all features.")
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
}
Size Limitations (15 MB)
App Clips must be under 15 MB (uncompressed, thinned for a specific device). Strategies to stay within the limit.
// Strategies to minimize App Clip size:
// 1. Share only necessary code — don't include unused frameworks
// In Build Phases, only include files the App Clip needs
// 2. Use on-demand resources for images and assets
// In the asset catalog, assign assets to "App Clip" tag
// Load them at runtime:
import Foundation
func loadOnDemandImage(tag: String) async throws -> Data {
let request = NSBundleResourceRequest(tags: [tag])
try await request.beginAccessingResources()
// Access the resource
guard let url = Bundle.main.url(forResource: "hero", withExtension: "jpg") else {
throw AppClipError.resourceNotFound
}
let data = try Data(contentsOf: url)
request.endAccessingResources()
return data
}
enum AppClipError: Error {
case resourceNotFound
}
// 3. Use SF Symbols instead of custom images where possible
// 4. Use system fonts instead of bundled custom fonts
// 5. Remove unused localizations
// 6. Use Asset Catalog slicing for images
// 7. Check size: Product > Archive > Distribute App > App Thinning report
// 8. Verify the thinned size:
// $ xcrun app-clip-size --app-clip-path path/to/MyAppClip.app
App Group Data Handoff to Full App
Share data from the App Clip to the full app using App Groups so users don't lose progress.
import Foundation
// Both the App Clip and main app must have the same App Group entitlement:
// group.com.example.myapp.shared
struct SharedDataManager {
static let suiteName = "group.com.example.myapp.shared"
// Save data from App Clip for the full app to read
static func saveFromAppClip(userPreferences: UserPreferences) {
guard let defaults = UserDefaults(suiteName: suiteName) else { return }
if let data = try? JSONEncoder().encode(userPreferences) {
defaults.set(data, forKey: "userPreferences")
}
defaults.set(true, forKey: "hasAppClipData")
defaults.set(Date(), forKey: "appClipLastUsed")
}
// Read App Clip data from the full app
static func loadAppClipData() -> UserPreferences? {
guard let defaults = UserDefaults(suiteName: suiteName) else { return nil }
guard defaults.bool(forKey: "hasAppClipData") else { return nil }
guard let data = defaults.data(forKey: "userPreferences") else { return nil }
return try? JSONDecoder().decode(UserPreferences.self, from: data)
}
// Share files via the shared container
static var sharedContainerURL: URL? {
FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: suiteName)
}
static func saveOrderData(_ order: Order) throws {
guard let containerURL = sharedContainerURL else {
throw AppClipError.resourceNotFound
}
let fileURL = containerURL.appendingPathComponent("pending_order.json")
let data = try JSONEncoder().encode(order)
try data.write(to: fileURL)
}
static func loadPendingOrder() throws -> Order? {
guard let containerURL = sharedContainerURL else { return nil }
let fileURL = containerURL.appendingPathComponent("pending_order.json")
guard FileManager.default.fileExists(atPath: fileURL.path) else { return nil }
let data = try Data(contentsOf: fileURL)
return try JSONDecoder().decode(Order.self, from: data)
}
}
struct UserPreferences: Codable {
var favoriteStoreID: String?
var preferredPaymentMethod: String?
var hasCompletedOnboarding: Bool
}
struct Order: Codable {
let id: String
let items: [OrderItem]
let total: Decimal
let storeID: String
}
struct OrderItem: Codable {
let name: String
let quantity: Int
let price: Decimal
}
Location Confirmation with CLAppClipCodeLocation
Verify that the user is physically present at the expected location to prevent relay attacks.
import AppClip
import CoreLocation
class LocationVerifier {
func verifyLocation(for activity: NSUserActivity) async -> Bool {
// Check if the invocation included location data
guard let payload = activity.appClipActivationPayload else {
print("No App Clip activation payload")
return false
}
// Define the expected region (set in App Store Connect)
let expectedCenter = CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194)
let expectedRegion = CLCircularRegion(
center: expectedCenter,
radius: 100, // meters
identifier: "store-downtown"
)
do {
try await payload.confirmAcquired(in: expectedRegion)
print("Location confirmed — user is at the expected location")
return true
} catch let error as APActivationPayloadError {
switch error.code {
case .disallowed:
print("User denied location access")
case .doesNotMatch:
print("User is not at the expected location")
@unknown default:
print("Unknown location error: \(error)")
}
return false
} catch {
print("Location verification failed: \(error)")
return false
}
}
}
// Use in the App Clip's onContinueUserActivity handler
struct LocationAwareAppClip: View {
@State private var isVerified = false
@State private var isVerifying = true
let verifier = LocationVerifier()
var body: some View {
Group {
if isVerifying {
ProgressView("Verifying location...")
} else if isVerified {
StoreExperienceView(storeID: "downtown")
} else {
ContentUnavailableView(
"Location Required",
systemImage: "location.slash",
description: Text("Please visit the store to use this App Clip.")
)
}
}
.onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in
Task {
isVerified = await verifier.verifyLocation(for: activity)
isVerifying = false
}
}
}
}
SKOverlay for Full App Promotion
Show an App Store overlay that encourages users to download the full app.
import StoreKit
import SwiftUI
// SwiftUI approach using appStoreOverlay
struct AppClipWithOverlay: View {
@State private var showOverlay = false
var body: some View {
VStack(spacing: 20) {
Text("Thanks for your order!")
.font(.title.bold())
Text("Download the full app to earn rewards, track orders, and more.")
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal)
Button("Get the Full App") {
showOverlay = true
}
.buttonStyle(.borderedProminent)
}
.appStoreOverlay(isPresented: $showOverlay) {
SKOverlay.AppClipConfiguration(position: .bottom)
}
.onAppear {
// Show overlay automatically after a delay
Task {
try? await Task.sleep(for: .seconds(3))
showOverlay = true
}
}
}
}
// UIKit approach for more control
import UIKit
class AppClipOverlayViewController: UIViewController, SKOverlayDelegate {
private var overlay: SKOverlay?
func presentFullAppOverlay() {
let config = SKOverlay.AppClipConfiguration(position: .bottom)
overlay = SKOverlay(configuration: config)
overlay?.delegate = self
guard let windowScene = view.window?.windowScene else { return }
overlay?.present(in: windowScene)
}
func dismissOverlay() {
guard let windowScene = view.window?.windowScene else { return }
SKOverlay.dismiss(in: windowScene)
}
// SKOverlayDelegate
func storeOverlayDidFinishDismissal(_ overlay: SKOverlay, transitionContext: SKOverlay.TransitionContext) {
print("Overlay dismissed")
}
func storeOverlayDidFinishPresentation(_ overlay: SKOverlay, transitionContext: SKOverlay.TransitionContext) {
print("Overlay presented")
}
func storeOverlay(_ overlay: SKOverlay, didFailToLoadWithError error: Error) {
print("Overlay failed to load: \(error)")
}
}
Complete App Clip Example
A full coffee shop ordering App Clip that demonstrates invocation handling, location verification, ordering flow, data handoff, and full app promotion.
import SwiftUI
import AppClip
import StoreKit
// MARK: - App Entry Point
@main
struct CoffeeShopClip: App {
@State private var router = ClipRouter()
var body: some Scene {
WindowGroup {
ClipContentView()
.environment(router)
.onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in
router.handleInvocation(activity)
}
}
}
}
// MARK: - Router
@Observable
class ClipRouter {
var shopID: String?
var isLocationVerified = false
var isLoading = true
func handleInvocation(_ activity: NSUserActivity) {
guard let url = activity.webpageURL,
let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
isLoading = false
return
}
// Parse shop ID from URL: https://coffeeapp.example.com/shop/downtown
let parts = components.path.split(separator: "/").map(String.init)
if let shopIndex = parts.firstIndex(of: "shop"),
shopIndex + 1 < parts.count {
shopID = parts[shopIndex + 1]
}
// Verify location
Task {
await verifyLocation(activity)
isLoading = false
}
}
private func verifyLocation(_ activity: NSUserActivity) async {
guard let payload = activity.appClipActivationPayload else {
isLocationVerified = true // Allow without location for testing
return
}
let region = CLCircularRegion(
center: CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194),
radius: 200,
identifier: "shop"
)
do {
try await payload.confirmAcquired(in: region)
isLocationVerified = true
} catch {
isLocationVerified = false
}
}
}
// MARK: - Content View
struct ClipContentView: View {
@Environment(ClipRouter.self) private var router
var body: some View {
Group {
if router.isLoading {
ProgressView("Loading...")
} else if let shopID = router.shopID {
CoffeeOrderView(shopID: shopID)
} else {
ContentUnavailableView(
"Scan to Order",
systemImage: "qrcode.viewfinder",
description: Text("Scan a QR code at a participating coffee shop to start ordering.")
)
}
}
}
}
// MARK: - Order View
struct CoffeeOrderView: View {
let shopID: String
@State private var menuItems: [MenuItem] = MenuItem.sampleMenu
@State private var cart: [CartItem] = []
@State private var showCheckout = false
@State private var showFullAppOverlay = false
var cartTotal: Decimal {
cart.reduce(0) { $0 + $1.menuItem.price * Decimal($1.quantity) }
}
var body: some View {
NavigationStack {
List {
Section("Menu") {
ForEach(menuItems) { item in
MenuItemRow(item: item) {
addToCart(item)
}
}
}
if !cart.isEmpty {
Section("Your Order") {
ForEach(cart) { cartItem in
HStack {
Text(cartItem.menuItem.name)
Spacer()
Text("\(cartItem.quantity)x")
.foregroundStyle(.secondary)
Text("$\(cartItem.menuItem.price * Decimal(cartItem.quantity))")
.fontWeight(.medium)
}
}
}
}
}
.navigationTitle("Order Coffee")
.toolbar {
ToolbarItem(placement: .bottomBar) {
if !cart.isEmpty {
Button {
showCheckout = true
} label: {
HStack {
Text("Checkout")
Spacer()
Text("$\(cartTotal)")
}
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
}
}
}
.sheet(isPresented: $showCheckout) {
CheckoutView(
cart: cart,
total: cartTotal,
shopID: shopID,
onComplete: {
showFullAppOverlay = true
saveOrderForFullApp()
}
)
}
.appStoreOverlay(isPresented: $showFullAppOverlay) {
SKOverlay.AppClipConfiguration(position: .bottom)
}
}
}
private func addToCart(_ item: MenuItem) {
if let index = cart.firstIndex(where: { $0.menuItem.id == item.id }) {
cart[index].quantity += 1
} else {
cart.append(CartItem(menuItem: item, quantity: 1))
}
}
private func saveOrderForFullApp() {
let order = Order(
id: UUID().uuidString,
items: cart.map { OrderItem(name: $0.menuItem.name, quantity: $0.quantity, price: $0.menuItem.price) },
total: cartTotal,
storeID: shopID
)
try? SharedDataManager.saveOrderData(order)
}
}
// MARK: - Checkout View
struct CheckoutView: View {
let cart: [CartItem]
let total: Decimal
let shopID: String
let onComplete: () -> Void
@Environment(\.dismiss) private var dismiss
@State private var isProcessing = false
@State private var orderComplete = false
var body: some View {
NavigationStack {
VStack(spacing: 24) {
if orderComplete {
VStack(spacing: 16) {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 60))
.foregroundStyle(.green)
Text("Order Placed!")
.font(.title.bold())
Text("Your order will be ready in 5-10 minutes.")
.foregroundStyle(.secondary)
}
.padding(.top, 40)
} else {
List {
Section("Order Summary") {
ForEach(cart) { item in
HStack {
Text("\(item.quantity)x \(item.menuItem.name)")
Spacer()
Text("$\(item.menuItem.price * Decimal(item.quantity))")
}
}
}
Section {
HStack {
Text("Total")
.fontWeight(.bold)
Spacer()
Text("$\(total)")
.fontWeight(.bold)
}
}
}
Button {
placeOrder()
} label: {
if isProcessing {
ProgressView()
.frame(maxWidth: .infinity)
} else {
Text("Pay $\(total)")
.frame(maxWidth: .infinity)
}
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
.disabled(isProcessing)
.padding()
}
Spacer()
}
.navigationTitle(orderComplete ? "Confirmed" : "Checkout")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Close") {
dismiss()
}
}
}
}
}
private func placeOrder() {
isProcessing = true
Task {
// Simulate payment processing
try? await Task.sleep(for: .seconds(2))
isProcessing = false
orderComplete = true
onComplete()
}
}
}
// MARK: - Supporting Views and Models
struct MenuItemRow: View {
let item: MenuItem
let onAdd: () -> Void
var body: some View {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(item.name)
.font(.headline)
Text(item.itemDescription)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Text("$\(item.price)")
.font(.subheadline.bold())
Button {
onAdd()
} label: {
Image(systemName: "plus.circle.fill")
.font(.title3)
}
.buttonStyle(.plain)
.foregroundStyle(.blue)
}
}
}
struct MenuItem: Identifiable {
let id = UUID()
let name: String
let itemDescription: String
let price: Decimal
static var sampleMenu: [MenuItem] {
[
MenuItem(name: "Espresso", itemDescription: "Rich single shot", price: 3.50),
MenuItem(name: "Latte", itemDescription: "Espresso with steamed milk", price: 5.00),
MenuItem(name: "Cold Brew", itemDescription: "Smooth, cold-steeped coffee", price: 4.50),
MenuItem(name: "Matcha Latte", itemDescription: "Ceremonial grade matcha", price: 5.50),
MenuItem(name: "Croissant", itemDescription: "Buttery, flaky pastry", price: 3.75)
]
}
}
struct CartItem: Identifiable {
let id = UUID()
let menuItem: MenuItem
var quantity: Int
}
Key Considerations
Size limit
: App Clips must be under 15 MB (thinned, uncompressed). Check with
xcrun app-clip-size
.
Capabilities
: App Clips support Sign in with Apple, Apple Pay, notifications (for 8 hours after launch), and App Groups. They cannot access HealthKit, CallKit, or perform background networking.
Data persistence
: App Clip data may be deleted by the system after a period of inactivity. Use App Groups to hand off important data to the full app.
Invocation URLs
: Register all URLs in App Store Connect. URLs must use HTTPS. Each URL maps to one App Clip experience.
Apple App Clip Codes
: These are Apple-designed visual codes that combine NFC and visual scanning. Generate them in App Store Connect.
Notifications
: App Clips can request notification permission, but it expires 8 hours after last launch. Encourage users to download the full app for persistent notifications.
Sign in with Apple
: Credentials are shared between the App Clip and full app if both use the same team ID.
Testing
: Use the
_XCAppClipURL
environment variable in the Xcode scheme to simulate invocation URLs during development.
---
# App Intents
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-app-intents.html
← Back to all frameworks & guides
All articles →Core UI and Apps · Reference guideApp IntentsRepository guidance for App Intents. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
AppIntent Protocol and perform()
@Parameter Property Wrapper
IntentDialog and IntentResult
AppShortcutsProvider for Siri and Shortcuts
EntityQuery for Spotlight Integration
AppIntents for Widgets (WidgetConfigurationIntent)
AppIntents for Focus Filters
Apple Intelligence Integration (SiriKit to App Intents Migration)
Interactive Widget Buttons with App Intents
Key Considerations
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Define a user action
↓2
Resolve typed parameters
↓3
Execute domain operation
↓4
Return a useful result
02 / ArchitectureResponsibility boundariesBoundary 1
Intent adapterBoundary 2
Domain serviceBoundary 3
Entities and resultsConnected responsibilities, not a required class hierarchy or an execution trace.
App Intents is Apple's modern framework for exposing app functionality to Siri, Shortcuts, Spotlight, Widgets, Focus Filters, and Apple Intelligence. It replaces SiriKit Intents with a Swift-native, protocol-driven approach that requires no intent definition files.
AppIntent Protocol and perform()
Every App Intent conforms to the
AppIntent
protocol and implements a
perform()
method that returns an
IntentResult
.
import AppIntents
struct OpenArticleIntent: AppIntent {
// Title shown in Shortcuts and Siri
static var title: LocalizedStringResource = "Open Article"
static var description: IntentDescription = "Opens a specific article in the app."
// The system can open your app when this intent runs
static var openAppWhenRun: Bool = true
@Parameter(title: "Article Name")
var articleName: String
@MainActor
func perform() async throws -> some IntentResult & ProvidesDialog {
// Look up the article and navigate to it
guard let article = ArticleStore.shared.find(byName: articleName) else {
throw ArticleError.notFound(articleName)
}
NavigationManager.shared.navigate(to: article)
return .result(dialog: "Opening \"\(article.title)\"")
}
}
enum ArticleError: Error, CustomLocalizedStringResourceConvertible {
case notFound(String)
var localizedStringResource: LocalizedStringResource {
switch self {
case .notFound(let name):
return "Could not find article \"\(name)\""
}
}
}
@Parameter Property Wrapper
Parameters define the inputs for your intent. They support default values, validation, and dynamic options.
import AppIntents
struct CreateReminderIntent: AppIntent {
static var title: LocalizedStringResource = "Create Reminder"
@Parameter(title: "Title", description: "The reminder title")
var title: String
@Parameter(title: "Due Date", description: "When the reminder is due")
var dueDate: Date?
@Parameter(
title: "Priority",
description: "Reminder priority level",
default: .medium
)
var priority: ReminderPriority
@Parameter(
title: "Tags",
description: "Tags to apply",
optionsProvider: TagOptionsProvider()
)
var tags: [String]
func perform() async throws -> some IntentResult & ProvidesDialog {
let reminder = Reminder(
title: title,
dueDate: dueDate,
priority: priority,
tags: tags
)
try await ReminderStore.shared.save(reminder)
return .result(dialog: "Created reminder: \(title)")
}
}
// Enum parameter with automatic case display
enum ReminderPriority: String, AppEnum {
case low, medium, high, urgent
static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Priority")
static var caseDisplayRepresentations: [ReminderPriority: DisplayRepresentation] = [
.low: "Low",
.medium: "Medium",
.high: "High",
.urgent: "Urgent"
]
}
// Dynamic options provider for parameter suggestions
struct TagOptionsProvider: DynamicOptionsProvider {
func results() async throws -> [String] {
await TagStore.shared.allTags().map(\.name)
}
}
IntentDialog and IntentResult
Intents return results that can include dialogs, snippets (SwiftUI views), and values.
import AppIntents
import SwiftUI
struct CheckWeatherIntent: AppIntent {
static var title: LocalizedStringResource = "Check Weather"
@Parameter(title: "City")
var city: String
// Return multiple result types with protocols
func perform() async throws -> some IntentResult & ProvidesDialog & ShowsSnippetView & ReturnsValue<String> {
let weather = try await WeatherService.shared.fetch(for: city)
let summary = "\(weather.condition) — \(weather.temperature)°F"
return .result(
value: summary,
dialog: IntentDialog(stringLiteral: "It's \(summary) in \(city)."),
view: WeatherSnippetView(weather: weather)
)
}
}
struct WeatherSnippetView: View {
let weather: WeatherData
var body: some View {
HStack(spacing: 16) {
Image(systemName: weather.symbolName)
.font(.largeTitle)
.foregroundStyle(.blue)
VStack(alignment: .leading) {
Text("\(weather.temperature)°F")
.font(.title.bold())
Text(weather.condition)
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
.padding()
}
}
struct WeatherData {
let temperature: Int
let condition: String
let symbolName: String
}
AppShortcutsProvider for Siri and Shortcuts
Expose your intents as system shortcuts that appear in Siri, the Shortcuts app, and Spotlight without user configuration.
import AppIntents
struct AppShortcuts: AppShortcutsProvider {
// The App Shortcuts the system surfaces automatically
static var appShortcuts: [AppShortcut] {
AppShortcut(
intent: OpenArticleIntent(),
phrases: [
"Open \(\.$articleName) in \(.applicationName)",
"Show article \(\.$articleName) in \(.applicationName)",
"Read \(\.$articleName) with \(.applicationName)"
],
shortTitle: "Open Article",
systemImageName: "doc.richtext"
)
AppShortcut(
intent: CreateReminderIntent(),
phrases: [
"Create a reminder in \(.applicationName)",
"Add a reminder with \(.applicationName)",
"Remind me in \(.applicationName)"
],
shortTitle: "Create Reminder",
systemImageName: "checklist"
)
AppShortcut(
intent: CheckWeatherIntent(),
phrases: [
"Check weather in \(\.$city) with \(.applicationName)",
"What's the weather in \(\.$city)"
],
shortTitle: "Check Weather",
systemImageName: "cloud.sun"
)
}
}
EntityQuery for Spotlight Integration
Entities represent searchable objects in your app. EntityQuery lets Spotlight and the system find and display them.
import AppIntents
import CoreSpotlight
// Define an entity that Spotlight can index and display
struct ArticleEntity: AppEntity {
static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Article")
static var defaultQuery = ArticleQuery()
var id: String
var title: String
var summary: String
var category: String
var displayRepresentation: DisplayRepresentation {
DisplayRepresentation(
title: LocalizedStringResource(stringLiteral: title),
subtitle: LocalizedStringResource(stringLiteral: category),
image: .init(systemName: "doc.richtext")
)
}
}
// Query that the system uses to find entities
struct ArticleQuery: EntityQuery {
// Find entities by their identifiers
func entities(for identifiers: [String]) async throws -> [ArticleEntity] {
let articles = await ArticleStore.shared.fetchAll()
return articles
.filter { identifiers.contains($0.id) }
.map { ArticleEntity(id: $0.id, title: $0.title, summary: $0.summary, category: $0.category) }
}
// Provide suggestions when the user is picking an entity
func suggestedEntities() async throws -> [ArticleEntity] {
let recent = await ArticleStore.shared.fetchRecent(limit: 10)
return recent.map {
ArticleEntity(id: $0.id, title: $0.title, summary: $0.summary, category: $0.category)
}
}
}
// Enable string-based search for entities
extension ArticleQuery: EntityStringQuery {
func entities(matching query: String) async throws -> [ArticleEntity] {
let results = await ArticleStore.shared.search(query)
return results.map {
ArticleEntity(id: $0.id, title: $0.title, summary: $0.summary, category: $0.category)
}
}
}
// Use entities in intents
struct ReadArticleIntent: AppIntent {
static var title: LocalizedStringResource = "Read Article"
@Parameter(title: "Article")
var article: ArticleEntity
static var openAppWhenRun: Bool = true
@MainActor
func perform() async throws -> some IntentResult & ProvidesDialog {
NavigationManager.shared.navigateToArticle(id: article.id)
return .result(dialog: "Opening \"\(article.title)\"")
}
}
AppIntents for Widgets (WidgetConfigurationIntent)
Use
WidgetConfigurationIntent
to let users configure widgets through the App Intents system.
import AppIntents
import WidgetKit
import SwiftUI
// Widget configuration intent — users pick options in the widget editor
struct SelectCategoryIntent: WidgetConfigurationIntent {
static var title: LocalizedStringResource = "Select Category"
static var description: IntentDescription = "Choose which category to display."
@Parameter(title: "Category", default: .all)
var category: WidgetCategory
@Parameter(title: "Show Count", default: true)
var showCount: Bool
@Parameter(title: "Max Items", default: 5)
var maxItems: Int
}
enum WidgetCategory: String, AppEnum {
case all, favorites, recent, trending
static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Category")
static var caseDisplayRepresentations: [WidgetCategory: DisplayRepresentation] = [
.all: DisplayRepresentation(title: "All", image: .init(systemName: "square.grid.2x2")),
.favorites: DisplayRepresentation(title: "Favorites", image: .init(systemName: "star.fill")),
.recent: DisplayRepresentation(title: "Recent", image: .init(systemName: "clock")),
.trending: DisplayRepresentation(title: "Trending", image: .init(systemName: "flame"))
]
}
// Widget using the configuration intent
struct CategoryWidget: Widget {
let kind = "CategoryWidget"
var body: some WidgetConfiguration {
AppIntentConfiguration(
kind: kind,
intent: SelectCategoryIntent.self,
provider: CategoryProvider()
) { entry in
CategoryWidgetView(entry: entry)
.containerBackground(.fill.tertiary, for: .widget)
}
.configurationDisplayName("Category")
.description("Shows items from a selected category.")
.supportedFamilies([.systemSmall, .systemMedium])
}
}
struct CategoryEntry: TimelineEntry {
let date: Date
let category: WidgetCategory
let items: [String]
let showCount: Bool
}
struct CategoryProvider: AppIntentTimelineProvider {
func placeholder(in context: Context) -> CategoryEntry {
CategoryEntry(date: .now, category: .all, items: ["Loading..."], showCount: true)
}
func snapshot(for configuration: SelectCategoryIntent, in context: Context) async -> CategoryEntry {
CategoryEntry(date: .now, category: configuration.category, items: ["Sample Item"], showCount: configuration.showCount)
}
func timeline(for configuration: SelectCategoryIntent, in context: Context) async -> Timeline<CategoryEntry> {
let items = await fetchItems(for: configuration.category, limit: configuration.maxItems)
let entry = CategoryEntry(
date: .now,
category: configuration.category,
items: items,
showCount: configuration.showCount
)
let nextUpdate = Calendar.current.date(byAdding: .hour, value: 1, to: .now)!
return Timeline(entries: [entry], policy: .after(nextUpdate))
}
private func fetchItems(for category: WidgetCategory, limit: Int) async -> [String] {
return ["Item 1", "Item 2", "Item 3"]
}
}
struct CategoryWidgetView: View {
let entry: CategoryEntry
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(entry.category.rawValue.capitalized)
.font(.headline)
if entry.showCount {
Spacer()
Text("\(entry.items.count)")
.font(.caption.bold())
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(.blue.opacity(0.2))
.clipShape(Capsule())
}
}
ForEach(entry.items, id: \.self) { item in
Text(item)
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
}
}
AppIntents for Focus Filters
Let users customize your app's behavior when a specific Focus mode is active.
import AppIntents
struct AppFocusFilter: SetFocusFilterIntent {
static var title: LocalizedStringResource = "Set App Focus Filter"
static var description: IntentDescription = "Customize which content is shown during Focus."
@Parameter(title: "Show Notifications", default: true)
var showNotifications: Bool
@Parameter(title: "Category Filter")
var category: WidgetCategory?
@Parameter(title: "Mute Sounds", default: false)
var muteSounds: Bool
// The display representation shown in Focus settings
var displayRepresentation: DisplayRepresentation {
DisplayRepresentation(
title: "App Focus",
subtitle: category.map { "Showing \($0.rawValue)" } ?? "All content",
image: .init(systemName: "app.badge")
)
}
func perform() async throws -> some IntentResult {
// Apply the focus configuration to your app
await FocusManager.shared.apply(
showNotifications: showNotifications,
category: category,
muteSounds: muteSounds
)
return .result()
}
}
Apple Intelligence Integration (SiriKit to App Intents Migration)
App Intents is the foundation for Apple Intelligence features. Migrate from SiriKit to App Intents to enable AI-powered interactions.
import AppIntents
// Assistive intent that Apple Intelligence can invoke contextually
struct SendMessageIntent: AppIntent {
static var title: LocalizedStringResource = "Send Message"
static var description: IntentDescription = IntentDescription(
"Send a message to a contact.",
categoryName: "Messaging"
)
// Apple Intelligence can extract these from natural language
@Parameter(title: "Recipient")
var recipient: ContactEntity
@Parameter(title: "Message")
var message: String
// Confirmation dialog before performing a sensitive action
static var isDiscoverable: Bool = true
func perform() async throws -> some IntentResult & ProvidesDialog {
try await MessageService.shared.send(message, to: recipient.id)
return .result(dialog: "Message sent to \(recipient.name).")
}
}
// Contact entity for Apple Intelligence to reference
struct ContactEntity: AppEntity {
static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Contact")
static var defaultQuery = ContactQuery()
var id: String
var name: String
var email: String
var displayRepresentation: DisplayRepresentation {
DisplayRepresentation(
title: LocalizedStringResource(stringLiteral: name),
subtitle: LocalizedStringResource(stringLiteral: email)
)
}
}
struct ContactQuery: EntityQuery {
func entities(for identifiers: [String]) async throws -> [ContactEntity] {
await ContactStore.shared.fetch(ids: identifiers).map {
ContactEntity(id: $0.id, name: $0.name, email: $0.email)
}
}
func suggestedEntities() async throws -> [ContactEntity] {
await ContactStore.shared.frequentContacts(limit: 10).map {
ContactEntity(id: $0.id, name: $0.name, email: $0.email)
}
}
}
extension ContactQuery: EntityStringQuery {
func entities(matching query: String) async throws -> [ContactEntity] {
await ContactStore.shared.search(query).map {
ContactEntity(id: $0.id, name: $0.name, email: $0.email)
}
}
}
Interactive Widget Buttons with App Intents
Use App Intents to make widgets interactive with tappable buttons and toggles.
import AppIntents
import SwiftUI
import WidgetKit
// Intent performed when user taps a widget button
struct ToggleFavoriteIntent: AppIntent {
static var title: LocalizedStringResource = "Toggle Favorite"
@Parameter(title: "Item ID")
var itemID: String
init() {}
init(itemID: String) {
self.itemID = itemID
}
func perform() async throws -> some IntentResult {
await ItemStore.shared.toggleFavorite(id: itemID)
return .result()
}
}
// Widget view with an interactive button
struct InteractiveWidgetView: View {
let item: WidgetItem
var body: some View {
VStack {
Text(item.name)
.font(.headline)
// This button runs the intent directly from the widget
Button(intent: ToggleFavoriteIntent(itemID: item.id)) {
Image(systemName: item.isFavorite ? "star.fill" : "star")
.foregroundStyle(item.isFavorite ? .yellow : .gray)
}
.buttonStyle(.plain)
}
}
}
struct WidgetItem {
let id: String
let name: String
let isFavorite: Bool
}
Key Considerations
Availability
: App Intents requires iOS 16+. WidgetConfigurationIntent and Focus Filters require iOS 16+. AppShortcutsProvider requires iOS 16.4+.
Thread safety
: The
perform()
method can run on any thread. Use
@MainActor
when accessing UI state.
Error display
: Throw errors conforming to
CustomLocalizedStringResourceConvertible
so Siri and Shortcuts display meaningful messages.
Phrases
: Include
\(.applicationName)
in at least one phrase per AppShortcut so Siri associates the phrase with your app.
Testing
: Use the Shortcuts app and Siri to test intents. Use the
xcrun simctl
command to trigger intents in the simulator.
Migration
: SiriKit INIntent definitions can coexist with App Intents. Migrate incrementally by implementing the same functionality with App Intents and marking the SiriKit version as deprecated.
---
# Apple Intelligence
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-apple-intelligence.html
← Back to all frameworks & guides
All articles →AI and Machine Learning · Reference guideApple IntelligenceRepository guidance for Apple Intelligence. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
1. Which framework does what
2. The privacy model
What you may claim
Declare it
3. App Intents — the front door to Siri
4. Image Playground
5. Visual Intelligence
6. Designing an AI feature that degrades
Anti-Patterns
Checklist
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Identify an intelligence feature
↓2
Check device and model readiness
↓3
Run a bounded request
↓4
Offer a non-AI fallback
02 / ArchitectureResponsibility boundariesBoundary 1
Feature interfaceBoundary 2
Capability boundaryBoundary 3
Model or fallbackConnected responsibilities, not a required class hierarchy or an execution trace.
Load this when:
deciding how an app should surface AI features, integrating
with Siri and system intelligence, using Private Cloud Compute, generating
images, or reasoning about the privacy guarantees you can state to users.
Apple Intelligence is the system layer, not a single API. Apps reach it through
several frameworks; this document routes you to the right one and covers the
privacy model that governs all of them.
1. Which framework does what
You want to…
Use
Doc
Run a prompt, generate structured data, call tools
Foundation Models
foundation-models.md
Let Siri and Spotlight invoke your app's actions
App Intents
app-intents.md
Generate images
Image Playground API
this doc, §4
Identify things in images and act on them
Visual Intelligence / VisionKit
this doc, §5
Run your own ML model
Core ML
ml/coreml.md
OCR, detection, segmentation
Vision
ml/vision.md
The most common mistake is reaching for a language model when App Intents is
the answer.
If the user's goal is "do a thing in my app," that is an intent —
it is faster, deterministic, testable, and works without a model being available.
Use Foundation Models for open-ended generation, not for dispatching actions.
2. The privacy model
This is the part you must get right, because it determines what you may tell
users.
On-device (SystemLanguageModel)
- The prompt never leaves the device.
- Works offline. No account, no API key, no cost.
- Bounded by device capability: smaller context, lighter reasoning.
Private Cloud Compute (PrivateCloudComputeLanguageModel, iOS 27+)
- Runs on Apple silicon servers built for this purpose.
-
Prompts are not retained.
Data is used to serve the request and nothing else.
- No account setup, authentication, or API key required.
- Requires a network. Adds latency.
- Free for developers under the small-business download threshold.
Third-party models
(via a custom
LanguageModel
conformance)
-
Apple's privacy guarantees do not extend to these.
A prompt sent to a
third-party endpoint is governed by that vendor's terms.
- If your app can route to one, say so in your privacy policy and — where the
content is sensitive — in the UI at the point of use.
What you may claim
On-device only "Processed on your device." ✅
Private Cloud Compute "Processed by Apple. Your data isn't stored." ✅
Third-party model "Processed on your device." ❌ false
Any path "We never see your data" (if you log prompts) ❌ false
Do not log prompt or response content to your own analytics and simultaneously
describe the feature as private. If you log, say so.
Declare it
User-facing AI features that process personal data need the corresponding entries
in
PrivacyInfo.xcprivacy
, and any third-party model SDK carries its own privacy
manifest requirements. See
docs/design/interaction-standards.md
§7.
3. App Intents — the front door to Siri
App Intents is how Apple Intelligence discovers what your app can do. It is not
optional plumbing; it is the integration surface.
struct AddRecipeIntent: AppIntent {
static let title: LocalizedStringResource = "Add Recipe"
static let description = IntentDescription("Saves a recipe to the user's collection.")
@Parameter(title: "Name")
var name: String
@MainActor
func perform() async throws -> some IntentResult & ProvidesDialog {
try await RecipeStore.shared.add(named: name)
return .result(dialog: "Saved \(name).")
}
}
Points that matter:
Adopt system-defined schemas
where one fits your domain. A schema-conforming
intent gets natural-language handling for free, and does not depend on you
guessing phrasings.
Do not hardcode invocation phrases.
The system maps language to intents and
keeps improving; hardcoded phrases freeze you at today's behavior.
Index your content
with Core Spotlight so semantic search and on-device
retrieval can find it.
New this year:
a View Annotations API for mapping on-screen entities, and an
App Intents testing framework that validates intents without UI automation —
use it instead of XCUITest for intent coverage.
Full reference:
docs/frameworks/app-intents.md
.
4. Image Playground
Generates images on Private Cloud Compute — photorealistic or stylized, from text
or a source photo.
import ImagePlayground
struct ComposeView: View {
@State private var showGenerator = false
@State private var generatedURL: URL?
var body: some View {
Button("Generate image") { showGenerator = true }
.imagePlaygroundSheet(isPresented: $showGenerator) { url in
generatedURL = url
}
}
}
Treat generation as
optional and fallible
: it needs a network, can be
unavailable by region, and the user can cancel. The compose flow must work
without it.
5. Visual Intelligence
Lets the system surface your app's content when the user points at something in
the real world or on screen. You supply entities and the intents that act on them
(open, play, buy), and the system routes matches to your app.
Worth adopting when your app has a catalogue of real-world things — products,
plants, landmarks, media. Not worth it for a to-do list.
6. Designing an AI feature that degrades
Apple Intelligence is not available on every device, in every region, or in every
language. Availability is a runtime condition, not a compile-time one.
@MainActor
@Observable
final class SmartComposeModel {
enum Mode { case intelligent, manual }
private(set) var mode: Mode = .manual
func configure() {
guard #available(iOS 26.0, *) else { return }
if case .available = SystemLanguageModel.default.availability {
mode = .intelligent
}
}
}
Design rules:
The non-AI path is the product.
The AI path is an enhancement layered on it.
If removing the model breaks the feature, the feature is mis-scoped.
Never show a disabled AI button with no explanation.
Either hide the entry
point or state why it is unavailable.
Label generated content.
Users should be able to tell what a model produced,
especially before they send or publish it.
Always allow editing before commit.
Do not auto-send, auto-post, or
auto-purchase from generated output.
Latency is a design problem.
Stream partial results; do not block the UI on
a round trip. See
foundation-models.md
§2.
Anti-Patterns
// 1. A language model where an App Intent belongs.
"Parse the user's sentence and figure out which screen to open."
// Use App Intents. Deterministic, testable, works with no model.
// 2. Claiming privacy you do not provide.
Text("Processed entirely on your device.") // while routing to a third-party API
// 3. Assuming availability.
let session = LanguageModelSession() // fails on ineligible devices/regions
// Check SystemLanguageModel.default.availability.
// 4. An AI-only feature with no fallback.
// Unusable for every user without Apple Intelligence.
// 5. Auto-committing generated content.
send(generatedReply) // user never saw it
// Always let the user read and edit first.
// 6. Private Cloud Compute as the default.
// Costs latency and a network dependency. Start on-device.
// 7. Hardcoded Siri phrases.
// Freezes you at today's phrasing. Use schemas.
// 8. Logging prompts to analytics while marketing the feature as private.
Checklist
[ ] The right framework is used — App Intents for actions, Foundation Models for
generation.
[ ] Availability is checked at runtime, not just with
@available
.
[ ] The feature degrades to a working non-AI path.
[ ] Privacy claims match the actual execution path, including third-party models.
[ ]
PrivacyInfo.xcprivacy
reflects any personal data processed.
[ ] Generated content is labelled and editable before it is committed.
[ ] Long generations stream rather than blocking.
[ ] App content is indexed in Spotlight for retrieval and semantic search.
[ ] Intents are covered by the App Intents testing framework.
---
# ARKit -- Complete Guide for Augmented Reality on iOS and…
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-arkit.html
← Back to all frameworks & guides
All articles →Graphics, 3D, and Games · Reference guideARKit -- Complete Guide for Augmented Reality on iOS and iPadOSRepository guidance for ARKit. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
Overview
1. Permissions and Info.plist
2. Choosing a Configuration
3. Running an AR Session with RealityKit (Recommended)
SwiftUI lifecycle
4. Plane and Mesh Detection
Plane anchors
LiDAR scene reconstruction
5. Image and Object Tracking
6. Face Tracking and Blendshapes
7. Body Tracking (Motion Capture)
8. People Occlusion and Person Segmentation
9. Geo Anchors (City-Locked AR)
10. Saving and Loading World Maps
11. Lifecycle, Interruptions, and Cleanup
12. Common Pitfalls
13. When to Pick Each Renderer
14. Migration Notes
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Check device support
↓2
Configure tracking session
↓3
Consume anchors and updates
↓4
Handle tracking loss
02 / ArchitectureResponsibility boundariesBoundary 1
AR sessionBoundary 2
Anchors and trackingBoundary 3
Scene presentationConnected responsibilities, not a required class hierarchy or an execution trace.
Overview
ARKit is Apple's framework for building augmented reality experiences on iPhone and iPad with LiDAR-class devices. It fuses motion sensors and camera input to produce world tracking, plane detection, scene reconstruction, image/object recognition, body tracking, face tracking, and people occlusion. ARKit produces the
data
(anchors, meshes, transforms); rendering is done by
RealityKit
(preferred), SceneKit, Metal, or SpriteKit.
Use ARKit on
iOS 11+
/
iPadOS 11+
. Many modern features (mesh reconstruction, motion capture, geo-anchors, scene depth) require LiDAR or A12+ chips. ARKit is
not available on visionOS
-- use ARKit-for-visionOS APIs (
import ARKit
from
visionOS
) which expose a different surface.
1. Permissions and Info.plist
<key>NSCameraUsageDescription</key>
<string>This app uses the camera to deliver augmented reality features.</string>
<!-- For face/world tracking with audio -->
<key>NSMicrophoneUsageDescription</key>
<string>Audio is captured to enrich AR experiences.</string>
<!-- For ARGeoTrackingConfiguration -->
<key>NSLocationWhenInUseUsageDescription</key>
<string>Location is used to anchor AR content to real-world coordinates.</string>
Always check device support
before
instantiating a session:
import ARKit
guard ARWorldTrackingConfiguration.isSupported else {
// Fall back to a non-AR experience
return
}
2. Choosing a Configuration
Configuration
Use Case
Min Device
ARWorldTrackingConfiguration
6-DOF camera + plane/mesh detection
A9+
ARFaceTrackingConfiguration
Face anchors, blendshapes, Animoji-style
TrueDepth camera
ARImageTrackingConfiguration
Track moving 2D images (no world tracking)
A9+
ARObjectScanningConfiguration
Author .arobject reference files
A11+
ARBodyTrackingConfiguration
Skeleton tracking
A12+
ARGeoTrackingConfiguration
World-locked content via VPS
A12+, supported cities
ARPositionalTrackingConfiguration
6-DOF only (lowest power)
A9+
let configuration = ARWorldTrackingConfiguration()
configuration.planeDetection = [.horizontal, .vertical]
configuration.environmentTexturing = .automatic
configuration.frameSemantics.insert(.sceneDepth) // LiDAR
configuration.sceneReconstruction = .meshWithClassification // LiDAR
configuration.userFaceTrackingEnabled = true // Front+back camera fusion
Always confirm optional capabilities before opting in:
if ARWorldTrackingConfiguration.supportsSceneReconstruction(.meshWithClassification) {
configuration.sceneReconstruction = .meshWithClassification
}
if ARWorldTrackingConfiguration.supportsFrameSemantics(.sceneDepth) {
configuration.frameSemantics.insert(.sceneDepth)
}
3. Running an AR Session with RealityKit (Recommended)
import SwiftUI
import RealityKit
import ARKit
struct ARContainerView: UIViewRepresentable {
func makeUIView(context: Context) -> ARView {
let arView = ARView(frame: .zero, cameraMode: .ar, automaticallyConfigureSession: false)
let configuration = ARWorldTrackingConfiguration()
configuration.planeDetection = [.horizontal]
configuration.environmentTexturing = .automatic
arView.session.delegate = context.coordinator
arView.session.run(configuration, options: [.resetTracking, .removeExistingAnchors])
// Place a model when the user taps on a detected plane
let tap = UITapGestureRecognizer(target: context.coordinator,
action: #selector(Coordinator.handleTap(_:)))
arView.addGestureRecognizer(tap)
context.coordinator.arView = arView
return arView
}
func updateUIView(_ uiView: ARView, context: Context) {}
func makeCoordinator() -> Coordinator { Coordinator() }
final class Coordinator: NSObject, ARSessionDelegate {
weak var arView: ARView?
@objc func handleTap(_ gesture: UITapGestureRecognizer) {
guard let arView else { return }
let location = gesture.location(in: arView)
// Ray-cast against estimated planes
let results = arView.raycast(from: location,
allowing: .estimatedPlane,
alignment: .horizontal)
guard let first = results.first else { return }
let anchor = AnchorEntity(world: first.worldTransform)
let model = ModelEntity(mesh: .generateBox(size: 0.1),
materials: [SimpleMaterial(color: .systemBlue, isMetallic: false)])
model.generateCollisionShapes(recursive: true)
anchor.addChild(model)
arView.scene.addAnchor(anchor)
}
}
}
SwiftUI lifecycle
struct ContentView: View {
var body: some View {
ARContainerView()
.ignoresSafeArea()
}
}
4. Plane and Mesh Detection
Plane anchors
func session(_ session: ARSession, didAdd anchors: [ARAnchor]) {
for case let plane as ARPlaneAnchor in anchors {
// plane.alignment, plane.classification, plane.geometry
}
}
LiDAR scene reconstruction
configuration.sceneReconstruction = .meshWithClassification
func session(_ session: ARSession, didUpdate anchors: [ARAnchor]) {
for case let mesh as ARMeshAnchor in anchors {
let geometry = mesh.geometry
// geometry.vertices, geometry.faces, geometry.classification
}
}
Mesh classifications include
.wall
,
.floor
,
.ceiling
,
.table
,
.seat
,
.window
,
.door
,
.none
.
5. Image and Object Tracking
guard let referenceImages = ARReferenceImage.referenceImages(
inGroupNamed: "ARImages", bundle: .main
) else { fatalError("Missing AR Resources group") }
let configuration = ARWorldTrackingConfiguration()
configuration.detectionImages = referenceImages
configuration.maximumNumberOfTrackedImages = 4
For physical objects, scan a
.arobject
with the official Apple sample, then:
guard let referenceObjects = ARReferenceObject.referenceObjects(
inGroupNamed: "ARObjects", bundle: .main
) else { return }
configuration.detectionObjects = referenceObjects
6. Face Tracking and Blendshapes
guard ARFaceTrackingConfiguration.isSupported else { return }
let config = ARFaceTrackingConfiguration()
config.maximumNumberOfTrackedFaces = 1
arView.session.run(config)
func session(_ session: ARSession, didUpdate anchors: [ARAnchor]) {
for case let face as ARFaceAnchor in anchors {
let smile = face.blendShapes[.mouthSmileLeft]?.floatValue ?? 0
let blink = face.blendShapes[.eyeBlinkLeft]?.floatValue ?? 0
// Drive an avatar with these values
}
}
7. Body Tracking (Motion Capture)
guard ARBodyTrackingConfiguration.isSupported else { return }
let config = ARBodyTrackingConfiguration()
arView.session.run(config)
let characterAnchor = AnchorEntity()
arView.scene.addAnchor(characterAnchor)
func session(_ session: ARSession, didUpdate anchors: [ARAnchor]) {
for case let body as ARBodyAnchor in anchors {
characterAnchor.transform = Transform(matrix: body.transform)
// body.skeleton.jointModelTransforms drives a rigged BodyTrackedEntity
}
}
8. People Occlusion and Person Segmentation
if ARWorldTrackingConfiguration.supportsFrameSemantics(.personSegmentationWithDepth) {
configuration.frameSemantics.insert(.personSegmentationWithDepth)
}
RealityKit's
ARView
automatically composites people in front of virtual content when this is enabled.
9. Geo Anchors (City-Locked AR)
guard ARGeoTrackingConfiguration.isSupported else { return }
ARGeoTrackingConfiguration.checkAvailability { available, error in
guard available else { return }
let config = ARGeoTrackingConfiguration()
arView.session.run(config)
let coordinate = CLLocationCoordinate2D(latitude: 37.3349, longitude: -122.0090)
let anchor = ARGeoAnchor(coordinate: coordinate, altitude: 25.0)
arView.session.add(anchor: anchor)
}
Only supported in select metropolitan areas -- always call
checkAvailability
first.
10. Saving and Loading World Maps
arView.session.getCurrentWorldMap { map, error in
guard let map else { return }
let data = try? NSKeyedArchiver.archivedData(withRootObject: map, requiringSecureCoding: true)
try? data?.write(to: worldMapURL)
}
let data = try Data(contentsOf: worldMapURL)
let map = try NSKeyedUnarchiver.unarchivedObject(ofClass: ARWorldMap.self, from: data)
let config = ARWorldTrackingConfiguration()
config.initialWorldMap = map
arView.session.run(config)
For multi-user shared experiences, replace world maps with
ARKit collaborative sessions
(
isCollaborationEnabled = true
) and a
MultipeerConnectivity
transport.
11. Lifecycle, Interruptions, and Cleanup
final class SessionDelegate: NSObject, ARSessionDelegate {
func session(_ session: ARSession, didFailWithError error: Error) {
// Surface an alert; ARError.Code tells you what failed
}
func sessionWasInterrupted(_ session: ARSession) {
// Camera was occluded or app backgrounded
}
func sessionInterruptionEnded(_ session: ARSession) {
// Reset tracking to recover quickly
guard let configuration = session.configuration else { return }
session.run(configuration, options: [.resetTracking, .removeExistingAnchors])
}
func session(_ session: ARSession, cameraDidChangeTrackingState camera: ARCamera) {
switch camera.trackingState {
case .notAvailable: break
case .limited(let reason): break // .initializing, .relocalizing, .insufficientFeatures, .excessiveMotion
case .normal: break
}
}
}
Always pause the session when the view disappears:
.onDisappear { arView.session.pause() }
12. Common Pitfalls
Forgetting NSCameraUsageDescription
-- the app crashes silently on first session run.
Running ARWorldTrackingConfiguration on the simulator
-- it isn't supported. Guard with
#if !targetEnvironment(simulator)
.
Not gating by capability
(
isSupported
,
supportsFrameSemantics(_:)
) -- causes runtime crashes on older devices.
Holding strong references to ARFrame
-- frames are pooled. Copy what you need (transforms, pixel buffer) and release the frame promptly.
Mixing front and back camera tracking carelessly
-- enabling
userFaceTrackingEnabled
requires both cameras to be available; check first.
Recreating the ARView on every SwiftUI update
--
UIViewRepresentable.makeUIView
runs once; do session setup there, not in
updateUIView
.
Skipping arView.session.pause()
on disappear -- drains battery, keeps camera light on.
13. When to Pick Each Renderer
Renderer
Use When
RealityKit (default)
Modern iOS 13+ apps, USDZ, photorealistic PBR materials, ECS gameplay
SceneKit
Existing SceneKit codebases, custom shader modifiers, advanced animations
Metal
Custom render pipelines, post-processing, shipping a custom renderer
SpriteKit
2D AR overlays, simple gameplay
14. Migration Notes
iOS 17+
:
ARView
gained explicit object capture APIs via
ObjectCaptureSession
(in RealityKit).
iOS 18+
: Room Plan, improved hand tracking on visionOS pair, expanded geo coverage.
visionOS
: ARKit on visionOS uses
data providers
(
HandTrackingProvider
,
WorldTrackingProvider
) instead of
ARSession
. Code is not source-compatible.
See also:
docs/frameworks/realitykit.md
,
docs/platforms/visionos.md
.
---
# AuthenticationServices
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-authentication-services.html
← Back to all frameworks & guides
All articles →Authentication, Security, and Privacy · Reference guideAuthenticationServicesRepository guidance for AuthenticationServices. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
1. The three facts that cause most Sign in with Apple bugs
2. Sign in with Apple
The seam
Bridging the delegate to async/await
The nonce
View model
The button
3. Revocation — the check people forget
4. Passkeys (iOS 16+)
5. OAuth via ASWebAuthenticationSession
6. Storing what comes back
Anti-Patterns
App Store notes
Checklist
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Start user-initiated sign-in
↓2
Request identity credential
↓3
Validate with trusted service
↓4
Establish app session
02 / ArchitectureResponsibility boundariesBoundary 1
Authorization UIBoundary 2
Identity verificationBoundary 3
Session stateConnected responsibilities, not a required class hierarchy or an execution trace.
Load this when:
adding Sign in with Apple, passkeys, or an OAuth/OIDC web
flow; storing or refreshing auth tokens; or handling an account that the user
can revoke from outside your app.
Covers
AuthenticationServices
— Sign in with Apple, passkeys
(
ASAuthorizationPlatformPublicKeyCredentialProvider
), and
ASWebAuthenticationSession
.
Availability:
framework iOS 12+; Sign in with Apple iOS 13+;
SignInWithAppleButton
(SwiftUI) iOS 14+;
passkeys iOS 16+
.
1. The three facts that cause most Sign in with Apple bugs
Read these before the code. Each one produces a bug that only appears in
production, days after sign-in.
1. Name and email are returned exactly once — on the very first
authorization.
Every subsequent sign-in returns
nil
for both, forever, even
after a reinstall. If you do not persist them at first authorization, they are
gone. There is no API to ask again; the user must revoke the app in Settings and
re-authorize.
2. The user can revoke access outside your app
(Settings → Apple Account →
Sign-In & Security → Sign in with Apple). Your stored credential keeps looking
valid. You must check
getCredentialState
on launch and sign the user out when
it is not
.authorized
.
3. The identity token is the only thing your server may trust.
The user
identifier alone is not proof of anything — it arrives on-device and can be
forged by a modified client. Send the JWT and verify it server-side against
Apple's public keys.
2. Sign in with Apple
The seam
ASAuthorizationController
is delegate-based, which does not compose with
async
/
await
or with this skill's testability rules. Wrap it once behind a
protocol.
import AuthenticationServices
/// What the app actually needs. The view model depends on this, never on
/// ASAuthorizationController — see patterns/clean-architecture.md.
protocol AppleSignInService: Sendable {
func signIn(nonce: String) async throws -> AppleCredential
func credentialState(for userID: String) async -> ASAuthorizationAppleIDProvider.CredentialState
}
struct AppleCredential: Sendable {
let userID: String
/// JWT for your server. The ONLY value your backend should trust.
let identityToken: Data
let authorizationCode: Data
/// Present on FIRST authorization only. Never returned again.
let email: String?
let fullName: PersonNameComponents?
}
Bridging the delegate to async/await
@MainActor
final class LiveAppleSignInService: NSObject, AppleSignInService {
// Held for the lifetime of one request. The system does NOT retain the
// controller — dropping it silently cancels the flow with no callback.
private var controller: ASAuthorizationController?
private var continuation: CheckedContinuation<AppleCredential, any Error>?
func signIn(nonce: String) async throws -> AppleCredential {
try await withCheckedThrowingContinuation { continuation in
self.continuation = continuation
let request = ASAuthorizationAppleIDProvider().createRequest()
request.requestedScopes = [.fullName, .email]
// Hash of a random nonce. Your server compares this against the
// claim in the identity token to reject replayed tokens.
request.nonce = sha256(nonce)
let controller = ASAuthorizationController(authorizationRequests: [request])
controller.delegate = self
controller.presentationContextProvider = self
self.controller = controller
controller.performRequests()
}
}
func credentialState(
for userID: String
) async -> ASAuthorizationAppleIDProvider.CredentialState {
await withCheckedContinuation { continuation in
ASAuthorizationAppleIDProvider().getCredentialState(forUserID: userID) { state, _ in
continuation.resume(returning: state)
}
}
}
/// Resume exactly once, then clear. Resuming a continuation twice is a
/// runtime crash; never resuming leaks the task forever.
private func finish(_ result: Result<AppleCredential, any Error>) {
let pending = continuation
continuation = nil
controller = nil
pending?.resume(with: result)
}
}
extension LiveAppleSignInService: ASAuthorizationControllerDelegate {
func authorizationController(
controller: ASAuthorizationController,
didCompleteWithAuthorization authorization: ASAuthorization
) {
guard
let credential = authorization.credential as? ASAuthorizationAppleIDCredential,
let identityToken = credential.identityToken,
let authorizationCode = credential.authorizationCode
else {
finish(.failure(AuthError.malformedCredential))
return
}
finish(.success(AppleCredential(
userID: credential.user,
identityToken: identityToken,
authorizationCode: authorizationCode,
email: credential.email, // first authorization only
fullName: credential.fullName // first authorization only
)))
}
func authorizationController(
controller: ASAuthorizationController,
didCompleteWithError error: any Error
) {
// The user tapping Cancel is not a failure to report.
if let authError = error as? ASAuthorizationError, authError.code == .canceled {
finish(.failure(CancellationError()))
} else {
finish(.failure(error))
}
}
}
extension LiveAppleSignInService: ASAuthorizationControllerPresentationContextProviding {
func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor {
// Do not use UIApplication.shared.windows.first — it is deprecated and
// wrong under multiple scenes.
UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.flatMap(\.windows)
.first { $0.isKeyWindow } ?? ASPresentationAnchor()
}
}
The nonce
import CryptoKit
func makeNonce(length: Int = 32) -> String {
var bytes = [UInt8](repeating: 0, count: length)
let status = SecRandomCopyBytes(kSecRandomDefault, length, &bytes)
precondition(status == errSecSuccess, "SecRandomCopyBytes failed")
return Data(bytes).base64EncodedString()
}
func sha256(_ input: String) -> String {
SHA256.hash(data: Data(input.utf8))
.map { String(format: "%02x", $0) }
.joined()
}
Send the
raw
nonce to your server and the
hashed
one to Apple. The
server checks that the token's
nonce
claim equals
sha256(rawNonce)
. Skipping
this means a stolen identity token can be replayed.
View model
@MainActor
@Observable
final class SignInModel {
private(set) var isSigningIn = false
var errorMessage: String?
private let appleSignIn: any AppleSignInService
private let session: any SessionStore
init(appleSignIn: any AppleSignInService, session: any SessionStore) {
self.appleSignIn = appleSignIn
self.session = session
}
func signInWithApple() async {
isSigningIn = true
defer { isSigningIn = false }
let nonce = makeNonce()
do {
let credential = try await appleSignIn.signIn(nonce: nonce)
// Persist name/email NOW — they will never be returned again.
try await session.establish(credential: credential, rawNonce: nonce)
errorMessage = nil
} catch is CancellationError {
return // user tapped Cancel
} catch {
errorMessage = String(localized: "Couldn't sign in. Please try again.")
}
}
}
The button
import AuthenticationServices
import SwiftUI
SignInWithAppleButton(.signIn) { request in
request.requestedScopes = [.fullName, .email]
request.nonce = sha256(model.currentNonce)
} onCompletion: { _ in
// Handled by the service above; this closure exists only to satisfy the API.
}
.signInWithAppleButtonStyle(colorScheme == .dark ? .white : .black)
.frame(height: 50) // Apple's HIG: 44pt minimum
Use
SignInWithAppleButton
, not a custom button. App Review rejects
reimplementations that do not match Apple's specified appearance, and the system
button handles localization and Dynamic Type for you.
3. Revocation — the check people forget
@MainActor
@Observable
final class SessionModel {
private(set) var isSignedIn = false
private let appleSignIn: any AppleSignInService
private let session: any SessionStore
/// Call from `.task` on the root view, every launch and every foreground.
func validateSession() async {
guard let userID = await session.storedAppleUserID() else {
isSignedIn = false
return
}
switch await appleSignIn.credentialState(for: userID) {
case .authorized:
isSignedIn = true
case .revoked, .notFound:
// The user revoked access in Settings, or the account is gone.
await session.clear()
isSignedIn = false
case .transferred:
// App was transferred between developer teams — migrate server-side.
await session.beginTeamTransferMigration()
@unknown default:
isSignedIn = false
}
}
}
Also observe the revocation notification while running:
.task {
for await _ in NotificationCenter.default.notifications(
named: ASAuthorizationAppleIDProvider.credentialRevokedNotification
) {
await model.validateSession()
}
}
4. Passkeys (iOS 16+)
Passkeys replace passwords with a WebAuthn credential bound to your domain.
They require the
Associated Domains
capability with
webcredentials:example.com
, and a matching
apple-app-site-association
file.
@available(iOS 16.0, *)
@MainActor
final class PasskeyService: NSObject {
private let domain = "example.com"
private var continuation: CheckedContinuation<ASAuthorization, any Error>?
private var controller: ASAuthorizationController?
/// Registration: the challenge and userID come from YOUR server.
func register(userName: String, userID: Data, challenge: Data) async throws -> ASAuthorization {
let provider = ASAuthorizationPlatformPublicKeyCredentialProvider(
relyingPartyIdentifier: domain
)
let request = provider.createCredentialRegistrationRequest(
challenge: challenge,
name: userName,
userID: userID
)
return try await perform(request)
}
/// Assertion: signing in with an existing passkey.
func signIn(challenge: Data) async throws -> ASAuthorization {
let provider = ASAuthorizationPlatformPublicKeyCredentialProvider(
relyingPartyIdentifier: domain
)
return try await perform(provider.createCredentialAssertionRequest(challenge: challenge))
}
private func perform(_ request: ASAuthorizationRequest) async throws -> ASAuthorization {
try await withCheckedThrowingContinuation { continuation in
self.continuation = continuation
let controller = ASAuthorizationController(authorizationRequests: [request])
controller.delegate = self
controller.presentationContextProvider = self
self.controller = controller
controller.performRequests()
}
}
}
The challenge must come from your server and be single-use.
A
client-generated challenge defeats the entire protocol.
For a sign-in field that offers both passkeys and a saved password, use
performAutoFillAssistedRequests()
together with
.textContentType(.username)
on the field.
5. OAuth via ASWebAuthenticationSession
For a third-party identity provider that has no native SDK.
@MainActor
final class WebAuthService: NSObject {
private var session: ASWebAuthenticationSession?
func authenticate(url: URL, callbackScheme: String) async throws -> URL {
try await withCheckedThrowingContinuation { continuation in
let session = ASWebAuthenticationSession(
url: url,
callbackURLScheme: callbackScheme
) { callbackURL, error in
if let error {
let cancelled = (error as? ASWebAuthenticationSessionError)?.code == .canceledLogin
continuation.resume(throwing: cancelled ? CancellationError() : error)
} else if let callbackURL {
continuation.resume(returning: callbackURL)
} else {
continuation.resume(throwing: AuthError.malformedCredential)
}
}
session.presentationContextProvider = self
// true = no shared cookies, so the user must log in each time.
// false = single sign-on with Safari. Choose deliberately.
session.prefersEphemeralWebBrowserSession = false
self.session = session
session.start()
}
}
}
Use
PKCE
for any OAuth flow from a mobile client. The implicit flow and
embedded
WKWebView
login are both rejected by most providers and by App Review.
6. Storing what comes back
Tokens go in the
Keychain
, never
UserDefaults
— see
checklists/security.md
and
docs/frameworks/cryptokit.md
.
actor KeychainSessionStore: SessionStore {
func establish(credential: AppleCredential, rawNonce: String) async throws {
// Exchange with your server FIRST — it verifies the identity token and
// the nonce, then returns your own session token.
let session = try await api.exchange(
identityToken: credential.identityToken,
authorizationCode: credential.authorizationCode,
rawNonce: rawNonce,
// Send these on first authorization only — they are never resent.
email: credential.email,
fullName: credential.fullName.map(PersonNameComponentsFormatter().string(from:))
)
try store(session.token, account: "session")
try store(credential.userID, account: "appleUserID")
}
}
Use
kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
for session tokens:
available to background tasks after first unlock, and never restored onto a
different device from a backup.
Anti-Patterns
// 1. Not persisting name/email at first authorization.
if let email = credential.email { showWelcome(email) } // and then discarded
// They are returned ONCE, ever. Persist immediately or lose them permanently.
// 2. Trusting the user identifier as authentication.
api.login(userID: credential.user)
// Arrives on-device, forgeable. Send the identity token; verify server-side.
// 3. No nonce.
let request = provider.createRequest()
request.requestedScopes = [.fullName, .email] // no request.nonce
// A stolen identity token can be replayed indefinitely.
// 4. Never checking credential state.
// The user revokes in Settings; your app stays "signed in" forever.
// Check getCredentialState on launch and on foreground.
// 5. Dropping the controller.
func signIn() {
let controller = ASAuthorizationController(...) // local, deallocated
controller.performRequests() // silently never calls back
}
// 6. Resuming a continuation twice — or not at all.
continuation.resume(returning: credential)
continuation.resume(returning: credential) // CRASH
// Every exit path resumes exactly once. Clear the stored continuation first.
// 7. Treating cancellation as an error.
catch { errorMessage = "Sign in failed" } // fires when the user taps Cancel
catch is CancellationError { return } // correct
// 8. Tokens in UserDefaults.
UserDefaults.standard.set(token, forKey: "authToken") // plaintext, backed up
// Keychain.
// 9. A custom Sign in with Apple button.
Button("Sign in with Apple") { … }
// App Review rejects appearances that do not match the specification.
// 10. Embedded WKWebView for OAuth.
// Rejected by providers and by App Review. Use ASWebAuthenticationSession.
// 11. UIApplication.shared.windows.first as the presentation anchor.
// Deprecated, and wrong with multiple scenes. Use connectedScenes.
// 12. A client-generated passkey challenge.
// Defeats the protocol. The challenge is server-issued and single-use.
// 13. The view model naming ASAuthorizationController directly.
// Untestable and un-previewable. Depend on a protocol.
App Store notes
If your app offers third-party or social login, App Review requires an
equivalent privacy-preserving option. Sign in with Apple satisfies this —
check the current text of Guideline 4.8 before submitting, as the wording has
changed across revisions.
Offer account
deletion
in-app if you offer account creation (Guideline
5.1.1(v)), and revoke the Apple token server-side when the user deletes.
Passkeys need Associated Domains and a served
apple-app-site-association
. Verify it responds over HTTPS with no redirect
before submitting — this is the usual cause of "passkeys work in debug, not in
TestFlight".
Checklist
[ ]
email
and
fullName
persisted on first authorization.
[ ] A random nonce per request; hashed to Apple, raw to your server.
[ ] Server verifies the identity token against Apple's public keys.
[ ]
getCredentialState
checked on launch and on foreground.
[ ]
credentialRevokedNotification
observed while running.
[ ]
ASAuthorizationController
retained for the request's lifetime.
[ ] Every continuation resumes exactly once on every path.
[ ]
CancellationError
handled as a deliberate no-op.
[ ] Tokens in the Keychain with an appropriate accessibility class.
[ ]
SignInWithAppleButton
, not a custom control.
[ ] Passkeys: Associated Domains configured, challenge server-issued.
[ ] OAuth:
ASWebAuthenticationSession
with PKCE, never a
WKWebView
.
[ ] The view model depends on a protocol; sign-in previews with a stub.
[ ] Account deletion offered and the Apple token revoked server-side.
---
# AVFoundation
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-avfoundation.html
← Back to all frameworks & guides
All articles →Camera, Media, and Audio · Reference guideAVFoundationRepository guidance for AVFoundation. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
AVPlayer and AVPlayerViewController
AVAudioPlayer and AVAudioRecorder
AVCaptureSession — Camera and Video Capture
AVAsset and AVAssetExportSession
Audio Session Configuration
Now Playing Info and Remote Commands
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Request needed permissions
↓2
Configure media session
↓3
Capture or play media
↓4
Handle interruptions
02 / ArchitectureResponsibility boundariesBoundary 1
Media sourceBoundary 2
AVFoundation pipelineBoundary 3
Playback or capture UIConnected responsibilities, not a required class hierarchy or an execution trace.
AVPlayer and AVPlayerViewController
import AVKit
import AVFoundation
// Simple video player in SwiftUI
struct VideoPlayerView: View {
let url: URL
@State private var player: AVPlayer?
var body: some View {
VideoPlayer(player: player)
.onAppear {
player = AVPlayer(url: url)
player?.play()
}
.onDisappear {
player?.pause()
player = nil
}
}
}
// Full-featured player with AVPlayerViewController (UIKit wrapper)
struct FullScreenVideoPlayer: UIViewControllerRepresentable {
let url: URL
func makeUIViewController(context: Context) -> AVPlayerViewController {
let controller = AVPlayerViewController()
let player = AVPlayer(url: url)
controller.player = player
controller.allowsPictureInPicturePlayback = true
controller.canStartPictureInPictureAutomaticallyFromInline = true
return controller
}
func updateUIViewController(_ uiViewController: AVPlayerViewController, context: Context) {}
}
// Observing player state
@Observable
class VideoPlayerModel {
var player: AVPlayer?
var isPlaying = false
var currentTime: TimeInterval = 0
var duration: TimeInterval = 0
private var timeObserver: Any?
func loadVideo(url: URL) {
let item = AVPlayerItem(url: url)
player = AVPlayer(playerItem: item)
// Observe playback time
timeObserver = player?.addPeriodicTimeObserver(
forInterval: CMTime(seconds: 0.5, preferredTimescale: 600),
queue: .main
) { [weak self] time in
self?.currentTime = time.seconds
}
// Observe duration when ready
Task {
if let duration = try? await item.asset.load(.duration) {
self.duration = duration.seconds
}
}
}
func togglePlayback() {
if isPlaying {
player?.pause()
} else {
player?.play()
}
isPlaying.toggle()
}
func seek(to time: TimeInterval) {
player?.seek(to: CMTime(seconds: time, preferredTimescale: 600))
}
deinit {
if let observer = timeObserver {
player?.removeTimeObserver(observer)
}
}
}
AVAudioPlayer and AVAudioRecorder
import AVFoundation
@Observable
class AudioManager {
var isPlaying = false
var isRecording = false
var recordingURL: URL?
private var audioPlayer: AVAudioPlayer?
private var audioRecorder: AVAudioRecorder?
// Play audio file
func play(url: URL) throws {
let session = AVAudioSession.sharedInstance()
try session.setCategory(.playback, mode: .default)
try session.setActive(true)
audioPlayer = try AVAudioPlayer(contentsOf: url)
audioPlayer?.prepareToPlay()
audioPlayer?.play()
isPlaying = true
}
func stopPlayback() {
audioPlayer?.stop()
isPlaying = false
}
// Record audio
func startRecording() throws {
let session = AVAudioSession.sharedInstance()
try session.setCategory(.record, mode: .default)
try session.setActive(true)
let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
let audioFilename = documentsPath.appendingPathComponent("\(UUID().uuidString).m4a")
recordingURL = audioFilename
let settings: [String: Any] = [
AVFormatIDKey: Int(kAudioFormatMPEG4AAC),
AVSampleRateKey: 44100,
AVNumberOfChannelsKey: 1,
AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue,
]
audioRecorder = try AVAudioRecorder(url: audioFilename, settings: settings)
audioRecorder?.isMeteringEnabled = true
audioRecorder?.record()
isRecording = true
}
func stopRecording() -> URL? {
audioRecorder?.stop()
isRecording = false
return recordingURL
}
// Get audio levels for visualization
func currentLevel() -> Float {
audioRecorder?.updateMeters()
return audioRecorder?.averagePower(forChannel: 0) ?? -160
}
}
AVCaptureSession — Camera and Video Capture
import AVFoundation
import UIKit
class CameraManager: NSObject {
let captureSession = AVCaptureSession()
private var photoOutput = AVCapturePhotoOutput()
private var videoOutput = AVCaptureMovieFileOutput()
private var currentCamera: AVCaptureDevice.Position = .back
var photoCaptureCompletion: ((UIImage?) -> Void)?
func setupSession() {
captureSession.beginConfiguration()
captureSession.sessionPreset = .photo
// Add camera input
guard let camera = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back),
let input = try? AVCaptureDeviceInput(device: camera),
captureSession.canAddInput(input)
else { return }
captureSession.addInput(input)
// Add photo output
guard captureSession.canAddOutput(photoOutput) else { return }
captureSession.addOutput(photoOutput)
photoOutput.isHighResolutionCaptureEnabled = true
captureSession.commitConfiguration()
}
func startSession() {
guard !captureSession.isRunning else { return }
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
self?.captureSession.startRunning()
}
}
func stopSession() {
guard captureSession.isRunning else { return }
captureSession.stopRunning()
}
func capturePhoto() {
let settings = AVCapturePhotoSettings()
settings.flashMode = .auto
photoOutput.capturePhoto(with: settings, delegate: self)
}
func switchCamera() {
captureSession.beginConfiguration()
// Remove current input
if let currentInput = captureSession.inputs.first as? AVCaptureDeviceInput {
captureSession.removeInput(currentInput)
}
// Add new camera
currentCamera = currentCamera == .back ? .front : .back
guard let newCamera = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: currentCamera),
let newInput = try? AVCaptureDeviceInput(device: newCamera),
captureSession.canAddInput(newInput)
else { return }
captureSession.addInput(newInput)
captureSession.commitConfiguration()
}
// Check camera permission
static func requestPermission() async -> Bool {
let status = AVCaptureDevice.authorizationStatus(for: .video)
switch status {
case .authorized:
return true
case .notDetermined:
return await AVCaptureDevice.requestAccess(for: .video)
default:
return false
}
}
}
extension CameraManager: AVCapturePhotoCaptureDelegate {
func photoOutput(_ output: AVCapturePhotoOutput,
didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) {
guard let data = photo.fileDataRepresentation(),
let image = UIImage(data: data)
else {
photoCaptureCompletion?(nil)
return
}
photoCaptureCompletion?(image)
}
}
// Camera preview layer for UIKit
class CameraPreviewView: UIView {
override class var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self }
var previewLayer: AVCaptureVideoPreviewLayer {
layer as! AVCaptureVideoPreviewLayer
}
func configure(session: AVCaptureSession) {
previewLayer.session = session
previewLayer.videoGravity = .resizeAspectFill
}
}
AVAsset and AVAssetExportSession
// Get video metadata
func getVideoInfo(url: URL) async throws -> (duration: TimeInterval, size: CGSize) {
let asset = AVURLAsset(url: url)
let duration = try await asset.load(.duration)
let tracks = try await asset.loadTracks(withMediaType: .video)
let size = try await tracks.first?.load(.naturalSize) ?? .zero
return (duration.seconds, size)
}
// Export/compress video
func compressVideo(inputURL: URL, outputURL: URL) async throws {
let asset = AVURLAsset(url: inputURL)
guard let exportSession = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetMediumQuality) else {
throw ExportError.sessionCreationFailed
}
exportSession.outputURL = outputURL
exportSession.outputFileType = .mp4
exportSession.shouldOptimizeForNetworkUse = true
await exportSession.export()
if let error = exportSession.error {
throw error
}
}
// Trim video
func trimVideo(inputURL: URL, outputURL: URL, startTime: TimeInterval, endTime: TimeInterval) async throws {
let asset = AVURLAsset(url: inputURL)
guard let exportSession = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetHighestQuality) else {
throw ExportError.sessionCreationFailed
}
let start = CMTime(seconds: startTime, preferredTimescale: 600)
let end = CMTime(seconds: endTime, preferredTimescale: 600)
exportSession.timeRange = CMTimeRange(start: start, end: end)
exportSession.outputURL = outputURL
exportSession.outputFileType = .mp4
await exportSession.export()
}
// Generate thumbnail
func generateThumbnail(url: URL, at time: TimeInterval) async throws -> UIImage {
let asset = AVURLAsset(url: url)
let generator = AVAssetImageGenerator(asset: asset)
generator.appliesPreferredTrackTransform = true
generator.maximumSize = CGSize(width: 320, height: 320)
let cmTime = CMTime(seconds: time, preferredTimescale: 600)
let (image, _) = try await generator.image(at: cmTime)
return UIImage(cgImage: image)
}
enum ExportError: Error {
case sessionCreationFailed
}
Audio Session Configuration
import AVFoundation
class AudioSessionManager {
// Playback only (music, podcasts)
static func configureForPlayback() throws {
let session = AVAudioSession.sharedInstance()
try session.setCategory(.playback, mode: .default, options: [])
try session.setActive(true)
}
// Recording
static func configureForRecording() throws {
let session = AVAudioSession.sharedInstance()
try session.setCategory(.record, mode: .default, options: [])
try session.setActive(true)
}
// Play and record simultaneously (voice chat)
static func configureForVoiceChat() throws {
let session = AVAudioSession.sharedInstance()
try session.setCategory(.playAndRecord, mode: .voiceChat, options: [
.defaultToSpeaker,
.allowBluetooth,
])
try session.setActive(true)
}
// Handle interruptions (phone calls)
static func observeInterruptions(handler: @escaping (Bool) -> Void) {
NotificationCenter.default.addObserver(
forName: AVAudioSession.interruptionNotification,
object: AVAudioSession.sharedInstance(),
queue: .main
) { notification in
guard let typeValue = notification.userInfo?[AVAudioSessionInterruptionTypeKey] as? UInt,
let type = AVAudioSession.InterruptionType(rawValue: typeValue) else { return }
handler(type == .began)
}
}
}
Now Playing Info and Remote Commands
import MediaPlayer
class NowPlayingManager {
static let shared = NowPlayingManager()
func setupRemoteCommands(
onPlay: @escaping () -> Void,
onPause: @escaping () -> Void,
onNext: @escaping () -> Void,
onPrevious: @escaping () -> Void
) {
let commandCenter = MPRemoteCommandCenter.shared()
commandCenter.playCommand.addTarget { _ in
onPlay()
return .success
}
commandCenter.pauseCommand.addTarget { _ in
onPause()
return .success
}
commandCenter.nextTrackCommand.addTarget { _ in
onNext()
return .success
}
commandCenter.previousTrackCommand.addTarget { _ in
onPrevious()
return .success
}
}
func updateNowPlaying(title: String, artist: String, duration: TimeInterval, currentTime: TimeInterval, artwork: UIImage?) {
var info = [String: Any]()
info[MPMediaItemPropertyTitle] = title
info[MPMediaItemPropertyArtist] = artist
info[MPMediaItemPropertyPlaybackDuration] = duration
info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentTime
info[MPNowPlayingInfoPropertyPlaybackRate] = 1.0
if let image = artwork {
info[MPMediaItemPropertyArtwork] = MPMediaItemArtwork(boundsSize: image.size) { _ in image }
}
MPNowPlayingInfoCenter.default().nowPlayingInfo = info
}
}
---
# BackgroundTasks
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-background-tasks.html
← Back to all frameworks & guides
All articles →System Integration · Reference guideBackgroundTasksRepository guidance for BackgroundTasks. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
BGTaskScheduler Registration
BGAppRefreshTask for Periodic Updates
BGProcessingTask for Long Operations
URLSession Background Transfers
AppDelegate Integration for Background Sessions
Testing Background Tasks in Xcode
Complete Background Sync Example
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Register task handlers
↓2
Schedule eligible work
↓3
Handle launch and expiration
↓4
Report completion
02 / ArchitectureResponsibility boundariesBoundary 1
System schedulerBoundary 2
Bounded task workerBoundary 3
Persistent app stateConnected responsibilities, not a required class hierarchy or an execution trace.
BGTaskScheduler Registration
Register task identifiers in
Info.plist
under
BGTaskSchedulerPermittedIdentifiers
:
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>com.yourapp.refresh</string>
<string>com.yourapp.db-cleanup</string>
<string>com.yourapp.sync</string>
</array>
Register handlers at app launch (before the end of the first
applicationDidFinishLaunching
call or in the
@main
App
init
):
import BackgroundTasks
@main
struct MyApp: App {
init() {
registerBackgroundTasks()
}
var body: some Scene {
WindowGroup {
ContentView()
}
}
private func registerBackgroundTasks() {
BGTaskScheduler.shared.register(
forTaskWithIdentifier: "com.yourapp.refresh",
using: nil // nil = main queue
) { task in
guard let task = task as? BGAppRefreshTask else { return }
handleAppRefresh(task: task)
}
BGTaskScheduler.shared.register(
forTaskWithIdentifier: "com.yourapp.db-cleanup",
using: nil
) { task in
guard let task = task as? BGProcessingTask else { return }
handleDatabaseCleanup(task: task)
}
BGTaskScheduler.shared.register(
forTaskWithIdentifier: "com.yourapp.sync",
using: nil
) { task in
guard let task = task as? BGProcessingTask else { return }
handleSync(task: task)
}
}
}
BGAppRefreshTask for Periodic Updates
App refresh tasks are short-lived (up to 30 seconds). Use for lightweight data fetches.
func handleAppRefresh(task: BGAppRefreshTask) {
// Schedule the next refresh before doing work
scheduleAppRefresh()
let refreshTask = Task {
do {
let newData = try await DataService.shared.fetchLatestData()
await MainActor.run {
DataStore.shared.update(with: newData)
}
task.setTaskCompleted(success: true)
} catch {
task.setTaskCompleted(success: false)
}
}
// Handle system cancelling the task
task.expirationHandler = {
refreshTask.cancel()
}
}
func scheduleAppRefresh() {
let request = BGAppRefreshTaskRequest(identifier: "com.yourapp.refresh")
request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60) // 15 minutes minimum
do {
try BGTaskScheduler.shared.submit(request)
} catch {
print("Could not schedule app refresh: \(error)")
}
}
BGProcessingTask for Long Operations
Processing tasks can run for several minutes. Require device to be charging and/or on Wi-Fi.
func handleDatabaseCleanup(task: BGProcessingTask) {
// Schedule the next cleanup
scheduleDatabaseCleanup()
let cleanupTask = Task {
do {
try await DatabaseManager.shared.performCleanup()
try await DatabaseManager.shared.vacuumDatabase()
try await CacheManager.shared.pruneExpiredEntries()
task.setTaskCompleted(success: true)
} catch {
task.setTaskCompleted(success: false)
}
}
task.expirationHandler = {
cleanupTask.cancel()
}
}
func scheduleDatabaseCleanup() {
let request = BGProcessingTaskRequest(identifier: "com.yourapp.db-cleanup")
request.earliestBeginDate = Date(timeIntervalSinceNow: 24 * 60 * 60) // Daily
request.requiresNetworkConnectivity = false
request.requiresExternalPower = true // Only when charging
do {
try BGTaskScheduler.shared.submit(request)
} catch {
print("Could not schedule cleanup: \(error)")
}
}
func handleSync(task: BGProcessingTask) {
scheduleSync()
let syncTask = Task {
do {
try await SyncEngine.shared.performFullSync()
task.setTaskCompleted(success: true)
} catch {
task.setTaskCompleted(success: false)
}
}
task.expirationHandler = {
syncTask.cancel()
}
}
func scheduleSync() {
let request = BGProcessingTaskRequest(identifier: "com.yourapp.sync")
request.earliestBeginDate = Date(timeIntervalSinceNow: 60 * 60) // 1 hour
request.requiresNetworkConnectivity = true
request.requiresExternalPower = false
do {
try BGTaskScheduler.shared.submit(request)
} catch {
print("Could not schedule sync: \(error)")
}
}
URLSession Background Transfers
Background transfers continue even when your app is suspended or terminated.
final class BackgroundDownloadManager: NSObject, URLSessionDownloadDelegate {
static let shared = BackgroundDownloadManager()
private lazy var backgroundSession: URLSession = {
let config = URLSessionConfiguration.background(
withIdentifier: "com.yourapp.background-download"
)
config.isDiscretionary = true // System chooses optimal time
config.sessionSendsLaunchEvents = true // Wake app on completion
config.allowsCellularAccess = false // Wi-Fi only
config.timeoutIntervalForResource = 60 * 60 * 24 // 24 hours
return URLSession(configuration: config, delegate: self, delegateQueue: nil)
}()
/// Completion handler stored from AppDelegate for background session events
var backgroundCompletionHandler: (() -> Void)?
/// Start a background download
func download(url: URL) -> URLSessionDownloadTask {
let task = backgroundSession.downloadTask(with: url)
task.earliestBeginDate = Date(timeIntervalSinceNow: 60) // Delay start
task.countOfBytesClientExpectsToSend = 200 // Request size estimate
task.countOfBytesClientExpectsToReceive = 5_000_000 // Response size estimate
task.resume()
return task
}
/// Start a background upload
func upload(data: Data, to url: URL) -> URLSessionUploadTask {
var request = URLRequest(url: url)
request.httpMethod = "POST"
// Write data to temp file (required for background uploads)
let tempURL = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
try? data.write(to: tempURL)
let task = backgroundSession.uploadTask(with: request, fromFile: tempURL)
task.resume()
return task
}
// MARK: - URLSessionDownloadDelegate
func urlSession(
_ session: URLSession,
downloadTask: URLSessionDownloadTask,
didFinishDownloadingTo location: URL
) {
// Move file from temp location before this method returns
let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
let destinationURL = documentsURL.appendingPathComponent(
downloadTask.originalRequest?.url?.lastPathComponent ?? "download"
)
try? FileManager.default.removeItem(at: destinationURL)
try? FileManager.default.moveItem(at: location, to: destinationURL)
}
func urlSession(
_ session: URLSession,
downloadTask: URLSessionDownloadTask,
didWriteData bytesWritten: Int64,
totalBytesWritten: Int64,
totalBytesExpectedToWrite: Int64
) {
let progress = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)
Task { @MainActor in
NotificationCenter.default.post(
name: .downloadProgress,
object: nil,
userInfo: ["progress": progress, "taskID": downloadTask.taskIdentifier]
)
}
}
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
if let error {
print("Background transfer failed: \(error.localizedDescription)")
}
}
// Called when all background events have been delivered
func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
Task { @MainActor in
backgroundCompletionHandler?()
backgroundCompletionHandler = nil
}
}
}
extension Notification.Name {
static let downloadProgress = Notification.Name("downloadProgress")
}
AppDelegate Integration for Background Sessions
import UIKit
class AppDelegate: NSObject, UIApplicationDelegate {
func application(
_ application: UIApplication,
handleEventsForBackgroundURLSession identifier: String,
completionHandler: @escaping () -> Void
) {
BackgroundDownloadManager.shared.backgroundCompletionHandler = completionHandler
}
}
// In your @main App:
@main
struct MyApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
var body: some Scene {
WindowGroup { ContentView() }
}
}
Testing Background Tasks in Xcode
Use the Xcode debugger console to simulate background task execution:
# Simulate app refresh task launch
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"com.yourapp.refresh"]
# Simulate processing task launch
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"com.yourapp.db-cleanup"]
# Simulate expiration of a running task
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateExpirationForTaskWithIdentifier:@"com.yourapp.refresh"]
Complete Background Sync Example
import BackgroundTasks
import OSLog
@Observable
final class SyncEngine {
static let shared = SyncEngine()
private(set) var lastSyncDate: Date?
private(set) var isSyncing = false
private let logger = Logger(subsystem: "com.yourapp", category: "Sync")
func performFullSync() async throws {
guard !isSyncing else { return }
isSyncing = true
defer { isSyncing = false }
logger.info("Starting full sync")
// 1. Push local changes
let pendingChanges = try await LocalStore.shared.pendingChanges()
if !pendingChanges.isEmpty {
logger.info("Pushing \(pendingChanges.count) local changes")
try await APIClient.shared.pushChanges(pendingChanges)
try await LocalStore.shared.markSynced(pendingChanges)
}
// 2. Pull remote changes
let remoteChanges = try await APIClient.shared.pullChanges(since: lastSyncDate)
logger.info("Pulled \(remoteChanges.count) remote changes")
try await LocalStore.shared.applyRemoteChanges(remoteChanges)
// 3. Update sync timestamp
lastSyncDate = Date()
UserDefaults.standard.set(lastSyncDate, forKey: "lastSyncDate")
logger.info("Sync completed successfully")
}
}
// Schedule sync when app backgrounds
struct ContentView: View {
@Environment(\.scenePhase) private var scenePhase
@State private var syncEngine = SyncEngine.shared
var body: some View {
NavigationStack {
VStack {
if syncEngine.isSyncing {
ProgressView("Syncing...")
}
if let lastSync = syncEngine.lastSyncDate {
Text("Last sync: \(lastSync, format: .relative(presentation: .named))")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
.onChange(of: scenePhase) { _, newPhase in
if newPhase == .background {
scheduleSync()
scheduleAppRefresh()
}
}
.task {
// Sync on app launch if stale
if let last = syncEngine.lastSyncDate,
Date().timeIntervalSince(last) > 300 { // 5 minutes
try? await syncEngine.performFullSync()
}
}
}
}
// Placeholder protocols for completeness
enum LocalStore {
static let shared = LocalStore.self
static func pendingChanges() async throws -> [Any] { [] }
static func markSynced(_ changes: [Any]) async throws {}
static func applyRemoteChanges(_ changes: [Any]) async throws {}
}
enum APIClient {
static let shared = APIClient.self
static func pushChanges(_ changes: [Any]) async throws {}
static func pullChanges(since date: Date?) async throws -> [Any] { [] }
}
---
# CloudKit
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-cloudkit.html
← Back to all frameworks & guides
All articles →Data Management · Reference guideCloudKitRepository guidance for CloudKit. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
CKContainer and CKDatabase (Public, Private, Shared)
CKRecord CRUD Operations
Create
Read
Update
Delete
CKQuery with NSPredicate
CKSubscription for Real-Time Push
CKShare and Sharing Records
CloudKit Dashboard
Integration with SwiftData/CoreData
NSPersistentCloudKitContainer (CoreData + CloudKit)
SwiftData with CloudKit (iOS 17+)
Custom Record Zones
Modern CloudKit (iOS 17+)
CKSyncEngine
CKSystemSharingUIObserver
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Define records and zones
↓2
Check account availability
↓3
Fetch or save changes
↓4
Resolve errors and conflicts
02 / ArchitectureResponsibility boundariesBoundary 1
Local app stateBoundary 2
Sync coordinatorBoundary 3
CloudKit recordsConnected responsibilities, not a required class hierarchy or an execution trace.
CloudKit is Apple's cloud backend framework providing database storage, authentication, and asset management. It syncs data across devices using iCloud with public, private, and shared databases.
CKContainer and CKDatabase (Public, Private, Shared)
import CloudKit
class CloudKitManager: ObservableObject {
// Default container matches your app's bundle ID
let container = CKContainer.default()
// Custom container identifier
let customContainer = CKContainer(identifier: "iCloud.com.yourapp.name")
// Access different databases
lazy var publicDB = container.publicCloudDatabase // Accessible to all users
lazy var privateDB = container.privateCloudDatabase // User's private data (counts toward user's iCloud quota)
lazy var sharedDB = container.sharedCloudDatabase // Data shared with this user by others
// Check iCloud account status
func checkAccountStatus() async throws -> CKAccountStatus {
let status = try await container.accountStatus()
switch status {
case .available:
print("iCloud available")
case .noAccount:
print("No iCloud account signed in")
case .restricted:
print("iCloud restricted by parental controls or MDM")
case .couldNotDetermine:
print("Could not determine iCloud status")
case .temporarilyUnavailable:
print("iCloud temporarily unavailable")
@unknown default:
break
}
return status
}
// Get current user record ID
func fetchUserRecordID() async throws -> CKRecord.ID {
return try await container.userRecordID()
}
// Request user discoverability permission
func requestPermission() async throws -> CKContainer.ApplicationPermissionStatus {
return try await container.requestApplicationPermission(.userDiscoverability)
}
}
CKRecord CRUD Operations
Create
extension CloudKitManager {
func createNote(title: String, body: String, image: UIImage?) async throws -> CKRecord {
let record = CKRecord(recordType: "Note")
record["title"] = title as CKRecordValue
record["body"] = body as CKRecordValue
record["createdAt"] = Date() as CKRecordValue
record["tags"] = ["swift", "cloudkit"] as CKRecordValue // Arrays are supported
// Save an image asset
if let image = image, let data = image.jpegData(compressionQuality: 0.8) {
let tempURL = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString + ".jpg")
try data.write(to: tempURL)
record["image"] = CKAsset(fileURL: tempURL)
}
// Reference another record
let folderRecordID = CKRecord.ID(recordName: "folder-abc")
record["folder"] = CKRecord.Reference(recordID: folderRecordID, action: .deleteSelf)
// .deleteSelf: child is deleted when parent is deleted
// .none: no cascade behavior
let savedRecord = try await privateDB.save(record)
return savedRecord
}
}
Read
extension CloudKitManager {
// Fetch a single record by ID
func fetchNote(recordID: CKRecord.ID) async throws -> CKRecord {
return try await privateDB.record(for: recordID)
}
// Fetch multiple records efficiently
func fetchNotes(recordIDs: [CKRecord.ID]) async throws -> [CKRecord] {
let results = try await privateDB.records(for: recordIDs)
return results.compactMap { _, result in
try? result.get()
}
}
// Fetch only specific fields (desiredKeys) to reduce bandwidth
func fetchNoteTitles(recordIDs: [CKRecord.ID]) async throws -> [CKRecord] {
let results = try await privateDB.records(for: recordIDs, desiredKeys: ["title", "createdAt"])
return results.compactMap { _, result in try? result.get() }
}
}
Update
extension CloudKitManager {
func updateNote(recordID: CKRecord.ID, newTitle: String) async throws -> CKRecord {
// Fetch the existing record first to get its change tag
let record = try await privateDB.record(for: recordID)
record["title"] = newTitle as CKRecordValue
record["modifiedAt"] = Date() as CKRecordValue
// Save returns the updated record
return try await privateDB.save(record)
}
}
Delete
extension CloudKitManager {
// Delete a single record
func deleteNote(recordID: CKRecord.ID) async throws {
try await privateDB.deleteRecord(withID: recordID)
}
// Batch operations using modifyRecords
func batchSaveAndDelete(
toSave: [CKRecord],
toDelete: [CKRecord.ID]
) async throws {
let (saveResults, deleteResults) = try await privateDB.modifyRecords(
saving: toSave,
deleting: toDelete,
savePolicy: .changedKeys, // Only upload changed fields
atomicityType: .nonAtomic // .full for all-or-nothing
)
for (id, result) in saveResults {
switch result {
case .success(let record):
print("Saved: \(record.recordID.recordName)")
case .failure(let error):
print("Save failed for \(id): \(error)")
}
}
print("Deleted \(deleteResults.count) records")
}
}
CKQuery with NSPredicate
extension CloudKitManager {
// Fetch all records of a type
func fetchAllNotes() async throws -> [CKRecord] {
let predicate = NSPredicate(value: true)
let query = CKQuery(recordType: "Note", predicate: predicate)
query.sortDescriptors = [NSSortDescriptor(key: "createdAt", ascending: false)]
let (results, _) = try await privateDB.records(matching: query)
return results.compactMap { _, result in try? result.get() }
}
// Filtered query with string matching
func searchNotes(containing text: String) async throws -> [CKRecord] {
// Tokenized field queries use CONTAINS; CloudKit also supports
// BEGINSWITH, ==, IN, and tokenized full-text search via allTokens
let predicate = NSPredicate(format: "title CONTAINS %@", text)
let query = CKQuery(recordType: "Note", predicate: predicate)
let (results, _) = try await privateDB.records(matching: query)
return results.compactMap { _, result in try? result.get() }
}
// Compound predicates
func fetchRecentNotes(after date: Date, tag: String) async throws -> [CKRecord] {
let datePredicate = NSPredicate(format: "createdAt > %@", date as NSDate)
let tagPredicate = NSPredicate(format: "tags CONTAINS %@", tag)
let compound = NSCompoundPredicate(
andPredicateWithSubpredicates: [datePredicate, tagPredicate]
)
let query = CKQuery(recordType: "Note", predicate: compound)
let (results, _) = try await privateDB.records(matching: query)
return results.compactMap { _, result in try? result.get() }
}
// Paginated query using cursor
func fetchNotesPaginated(
cursor: CKQueryOperation.Cursor? = nil,
limit: Int = 20
) async throws -> ([CKRecord], CKQueryOperation.Cursor?) {
if let cursor = cursor {
let (results, newCursor) = try await privateDB.records(
continuingMatchFrom: cursor,
resultsLimit: limit
)
let records = results.compactMap { _, result in try? result.get() }
return (records, newCursor)
} else {
let query = CKQuery(recordType: "Note", predicate: NSPredicate(value: true))
query.sortDescriptors = [NSSortDescriptor(key: "createdAt", ascending: false)]
let (results, newCursor) = try await privateDB.records(
matching: query,
resultsLimit: limit
)
let records = results.compactMap { _, result in try? result.get() }
return (records, newCursor)
}
}
}
CKSubscription for Real-Time Push
extension CloudKitManager {
// Query subscription: fires when records matching a predicate change
func subscribeToNoteChanges() async throws {
let subscription = CKQuerySubscription(
recordType: "Note",
predicate: NSPredicate(value: true),
subscriptionID: "note-changes",
options: [.firesOnRecordCreation, .firesOnRecordUpdate, .firesOnRecordDeletion]
)
let notification = CKSubscription.NotificationInfo()
notification.title = "Note Updated"
notification.alertBody = "A note was modified"
notification.shouldBadge = true
notification.soundName = "default"
notification.shouldSendContentAvailable = true // Silent push for background refresh
subscription.notificationInfo = notification
try await privateDB.save(subscription)
}
// Database subscription: fires on any change in the database
func subscribeToDatabaseChanges() async throws {
let subscription = CKDatabaseSubscription(subscriptionID: "private-db-changes")
let notification = CKSubscription.NotificationInfo()
notification.shouldSendContentAvailable = true
subscription.notificationInfo = notification
try await privateDB.save(subscription)
}
// Fetch changes since last sync using server change tokens
func fetchChanges(in zoneID: CKRecordZone.ID = .default) async throws {
// Load saved token
let tokenData = UserDefaults.standard.data(forKey: "zoneChangeToken-\(zoneID.zoneName)")
let token = tokenData.flatMap {
try? NSKeyedUnarchiver.unarchivedObject(ofClass: CKServerChangeToken.self, from: $0)
}
let changes = try await privateDB.recordZoneChanges(inZoneWith: zoneID, since: token)
for modification in changes.modificationResultsByID {
let (recordID, result) = modification
switch result {
case .success(let modification):
print("Modified: \(recordID.recordName), record: \(modification.record.recordType)")
case .failure(let error):
print("Error for \(recordID): \(error)")
}
}
for deletion in changes.deletions {
print("Deleted: \(deletion.recordID.recordName), type: \(deletion.recordType)")
}
// Persist the new change token for next sync
if let newToken = changes.changeToken,
let data = try? NSKeyedArchiver.archivedData(
withRootObject: newToken, requiringSecureCoding: true
) {
UserDefaults.standard.set(data, forKey: "zoneChangeToken-\(zoneID.zoneName)")
}
}
}
CKShare and Sharing Records
extension CloudKitManager {
// Create a share for a record
func shareRecord(_ record: CKRecord) async throws -> CKShare {
let share = CKShare(rootRecord: record)
share[CKShare.SystemFieldKey.title] = "Shared Note" as CKRecordValue
share[CKShare.SystemFieldKey.shareType] = "com.app.note" as CKRecordValue
share.publicPermission = .readOnly // .none, .readOnly, .readWrite
// Save both the record and the share atomically
let (savedResults, _) = try await privateDB.modifyRecords(
saving: [record, share],
deleting: []
)
guard let savedShare = try? savedResults[share.recordID]?.get() as? CKShare else {
throw CloudKitError.shareFailed
}
// share.url contains the URL to send to participants
print("Share URL: \(savedShare.url?.absoluteString ?? "none")")
return savedShare
}
// Accept a share from a URL
func acceptShare(metadata: CKShare.Metadata) async throws {
try await container.accept(metadata)
}
// Fetch records shared with this user
func fetchSharedRecords() async throws -> [CKRecord] {
let zones = try await sharedDB.allRecordZones()
var allRecords: [CKRecord] = []
for zone in zones {
let query = CKQuery(recordType: "Note", predicate: NSPredicate(value: true))
let (results, _) = try await sharedDB.records(
matching: query,
inZoneWith: zone.zoneID
)
allRecords += results.compactMap { _, result in try? result.get() }
}
return allRecords
}
}
// SwiftUI wrapper for the system sharing controller
struct CloudSharingView: UIViewControllerRepresentable {
let share: CKShare
let container: CKContainer
func makeUIViewController(context: Context) -> UICloudSharingController {
let controller = UICloudSharingController(share: share, container: container)
controller.availablePermissions = [.allowReadOnly, .allowReadWrite, .allowPrivate]
return controller
}
func updateUIViewController(_ uiViewController: UICloudSharingController, context: Context) {}
}
enum CloudKitError: Error {
case shareFailed
case accountUnavailable
case recordNotFound
}
CloudKit Dashboard
The CloudKit Dashboard at https://icloud.developer.apple.com provides:
Schema
: View and modify record types, fields, and indexes. Add queryable and sortable indexes for any field used in CKQuery predicates or sort descriptors.
Data Browser
: Query, create, edit, and delete records in public, private (via Team Development), and shared databases.
Logs
: View server-side request logs, error breakdowns, and push notification delivery status.
Telemetry
: Monitor request counts, error rates, latency, and data transfer metrics.
Subscriptions
: View and manage active CKSubscription objects.
Security Roles
: Control public database access with custom roles (e.g., allowing authenticated users to create records).
Deployment
: Promote your development schema to production. This is a one-way operation and cannot be reversed.
Reset Development
: Reset the development environment schema to match production when needed.
Integration with SwiftData/CoreData
NSPersistentCloudKitContainer (CoreData + CloudKit)
import CoreData
class PersistenceController {
static let shared = PersistenceController()
let container: NSPersistentCloudKitContainer
init() {
container = NSPersistentCloudKitContainer(name: "Model")
guard let description = container.persistentStoreDescriptions.first else {
fatalError("No persistent store description found")
}
// Enable CloudKit sync
description.cloudKitContainerOptions = NSPersistentCloudKitContainerOptions(
containerIdentifier: "iCloud.com.yourapp.name"
)
// Required: enable remote change notifications
description.setOption(
true as NSNumber,
forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey
)
// Required: enable history tracking for CloudKit sync
description.setOption(
true as NSNumber,
forKey: NSPersistentHistoryTrackingKey
)
container.loadPersistentStores { description, error in
if let error = error {
fatalError("Core Data store failed to load: \(error)")
}
}
// Automatically merge remote changes into the view context
container.viewContext.automaticallyMergesChangesFromParent = true
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
// Observe remote changes
NotificationCenter.default.addObserver(
self,
selector: #selector(handleRemoteChange),
name: .NSPersistentStoreRemoteChange,
object: container.persistentStoreCoordinator
)
}
@objc func handleRemoteChange(_ notification: Notification) {
print("Remote change received from CloudKit")
// Process persistent history if needed
}
}
SwiftData with CloudKit (iOS 17+)
import SwiftData
@Model
class Note {
var title: String
var body: String
var createdAt: Date
var tags: [String]
// Relationships must be optional for CloudKit compatibility
var folder: Folder?
init(title: String, body: String) {
self.title = title
self.body = body
self.createdAt = Date()
self.tags = []
}
}
// SwiftData syncs with CloudKit automatically when the iCloud capability is enabled
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(for: [Note.self])
}
}
// Requirements for SwiftData + CloudKit:
// 1. Enable iCloud and CloudKit in Signing & Capabilities
// 2. All stored properties must have default values or be optional
// 3. No unique constraints (CloudKit does not support them)
// 4. Relationships must be optional with no required cascade rules
// 5. @Attribute(.externalStorage) maps to CKAsset for large data
Custom Record Zones
extension CloudKitManager {
// Custom zones allow atomic commits and change tracking
func createCustomZone() async throws -> CKRecordZone {
let zone = CKRecordZone(zoneName: "NotesZone")
let savedZone = try await privateDB.save(zone)
return savedZone
}
func saveToCustomZone(title: String) async throws -> CKRecord {
let zoneID = CKRecordZone.ID(zoneName: "NotesZone")
let recordID = CKRecord.ID(recordName: UUID().uuidString, zoneID: zoneID)
let record = CKRecord(recordType: "Note", recordID: recordID)
record["title"] = title as CKRecordValue
return try await privateDB.save(record)
}
}
Modern CloudKit (iOS 17+)
CKSyncEngine
CKSyncEngine
is Apple's recommended sync engine that replaces manual change token management, zone fetching, and subscription handling. It encapsulates the full sync lifecycle -- scheduling, batching, retry logic, conflict resolution, and account change handling -- into a single, delegate-driven API.
Why CKSyncEngine replaces manual change token fetching
: Previously, developers had to manually manage
CKServerChangeToken
objects, handle
CKFetchRecordZoneChangesOperation
, process
CKModifyRecordZonesOperation
, manage retry and error logic, and track database subscriptions.
CKSyncEngine
handles all of this automatically, dramatically reducing boilerplate and the surface area for sync bugs.
import CloudKit
// MARK: - CKSyncEngine Setup
class SyncManager: CKSyncEngineDelegate {
let syncEngine: CKSyncEngine
init() {
// Load any previously persisted sync engine state
let lastKnownState = Self.loadSyncEngineState()
// Configure the sync engine
let configuration = CKSyncEngine.Configuration(
database: CKContainer.default().privateCloudDatabase,
stateSerialization: lastKnownState,
delegate: self
)
syncEngine = CKSyncEngine(configuration)
}
// MARK: - CKSyncEngineDelegate Methods
// Called when the sync engine has events to process
func handleEvent(_ event: CKSyncEngine.Event, syncEngine: CKSyncEngine) async {
switch event {
case .stateUpdate(let stateUpdate):
// Persist the sync engine state so it can resume after app relaunch
Self.saveSyncEngineState(stateUpdate.stateSerialization)
case .accountChange(let event):
handleAccountChange(event)
case .fetchedDatabaseChanges(let event):
// New zones discovered or zones deleted on the server
for modification in event.modifications {
print("Zone modified: \(modification.zoneID)")
}
for deletion in event.deletions {
print("Zone deleted: \(deletion.zoneID)")
// Remove local data for this zone
}
case .fetchedRecordZoneChanges(let event):
handleFetchedChanges(event)
case .sentDatabaseChanges(let event):
// Confirmation that zone creates/deletes were sent
for saved in event.savedZones {
print("Zone saved to server: \(saved.zoneID)")
}
case .sentRecordZoneChanges(let event):
handleSentChanges(event)
case .willFetchChanges:
// Prepare for incoming changes (e.g., show sync indicator)
break
case .didFetchChanges:
// All fetched changes have been processed
break
case .willSendChanges:
break
case .didSendChanges:
break
@unknown default:
break
}
}
// Called when the engine needs the next batch of records to send
func nextRecordZoneChangeBatch(
_ context: CKSyncEngine.SendChangesContext,
syncEngine: CKSyncEngine
) async -> CKSyncEngine.RecordZoneChangeBatch? {
// Get pending changes from the engine's state
let pendingChanges = syncEngine.state.pendingRecordZoneChanges
// Build a batch from your local data
let batch = await CKSyncEngine.RecordZoneChangeBatch(pendingChanges: pendingChanges) { recordID in
// Return the CKRecord for this ID from your local store
return self.buildRecord(for: recordID)
}
return batch
}
// MARK: - Handling Fetched Changes
private func handleFetchedChanges(_ event: CKSyncEngine.Event.FetchedRecordZoneChanges) {
// Process modifications (inserts and updates from the server)
for modification in event.modifications {
let record = modification.record
// Upsert into your local store
saveLocally(record)
print("Fetched: \(record.recordType) - \(record.recordID.recordName)")
}
// Process deletions
for deletion in event.deletions {
deleteLocally(recordID: deletion.recordID)
print("Deleted remotely: \(deletion.recordID.recordName)")
}
}
// MARK: - Handling Sent Changes and Conflicts
private func handleSentChanges(_ event: CKSyncEngine.Event.SentRecordZoneChanges) {
// Records successfully saved to the server
for saved in event.savedRecords {
// Update local store with server-assigned system fields
updateLocalSystemFields(saved)
print("Sent to server: \(saved.recordID.recordName)")
}
// Records that failed to save
for failed in event.failedRecordSaves {
let recordID = failed.record.recordID
switch failed.error.code {
case .serverRecordChanged:
// CONFLICT: The server has a newer version
// Resolve by merging or choosing server/client wins
if let serverRecord = failed.error.serverRecord {
resolveConflict(
clientRecord: failed.record,
serverRecord: serverRecord
)
}
case .zoneNotFound:
// Zone doesn't exist yet; the engine will auto-create it on next sync
break
case .unknownItem:
// Record was already deleted on the server
deleteLocally(recordID: recordID)
default:
print("Failed to save \(recordID): \(failed.error)")
}
}
// Deletions confirmed
for deletedID in event.deletedRecordIDs {
print("Server confirmed deletion: \(deletedID.recordName)")
}
}
// MARK: - Conflict Resolution
private func resolveConflict(clientRecord: CKRecord, serverRecord: CKRecord) {
// Strategy: merge non-conflicting fields, server wins for conflicts
let mergedRecord = serverRecord
// Example: merge by taking the latest modification date per field
// Or implement custom logic per record type
if let clientModified = clientRecord.modificationDate,
let serverModified = serverRecord.modificationDate,
clientModified > serverModified {
// Client is newer for this field — apply client changes
for key in clientRecord.allKeys() {
mergedRecord[key] = clientRecord[key]
}
}
// Tell the engine to retry with the merged record
syncEngine.state.add(pendingRecordZoneChanges: [
.saveRecord(mergedRecord.recordID)
])
}
// MARK: - Account Changes
private func handleAccountChange(_ event: CKSyncEngine.Event.AccountChange) {
switch event.changeType {
case .signIn:
// User signed into iCloud — start syncing
print("iCloud account signed in")
case .switchAccounts:
// Different iCloud account — clear local cache and re-sync
clearLocalData()
print("iCloud account switched")
case .signOut:
// User signed out — optionally keep local data or clear it
print("iCloud account signed out")
@unknown default:
break
}
}
// MARK: - Scheduling Sync
func addPendingChange(recordID: CKRecord.ID) {
// Tell the engine there's a local change to sync
syncEngine.state.add(pendingRecordZoneChanges: [
.saveRecord(recordID)
])
}
func addPendingDeletion(recordID: CKRecord.ID) {
syncEngine.state.add(pendingRecordZoneChanges: [
.deleteRecord(recordID)
])
}
// MARK: - State Persistence
private static func saveSyncEngineState(_ state: CKSyncEngine.State.Serialization) {
if let data = try? JSONEncoder().encode(state) {
UserDefaults.standard.set(data, forKey: "ckSyncEngineState")
}
}
private static func loadSyncEngineState() -> CKSyncEngine.State.Serialization? {
guard let data = UserDefaults.standard.data(forKey: "ckSyncEngineState") else { return nil }
return try? JSONDecoder().decode(CKSyncEngine.State.Serialization.self, from: data)
}
// MARK: - Local Store Helpers (implement with your persistence layer)
private func buildRecord(for recordID: CKRecord.ID) -> CKRecord? {
// Fetch your local model and convert to CKRecord
return nil
}
private func saveLocally(_ record: CKRecord) {
// Insert or update in your local database (SwiftData, CoreData, etc.)
}
private func deleteLocally(recordID: CKRecord.ID) {
// Delete from your local database
}
private func updateLocalSystemFields(_ record: CKRecord) {
// Store the server's changeTag and other system fields locally
}
private func clearLocalData() {
// Clear all local synced data on account switch
}
}
CKSystemSharingUIObserver
CKSystemSharingUIObserver
provides modern observation of CloudKit sharing UI events, allowing your app to react when the system sharing controller saves a share or encounters an error.
import CloudKit
class SharingManager {
private var sharingObserver: CKSystemSharingUIObserver?
func observeSharingUI(for container: CKContainer) {
sharingObserver = CKSystemSharingUIObserver(container)
// Observe when a share is saved from the system UI
sharingObserver?.systemSharingUIDidSaveShareBlock = { recordID, result in
switch result {
case .success(let share):
print("Share saved: \(share.url?.absoluteString ?? "no URL")")
// Update your UI to reflect the share
case .failure(let error):
print("Share save failed: \(error)")
}
}
// Observe when a share is stopped from the system UI
sharingObserver?.systemSharingUIDidStopSharingBlock = { recordID, result in
switch result {
case .success:
print("Sharing stopped for record: \(recordID)")
// Update your UI to remove sharing indicators
case .failure(let error):
print("Stop sharing failed: \(error)")
}
}
}
}
---
# Core AI -- Custom On-Device AI Models on Apple Silicon
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-core-ai.html
← Back to all frameworks & guides
All articles →AI and Machine Learning · Reference guideCore AI -- Custom On-Device AI Models on Apple SiliconRepository guidance for Core AI. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
Overview
1. When to Choose Core AI
2. Xcode Project Setup
3. Load a Model and Function
4. Run NDArray Inference
5. Specialization, Caching, and Storage
6. Ahead-of-Time Compilation
7. Debugging and Profiling
8. Security and Product Rules
9. Review Checklist
Released SDK boundary
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Choose a custom model workload
↓2
Verify installed SDK support
↓3
Prepare model assets
↓4
Measure inference on device
02 / ArchitectureResponsibility boundariesBoundary 1
Model assetsBoundary 2
Inference boundaryBoundary 3
App featureConnected responsibilities, not a required class hierarchy or an execution trace.
Overview
Core AI is Apple's framework for bringing custom AI models into an app and running them efficiently on Apple silicon. Use it when you own or ship a model asset and need on-device inference across CPU, GPU, and Neural Engine with Swift APIs, model specialization, caching, ahead-of-time compilation, and Core AI debugging tools.
Use
Foundation Models
when you need language generation, structured output, tools, or Apple Intelligence model access. Use
Core ML
when your model is not a neural network workload, or when an existing Core ML pipeline already covers the feature.
Release status checked 2026-09-16: iOS 27 and Xcode 27 are released. This guide describes the released API direction; Core AI examples have not been compiled on this repository’s Xcode 26.6 verification host. See
release verification
.
1. When to Choose Core AI
Need
Prefer
Ship a custom neural model in .aimodel / .aimodelc format
Core AI
Prompt Apple Foundation models, PCC, or a server LLM through one API
Foundation Models
Use trees, classical ML, tabular feature engineering, or existing .mlmodel assets
Core ML
Write custom GPU kernels or render/compute pipelines directly
Metal
Run MLX models or local agent experiments on macOS
MLX Swift
Core AI starts from an
.aimodel
source asset. Convert and optimize model assets before app integration using Apple's Core AI PyTorch Extensions and Core AI Optimization tooling.
2. Xcode Project Setup
Add the
.aimodel
file to the app or package target.
Confirm the file appears in the target build phases.
Install the Metal Toolchain. Builds that include
.aimodel
files fail without the required compiler.
Inspect the model in Xcode's model viewer before writing runtime code.
The model viewer should answer:
What are the model's function names?
What input and output names does each function expect?
Are inputs
NDArray
values or images?
What compute/storage precision does the asset use?
Is the author/license metadata present?
3. Load a Model and Function
import CoreAI
import Foundation
@available(iOS 27.0, macOS 27.0, visionOS 27.0, *)
enum ClassifierError: Error {
case missingModel
case missingFunction(String)
case missingOutput(String)
case unexpectedOutputType
}
@available(iOS 27.0, macOS 27.0, visionOS 27.0, *)
actor ImageClassifier {
private let function: InferenceFunction
init(modelURL: URL) async throws {
let model = try await AIModel(contentsOf: modelURL, options: .default)
guard let descriptor = model.functionDescriptor(for: "main") else {
throw ClassifierError.missingFunction("main")
}
// Use the descriptor for logging, validation, or assertions in debug builds.
debugPrint(descriptor)
guard let function = try model.loadFunction(named: "main") else {
throw ClassifierError.missingFunction("main")
}
self.function = function
}
}
AIModel
represents the specialized model asset for the current device. Loading an
InferenceFunction
prepares runnable resources for a specific compute graph and can be expensive, so do it before the first user-visible inference path.
4. Run
NDArray
Inference
The converted model defines the exact input and output names. Match those names in code.
@available(iOS 27.0, macOS 27.0, visionOS 27.0, *)
extension ImageClassifier {
func predict(features: [Float]) async throws -> [Float] {
var input = NDArray(shape: [1, features.count], scalarType: .float32)
var mutable = input.mutableView(as: Float.self)
guard let elements = mutable.contiguousElements else {
throw ClassifierError.unexpectedOutputType
}
for index in features.indices {
elements[index] = features[index]
}
var outputs = try await function.run(inputs: ["input": input])
guard let predictionValue = outputs.remove("prediction") else {
throw ClassifierError.missingOutput("prediction")
}
guard let prediction = predictionValue.ndArray else {
throw ClassifierError.unexpectedOutputType
}
let view = prediction.view(as: Float.self)
guard let values = view.contiguousElements else {
throw ClassifierError.unexpectedOutputType
}
return Array(values)
}
}
Keep the model's conversion metadata and the Swift constants together. A mismatch in input names, shapes, or scalar type is an integration defect, not a prompt problem.
5. Specialization, Caching, and Storage
Loading a
.aimodel
can require device-specific specialization. The default behavior specializes and caches the result so later loads are faster.
Use explicit specialization when you want to control timing or cache policy:
let model = try await AIModel.specialize(
contentsOf: modelURL,
options: .default,
cachePolicy: .persistent
)
let bookmark = model.bookmarkData
UserDefaults.standard.set(bookmark, forKey: "classifier.model.bookmark")
Use persistent cache policy only when the product really needs it. Specialized assets are tied to OS versions, source model content, storage pressure, and compute options. If bookmark resolution fails later, redownload or rebundle the source model and specialize again.
For app groups, use an
AIModelCache(appGroup:)
so related apps/extensions do not duplicate specialized assets.
6. Ahead-of-Time Compilation
Large models can take too long to specialize on first launch. Use
coreai-build
to move the expensive compile step to the build machine:
xcodebuild -downloadComponent MetalToolchain
xcrun coreai-build compile MyModel.aimodel --platform iOS --min-deployment-version 27.0 --output compiled/
At runtime, choose the compiled asset for the current device architecture:
let arch = AIModel.deviceArchitectureName
let assetName = "MyModel.\(arch).aimodelc"
Host architecture-specific
.aimodelc
assets remotely when they are large. Background Assets can manage downloads and updates.
7. Debugging and Profiling
Core AI ships with three main debugging surfaces:
Xcode model viewer
for metadata, operation distribution, function signatures, precision, and model inspection.
Core AI debug gauge
during a debug session for model load, specialization, and inference activity.
Core AI instrument
for timing across CPU, GPU, and Neural Engine.
Core AI Debugger app
for inspecting
.aimodel
structure and tracing tensor values back to Python source.
Use this workflow:
Validate the
.aimodel
in the Core AI Debugger before app integration.
Confirm function names and signatures in Xcode.
Run with the Xcode gauge while exercising real UI flows.
Profile with the Core AI instrument before changing compute options.
Compare outputs against reference data whenever conversion or optimization changes.
8. Security and Product Rules
Keep model licenses and attribution in the asset metadata and app documentation.
Treat downloaded models as executable product inputs: sign, version, checksum, and stage rollout.
Do not run first-time specialization on a critical interaction without progress UI.
Provide thermal, battery, and storage fallbacks for large models.
Prefer
.default
compute options until profiling proves a need to override.
Keep user data on device unless the feature explicitly explains network use.
9. Review Checklist
[ ]
.aimodel
target membership and Metal Toolchain setup are documented
[ ] Function names, input names, output names, shapes, and scalar types are asserted
[ ] First-run specialization has loading UI and cancellation behavior
[ ] Cache policy is intentional and storage impact is tested
[ ] AOT compilation considered for large models
[ ] Debugger/gauge/Instruments workflow captured in the PR
[ ] Reference-output regression tests exist for conversion/optimization changes
[ ] Fallback path exists for unsupported devices, storage pressure, and OS updates
See also:
docs/frameworks/foundation-models.md
,
docs/frameworks/ml/coreml.md
,
docs/frameworks/ml/on-device-ai.md
,
docs/frameworks/metal.md
.
Released SDK boundary
Apple’s Core AI documentation
now marks the 27.0 platform APIs as non-beta. Model format, supported operators, memory requirements and device eligibility still need validation per model. Use
Apple’s Core AI model adapter
when connecting a supported local model to Foundation Models; it is a separate dependency, not vendored framework code. This repository has not run a converted Core AI model on-device.
---
# Core Data
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-core-data.html
← Back to all frameworks & guides
All articles →Data Management · Reference guideCore DataRepository guidance for Core Data. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
NSManagedObjectModel and .xcdatamodeld
Persistent Container Setup
NSManagedObjectContext — View and Background
NSFetchRequest and NSPredicate
NSFetchedResultsController
Relationships
Lightweight Migration
Core Data with CloudKit
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Define persistent model
↓2
Create managed contexts
↓3
Fetch and save changes
↓4
Test migration and failure
02 / ArchitectureResponsibility boundariesBoundary 1
Persistent storeBoundary 2
Context ownershipBoundary 3
View modelsConnected responsibilities, not a required class hierarchy or an execution trace.
NSManagedObjectModel and .xcdatamodeld
Core Data uses an
.xcdatamodeld
file to define your schema visually in Xcode. Each entity maps to an
NSManagedObject
subclass.
// Auto-generated NSManagedObject subclass (Codegen: Class Definition)
// Or manually define with Codegen: Manual/None
import CoreData
@objc(Task)
public class Task: NSManagedObject {
@NSManaged public var id: UUID
@NSManaged public var title: String
@NSManaged public var isCompleted: Bool
@NSManaged public var createdAt: Date
@NSManaged public var priority: Int16
@NSManaged public var category: Category? // Relationship
}
extension Task {
@nonobjc public class func fetchRequest() -> NSFetchRequest<Task> {
return NSFetchRequest<Task>(entityName: "Task")
}
var priorityLevel: Priority {
get { Priority(rawValue: priority) ?? .medium }
set { priority = newValue.rawValue }
}
}
enum Priority: Int16 {
case low = 0, medium = 1, high = 2
}
Persistent Container Setup
class PersistenceController {
static let shared = PersistenceController()
let container: NSPersistentContainer
init(inMemory: Bool = false) {
container = NSPersistentContainer(name: "MyApp")
if inMemory {
container.persistentStoreDescriptions.first?.url = URL(fileURLWithPath: "/dev/null")
}
container.loadPersistentStores { description, error in
if let error = error as NSError? {
fatalError("Core Data store failed: \(error), \(error.userInfo)")
}
}
container.viewContext.automaticallyMergesChangesFromParent = true
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
}
// Preview helper
static var preview: PersistenceController = {
let controller = PersistenceController(inMemory: true)
let ctx = controller.container.viewContext
for i in 0..<10 {
let task = Task(context: ctx)
task.id = UUID()
task.title = "Sample Task \(i)"
task.isCompleted = i.isMultiple(of: 3)
task.createdAt = Date()
task.priority = Int16(i % 3)
}
try? ctx.save()
return controller
}()
}
// Inject into SwiftUI
@main
struct MyApp: App {
let persistence = PersistenceController.shared
var body: some Scene {
WindowGroup {
ContentView()
.environment(\.managedObjectContext, persistence.container.viewContext)
}
}
}
NSManagedObjectContext — View and Background
// View context (main thread) — for reads and light writes
let viewContext = PersistenceController.shared.container.viewContext
// Background context — for heavy operations
func importData(_ items: [ItemDTO]) async throws {
let context = PersistenceController.shared.container.newBackgroundContext()
context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
try await context.perform {
for dto in items {
let task = Task(context: context)
task.id = dto.id
task.title = dto.title
task.isCompleted = dto.completed
task.createdAt = dto.createdAt
}
try context.save()
}
}
// performBackgroundTask convenience
PersistenceController.shared.container.performBackgroundTask { context in
// Already on a background queue
let request = Task.fetchRequest()
request.predicate = NSPredicate(format: "isCompleted == YES")
if let completed = try? context.fetch(request) {
completed.forEach { context.delete($0) }
try? context.save()
}
}
NSFetchRequest and NSPredicate
// Basic fetch
let request = Task.fetchRequest()
request.sortDescriptors = [
NSSortDescriptor(keyPath: \Task.priority, ascending: false),
NSSortDescriptor(keyPath: \Task.createdAt, ascending: true),
]
let allTasks = try viewContext.fetch(request)
// Filtered fetch
request.predicate = NSPredicate(format: "isCompleted == %@ AND priority >= %d", NSNumber(value: false), 1)
// Compound predicates
let notCompleted = NSPredicate(format: "isCompleted == NO")
let highPriority = NSPredicate(format: "priority == %d", Priority.high.rawValue)
let searchPredicate = NSPredicate(format: "title CONTAINS[cd] %@", searchText)
request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [notCompleted, highPriority, searchPredicate])
// Fetch limit and batch size
request.fetchLimit = 20
request.fetchBatchSize = 50
// Count instead of fetching objects
let count = try viewContext.count(for: request)
// Fetch specific properties
request.propertiesToFetch = ["title", "priority"]
request.resultType = .dictionaryResultType
// SwiftUI @FetchRequest
struct TaskListView: View {
@Environment(\.managedObjectContext) private var viewContext
@FetchRequest(
sortDescriptors: [SortDescriptor(\.createdAt, order: .reverse)],
predicate: NSPredicate(format: "isCompleted == NO"),
animation: .default
)
private var tasks: FetchedResults<Task>
var body: some View {
List {
ForEach(tasks) { task in
TaskRow(task: task)
}
.onDelete(perform: deleteTasks)
}
}
private func deleteTasks(offsets: IndexSet) {
offsets.map { tasks[$0] }.forEach(viewContext.delete)
try? viewContext.save()
}
}
NSFetchedResultsController
// Used primarily in UIKit for efficient table/collection view updates
class TaskListViewController: UITableViewController, NSFetchedResultsControllerDelegate {
private var fetchedResultsController: NSFetchedResultsController<Task>!
override func viewDidLoad() {
super.viewDidLoad()
let request = Task.fetchRequest()
request.sortDescriptors = [
NSSortDescriptor(keyPath: \Task.priority, ascending: false),
NSSortDescriptor(keyPath: \Task.title, ascending: true),
]
request.predicate = NSPredicate(format: "isCompleted == NO")
fetchedResultsController = NSFetchedResultsController(
fetchRequest: request,
managedObjectContext: PersistenceController.shared.container.viewContext,
sectionNameKeyPath: "priority",
cacheName: "taskCache"
)
fetchedResultsController.delegate = self
try? fetchedResultsController.performFetch()
}
// Diffable data source integration
func controller(
_ controller: NSFetchedResultsController<NSFetchRequestResult>,
didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference
) {
let snapshot = snapshot as NSDiffableDataSourceSnapshot<String, NSManagedObjectID>
dataSource.apply(snapshot, animatingDifferences: true)
}
}
Relationships
// One-to-Many: Category has many Tasks
@objc(Category)
public class Category: NSManagedObject {
@NSManaged public var id: UUID
@NSManaged public var name: String
@NSManaged public var tasks: NSSet? // One-to-many (inverse of Task.category)
}
extension Category {
var tasksArray: [Task] {
let set = tasks as? Set<Task> ?? []
return set.sorted { $0.createdAt < $1.createdAt }
}
@objc(addTasksObject:)
@NSManaged public func addToTasks(_ value: Task)
@objc(removeTasksObject:)
@NSManaged public func removeFromTasks(_ value: Task)
}
// Creating with relationships
let category = Category(context: viewContext)
category.id = UUID()
category.name = "Work"
let task = Task(context: viewContext)
task.id = UUID()
task.title = "Prepare presentation"
task.category = category // Sets both sides with inverse relationship
try viewContext.save()
// Many-to-Many: Tasks can have many Tags, Tags can have many Tasks
// Define in .xcdatamodeld with inverse relationships on both sides
// Both sides generate NSSet properties
Lightweight Migration
// Automatic lightweight migration (handles simple changes)
let description = NSPersistentStoreDescription()
description.shouldMigrateStoreAutomatically = true
description.shouldInferMappingModelAutomatically = true
container.persistentStoreDescriptions = [description]
// Supported lightweight migrations:
// - Adding a new attribute (with default value)
// - Removing an attribute
// - Renaming an attribute (set renaming identifier in model editor)
// - Adding a new entity
// - Adding/removing a relationship
// - Changing optional <-> non-optional (if default value set)
Core Data with CloudKit
class CloudPersistenceController {
static let shared = CloudPersistenceController()
let container: NSPersistentCloudKitContainer
init() {
container = NSPersistentCloudKitContainer(name: "MyApp")
// Configure for CloudKit sync
guard let description = container.persistentStoreDescriptions.first else {
fatalError("No store description")
}
description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
description.cloudKitContainerOptions = NSPersistentCloudKitContainerOptions(
containerIdentifier: "iCloud.com.myapp.data"
)
container.loadPersistentStores { _, error in
if let error { fatalError("CloudKit store failed: \(error)") }
}
container.viewContext.automaticallyMergesChangesFromParent = true
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
// Observe remote changes
NotificationCenter.default.addObserver(
self,
selector: #selector(storeRemoteChange),
name: .NSPersistentStoreRemoteChange,
object: container.persistentStoreCoordinator
)
}
@objc private func storeRemoteChange(_ notification: Notification) {
// Handle remote changes if needed
}
}
---
# CoreLocation
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-core-location.html
← Back to all frameworks & guides
All articles →System Integration · Reference guideCoreLocationRepository guidance for Core Location. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
CLLocationManager Setup and Permissions
Continuous and One-Shot Location
Geocoding and Reverse Geocoding
Geofencing (CLCircularRegion)
iBeacon Monitoring
Background Location Updates
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Explain location purpose
↓2
Request appropriate access
↓3
Consume location updates
↓4
Stop unnecessary tracking
02 / ArchitectureResponsibility boundariesBoundary 1
Authorization stateBoundary 2
Location serviceBoundary 3
Feature consumerConnected responsibilities, not a required class hierarchy or an execution trace.
CLLocationManager Setup and Permissions
Add to
Info.plist
:
-
NSLocationWhenInUseUsageDescription
— required for foreground location
-
NSLocationAlwaysAndWhenInUseUsageDescription
— required for background location
import CoreLocation
@Observable
class LocationManager: NSObject, CLLocationManagerDelegate {
static let shared = LocationManager()
var currentLocation: CLLocation?
var authorizationStatus: CLAuthorizationStatus = .notDetermined
var locationError: Error?
private let manager = CLLocationManager()
override init() {
super.init()
manager.delegate = self
manager.desiredAccuracy = kCLLocationAccuracyBest
manager.distanceFilter = 10 // meters
}
func requestPermission() {
manager.requestWhenInUseAuthorization()
}
func requestAlwaysPermission() {
manager.requestAlwaysAuthorization()
}
// Delegate: authorization changed
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
authorizationStatus = manager.authorizationStatus
switch manager.authorizationStatus {
case .authorizedWhenInUse, .authorizedAlways:
manager.startUpdatingLocation()
case .denied, .restricted:
locationError = LocationError.permissionDenied
case .notDetermined:
break
@unknown default:
break
}
}
// Delegate: location updated
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
currentLocation = locations.last
}
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
locationError = error
}
}
enum LocationError: LocalizedError {
case permissionDenied
case locationUnavailable
var errorDescription: String? {
switch self {
case .permissionDenied: return "Location permission denied"
case .locationUnavailable: return "Location unavailable"
}
}
}
Continuous and One-Shot Location
extension LocationManager {
// Start continuous updates
func startTracking() {
manager.startUpdatingLocation()
}
func stopTracking() {
manager.stopUpdatingLocation()
}
// One-shot location request
func requestCurrentLocation() {
manager.requestLocation()
}
// Significant location changes (battery efficient, ~500m threshold)
func startSignificantLocationMonitoring() {
guard CLLocationManager.significantLocationChangeMonitoringAvailable() else { return }
manager.startMonitoringSignificantLocationChanges()
}
}
// SwiftUI usage
struct NearbyView: View {
let locationManager = LocationManager.shared
var body: some View {
VStack {
if let location = locationManager.currentLocation {
Text("Lat: \(location.coordinate.latitude, specifier: "%.4f")")
Text("Lon: \(location.coordinate.longitude, specifier: "%.4f")")
Text("Accuracy: \(location.horizontalAccuracy, specifier: "%.0f")m")
} else {
Text("Locating...")
}
}
.onAppear {
locationManager.requestPermission()
}
}
}
Geocoding and Reverse Geocoding
import CoreLocation
class GeocodingService {
private let geocoder = CLGeocoder()
// Address string to coordinates
func geocode(address: String) async throws -> CLLocation {
let placemarks = try await geocoder.geocodeAddressString(address)
guard let location = placemarks.first?.location else {
throw GeocodingError.noResults
}
return location
}
// Coordinates to address
func reverseGeocode(location: CLLocation) async throws -> String {
let placemarks = try await geocoder.reverseGeocodeLocation(location)
guard let placemark = placemarks.first else {
throw GeocodingError.noResults
}
return [
placemark.name,
placemark.locality,
placemark.administrativeArea,
placemark.country,
]
.compactMap { $0 }
.joined(separator: ", ")
}
// Structured address from placemark
func structuredAddress(from location: CLLocation) async throws -> Address {
let placemarks = try await geocoder.reverseGeocodeLocation(location)
guard let pm = placemarks.first else { throw GeocodingError.noResults }
return Address(
street: [pm.subThoroughfare, pm.thoroughfare].compactMap { $0 }.joined(separator: " "),
city: pm.locality ?? "",
state: pm.administrativeArea ?? "",
postalCode: pm.postalCode ?? "",
country: pm.country ?? "",
isoCountryCode: pm.isoCountryCode ?? ""
)
}
}
struct Address {
let street, city, state, postalCode, country, isoCountryCode: String
}
enum GeocodingError: Error {
case noResults
}
Geofencing (CLCircularRegion)
extension LocationManager {
func startMonitoringRegion(
center: CLLocationCoordinate2D,
radius: CLLocationDistance,
identifier: String
) {
guard CLLocationManager.isMonitoringAvailable(for: CLCircularRegion.self) else { return }
let region = CLCircularRegion(
center: center,
radius: min(radius, manager.maximumRegionMonitoringDistance),
identifier: identifier
)
region.notifyOnEntry = true
region.notifyOnExit = true
manager.startMonitoring(for: region)
}
func stopMonitoringRegion(identifier: String) {
for region in manager.monitoredRegions {
if region.identifier == identifier {
manager.stopMonitoring(for: region)
}
}
}
// Delegate callbacks
func locationManager(_ manager: CLLocationManager, didEnterRegion region: CLRegion) {
NotificationCenter.default.post(
name: .didEnterGeofence,
object: nil,
userInfo: ["regionId": region.identifier]
)
}
func locationManager(_ manager: CLLocationManager, didExitRegion region: CLRegion) {
NotificationCenter.default.post(
name: .didExitGeofence,
object: nil,
userInfo: ["regionId": region.identifier]
)
}
}
extension Notification.Name {
static let didEnterGeofence = Notification.Name("didEnterGeofence")
static let didExitGeofence = Notification.Name("didExitGeofence")
}
// Example: monitor arrival at office
let officeCoordinate = CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194)
LocationManager.shared.startMonitoringRegion(
center: officeCoordinate,
radius: 100,
identifier: "office"
)
iBeacon Monitoring
extension LocationManager {
func startBeaconMonitoring(uuid: UUID, major: UInt16? = nil, minor: UInt16? = nil) {
let constraint: CLBeaconIdentityConstraint
if let major, let minor {
constraint = CLBeaconIdentityConstraint(uuid: uuid, major: major, minor: minor)
} else if let major {
constraint = CLBeaconIdentityConstraint(uuid: uuid, major: major)
} else {
constraint = CLBeaconIdentityConstraint(uuid: uuid)
}
let region = CLBeaconRegion(beaconIdentityConstraint: constraint, identifier: uuid.uuidString)
region.notifyEntryStateOnDisplay = true
manager.startMonitoring(for: region)
manager.startRangingBeacons(satisfying: constraint)
}
func locationManager(_ manager: CLLocationManager, didRange beacons: [CLBeacon],
satisfying constraint: CLBeaconIdentityConstraint) {
for beacon in beacons {
let proximity: String
switch beacon.proximity {
case .immediate: proximity = "immediate"
case .near: proximity = "near"
case .far: proximity = "far"
case .unknown: proximity = "unknown"
@unknown default: proximity = "unknown"
}
print("Beacon \(beacon.minor): \(proximity), accuracy: \(beacon.accuracy)m")
}
}
}
Background Location Updates
// Enable in Xcode: Signing & Capabilities > Background Modes > Location updates
extension LocationManager {
func enableBackgroundUpdates() {
manager.allowsBackgroundLocationUpdates = true
manager.pausesLocationUpdatesAutomatically = false
manager.showsBackgroundLocationIndicator = true // Blue status bar indicator
}
// For navigation-style continuous tracking
func startNavigationTracking() {
manager.desiredAccuracy = kCLLocationAccuracyBestForNavigation
manager.activityType = .automotiveNavigation
enableBackgroundUpdates()
manager.startUpdatingLocation()
}
// For fitness tracking
func startFitnessTracking() {
manager.desiredAccuracy = kCLLocationAccuracyBest
manager.activityType = .fitness
manager.distanceFilter = 5
enableBackgroundUpdates()
manager.startUpdatingLocation()
}
}
// Visit monitoring (battery efficient, detects arrivals/departures)
extension LocationManager {
func startVisitMonitoring() {
manager.startMonitoringVisits()
}
func locationManager(_ manager: CLLocationManager, didVisit visit: CLVisit) {
let coordinate = visit.coordinate
let arrival = visit.arrivalDate
let departure = visit.departureDate
print("Visit at \(coordinate): arrived \(arrival), departed \(departure)")
}
}
---
# Core Spotlight RAG -- Private App-Local Retrieval for…
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-core-spotlight-rag.html
← Back to all frameworks & guides
All articles →AI and Machine Learning · Reference guideCore Spotlight RAG -- Private App-Local Retrieval for Foundation ModelsRepository guidance for Core Spotlight RAG, Core Spotlight. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
Overview
1. Architecture
2. Index App Content
3. Use SpotlightSearchTool
4. Contact and Identity Queries
5. Grounding Rules
6. Testing
7. Review Checklist
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Choose indexable content
↓2
Index scoped items
↓3
Retrieve relevant records
↓4
Ground the response
02 / ArchitectureResponsibility boundariesBoundary 1
Source documentsBoundary 2
Search indexBoundary 3
Retrieval consumerConnected responsibilities, not a required class hierarchy or an execution trace.
Overview
Core Spotlight indexes app content on device. In the iOS 27 generation, Apple adds
SpotlightSearchTool
, a Foundation Models
Tool
that lets a
LanguageModelSession
use app-indexed content as private retrieval context.
Use this for app-local RAG: notes, documents, tasks, messages, projects, photos, or any user-owned content the app already manages.
Core Spotlight indexes remain private to the device owner. Do not mirror this content to a server unless the product explicitly requires sync and explains it.
1. Architecture
App content store
-> CSSearchableItem + CSSearchableItemAttributeSet
-> CSSearchableIndex
-> SpotlightSearchTool
-> LanguageModelSession
-> grounded answer with app-local context
Core Spotlight is not just a search UI feature. It becomes the retrieval substrate the model can query through a tool.
2. Index App Content
import CoreSpotlight
import UniformTypeIdentifiers
struct Note: Identifiable {
let id: String
let title: String
let body: String
let modifiedAt: Date
}
func searchableItem(for note: Note) -> CSSearchableItem {
let attributes = CSSearchableItemAttributeSet(contentType: .text)
attributes.title = note.title
attributes.contentDescription = note.body
attributes.subject = note.title
attributes.contentCreationDate = note.modifiedAt
return CSSearchableItem(
uniqueIdentifier: note.id,
domainIdentifier: "notes",
attributeSet: attributes
)
}
func index(notes: [Note]) async throws {
let items = notes.map(searchableItem(for:))
try await CSSearchableIndex.default().indexSearchableItems(items)
}
Index content as it changes. A model cannot retrieve what the app never indexed, and stale indexes create stale answers.
3. Use
SpotlightSearchTool
import CoreSpotlight
import FoundationModels
@available(iOS 27.0, macOS 27.0, *)
func makeNotesSession() -> LanguageModelSession {
var source = CoreSpotlightSource(
fetchAttributes: [.subject, .contentDescription, .contentCreationDate]
)
source.maximumResultCount = 12
let configuration = SpotlightSearchTool.Configuration(
sources: [.coreSpotlight(source)]
)
let searchTool = SpotlightSearchTool(configuration: configuration)
return LanguageModelSession(
tools: [searchTool],
instructions: """
Answer using the user's indexed notes when relevant.
Cite note titles when you use note content.
Say when the notes do not contain enough information.
"""
)
}
Configure the source narrowly. Fetch only attributes the answer needs, set a bounded result count, and provide instructions for attribution and uncertainty.
4. Contact and Identity Queries
Searches involving "me", "my manager", or people in messages can require identity resolution. If your app supports person-centric retrieval, implement Apple's contact resolution hooks with your app's account/profile source.
Rules:
Do not infer identity from Contacts without permission and purpose.
Prefer the signed-in app profile when it is authoritative.
Keep identity resolution deterministic in tests.
Redact or avoid sensitive attributes unless the answer needs them.
5. Grounding Rules
The answer should distinguish retrieved facts from model reasoning:
Include source titles or stable item names in UI.
Use a "not found" answer when retrieval returns nothing relevant.
Do not let the model invent records that are absent from the index.
Reindex immediately after edits, deletes, imports, and sync conflict resolution.
Delete index entries when app content is deleted.
6. Testing
Create a deterministic test index:
Seed a few known
CSSearchableItem
values.
Ask queries with expected hits.
Ask queries with expected misses.
Verify the model calls the Spotlight tool before answering.
Verify answers cite retrieved item titles.
For model behavior, pair this with
docs/testing/evaluations.md
and a
ToolCallEvaluator
.
7. Review Checklist
[ ] Indexed fields are minimal, useful, and privacy-reviewed
[ ] Index updates run on create/update/delete/import/sync
[ ]
SpotlightSearchTool
result count and attributes are bounded
[ ] Instructions require source attribution and uncertainty
[ ] Deleted app content is removed from the Spotlight index
[ ] Tests cover hit, miss, stale-index, and tool-failure paths
[ ] No server RAG is used for private app-local content without explicit product need
See also:
docs/frameworks/foundation-models.md
,
docs/frameworks/app-intents-intelligence.md
,
docs/frameworks/usernotifications.md
,
docs/testing/evaluations.md
.
---
# CryptoKit
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-cryptokit.html
← Back to all frameworks & guides
All articles →Authentication, Security, and Privacy · Reference guideCryptoKitRepository guidance for CryptoKit. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
Hashing (SHA-256, SHA-384, SHA-512)
HMAC Authentication
AES-GCM Symmetric Encryption / Decryption
P256 / P384 / P521 Key Agreement and Signing
Curve25519 Key Exchange
Secure Enclave Integration
ChaChaPoly for Performance
Complete Secure Messaging Example
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Choose a cryptographic operation
↓2
Load protected key material
↓3
Authenticate or encrypt data
↓4
Handle verification failure
02 / ArchitectureResponsibility boundariesBoundary 1
Key storageBoundary 2
Cryptographic operationBoundary 3
Protected payloadConnected responsibilities, not a required class hierarchy or an execution trace.
Hashing (SHA-256, SHA-384, SHA-512)
import CryptoKit
import Foundation
// SHA-256
func sha256Hash(data: Data) -> String {
let digest = SHA256.hash(data: data)
return digest.compactMap { String(format: "%02x", $0) }.joined()
}
// SHA-384
func sha384Hash(data: Data) -> String {
let digest = SHA384.hash(data: data)
return digest.compactMap { String(format: "%02x", $0) }.joined()
}
// SHA-512
func sha512Hash(data: Data) -> String {
let digest = SHA512.hash(data: data)
return digest.compactMap { String(format: "%02x", $0) }.joined()
}
// Hash a string
let message = "Hello, CryptoKit!"
let messageData = Data(message.utf8)
let hash = sha256Hash(data: messageData)
// "a1b2c3..." (64 hex characters)
// Hash a file
func hashFile(at url: URL) throws -> SHA256Digest {
let data = try Data(contentsOf: url)
return SHA256.hash(data: data)
}
// Compare digests securely (constant-time comparison)
let digest1 = SHA256.hash(data: Data("abc".utf8))
let digest2 = SHA256.hash(data: Data("abc".utf8))
let isEqual = digest1 == digest2 // true — uses constant-time comparison
HMAC Authentication
func createHMAC(message: Data, key: SymmetricKey) -> Data {
let mac = HMAC<SHA256>.authenticationCode(for: message, using: key)
return Data(mac)
}
func verifyHMAC(message: Data, mac: Data, key: SymmetricKey) -> Bool {
HMAC<SHA256>.isValidAuthenticationCode(mac, authenticating: message, using: key)
}
// Usage
let key = SymmetricKey(size: .bits256)
let message = Data("Authenticate this message".utf8)
let mac = createHMAC(message: message, key: key)
let isValid = verifyHMAC(message: message, mac: mac, key: key) // true
// HMAC for API request signing
func signRequest(_ request: inout URLRequest, body: Data, secretKey: SymmetricKey) {
let timestamp = String(Int(Date().timeIntervalSince1970))
let payload = timestamp.data(using: .utf8)! + body
let signature = HMAC<SHA256>.authenticationCode(for: payload, using: secretKey)
request.setValue(Data(signature).base64EncodedString(), forHTTPHeaderField: "X-Signature")
request.setValue(timestamp, forHTTPHeaderField: "X-Timestamp")
}
AES-GCM Symmetric Encryption / Decryption
struct AESEncryptor {
/// Encrypt data with AES-GCM
static func encrypt(data: Data, key: SymmetricKey) throws -> Data {
let sealedBox = try AES.GCM.seal(data, using: key)
// combined = nonce + ciphertext + tag
guard let combined = sealedBox.combined else {
throw CryptoError.encryptionFailed
}
return combined
}
/// Decrypt AES-GCM sealed data
static func decrypt(data: Data, key: SymmetricKey) throws -> Data {
let sealedBox = try AES.GCM.SealedBox(combined: data)
return try AES.GCM.open(sealedBox, using: key)
}
/// Encrypt a string
static func encryptString(_ string: String, key: SymmetricKey) throws -> Data {
try encrypt(data: Data(string.utf8), key: key)
}
/// Decrypt to string
static func decryptString(data: Data, key: SymmetricKey) throws -> String {
let decrypted = try decrypt(data: data, key: key)
guard let string = String(data: decrypted, encoding: .utf8) else {
throw CryptoError.decodingFailed
}
return string
}
/// Generate a key from a password using HKDF
static func deriveKey(from password: String, salt: Data) -> SymmetricKey {
let inputKey = SymmetricKey(data: Data(password.utf8))
let derived = HKDF<SHA256>.deriveKey(
inputKeyMaterial: inputKey,
salt: salt,
info: Data("AES-GCM-Encryption".utf8),
outputByteCount: 32
)
return derived
}
}
enum CryptoError: LocalizedError {
case encryptionFailed, decodingFailed
var errorDescription: String? {
switch self {
case .encryptionFailed: "Encryption failed."
case .decodingFailed: "Failed to decode decrypted data."
}
}
}
P256 / P384 / P521 Key Agreement and Signing
// Digital signatures with P256
struct ECDSASigner {
let privateKey: P256.Signing.PrivateKey
init() {
privateKey = P256.Signing.PrivateKey()
}
var publicKey: P256.Signing.PublicKey {
privateKey.publicKey
}
func sign(data: Data) throws -> P256.Signing.ECDSASignature {
try privateKey.signature(for: data)
}
static func verify(
signature: P256.Signing.ECDSASignature,
data: Data,
publicKey: P256.Signing.PublicKey
) -> Bool {
publicKey.isValidSignature(signature, for: data)
}
}
// Key agreement (Diffie-Hellman) with P256
struct KeyAgreement {
static func sharedSecret(
privateKey: P256.KeyAgreement.PrivateKey,
publicKey: P256.KeyAgreement.PublicKey
) throws -> SymmetricKey {
let sharedSecret = try privateKey.sharedSecretFromKeyAgreement(with: publicKey)
// Derive a symmetric key using HKDF
return sharedSecret.hkdfDerivedSymmetricKey(
using: SHA256.self,
salt: Data(),
sharedInfo: Data("P256-Key-Agreement".utf8),
outputByteCount: 32
)
}
}
// Usage: Two parties derive the same shared key
let alicePrivate = P256.KeyAgreement.PrivateKey()
let bobPrivate = P256.KeyAgreement.PrivateKey()
let aliceSharedKey = try KeyAgreement.sharedSecret(
privateKey: alicePrivate, publicKey: bobPrivate.publicKey
)
let bobSharedKey = try KeyAgreement.sharedSecret(
privateKey: bobPrivate, publicKey: alicePrivate.publicKey
)
// aliceSharedKey == bobSharedKey
Curve25519 Key Exchange
struct Curve25519Exchange {
static func deriveSharedKey(
privateKey: Curve25519.KeyAgreement.PrivateKey,
peerPublicKey: Curve25519.KeyAgreement.PublicKey
) throws -> SymmetricKey {
let shared = try privateKey.sharedSecretFromKeyAgreement(with: peerPublicKey)
return shared.hkdfDerivedSymmetricKey(
using: SHA256.self,
salt: Data(),
sharedInfo: Data("Curve25519-Exchange".utf8),
outputByteCount: 32
)
}
}
// Curve25519 signing
let signingKey = Curve25519.Signing.PrivateKey()
let message = Data("Sign this message".utf8)
let signature = try signingKey.signature(for: message)
let isValid = signingKey.publicKey.isValidSignature(signature, for: message)
// Export / import keys
let publicKeyData = signingKey.publicKey.rawRepresentation // 32 bytes
let restoredPublicKey = try Curve25519.Signing.PublicKey(rawRepresentation: publicKeyData)
Secure Enclave Integration
struct SecureEnclaveManager {
/// Create a private key stored in the Secure Enclave
static func createKey() throws -> SecureEnclave.P256.Signing.PrivateKey {
guard SecureEnclave.isAvailable else {
throw SecureEnclaveError.notAvailable
}
// Key with access control
let accessControl = SecAccessControlCreateWithFlags(
nil,
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
[.privateKeyUsage, .biometryCurrentSet],
nil
)!
return try SecureEnclave.P256.Signing.PrivateKey(
accessControl: accessControl
)
}
/// Sign data with Secure Enclave key (requires biometric auth)
static func sign(data: Data, key: SecureEnclave.P256.Signing.PrivateKey) throws -> Data {
let signature = try key.signature(for: data)
return signature.derRepresentation
}
/// Persist and restore Secure Enclave keys
static func persistKey(_ key: SecureEnclave.P256.Signing.PrivateKey) throws -> Data {
key.dataRepresentation
}
static func restoreKey(from data: Data) throws -> SecureEnclave.P256.Signing.PrivateKey {
try SecureEnclave.P256.Signing.PrivateKey(dataRepresentation: data)
}
}
enum SecureEnclaveError: LocalizedError {
case notAvailable
var errorDescription: String? { "Secure Enclave is not available on this device." }
}
ChaChaPoly for Performance
ChaChaPoly (ChaCha20-Poly1305) is faster than AES-GCM on devices without AES hardware acceleration.
struct ChaChaEncryptor {
static func encrypt(data: Data, key: SymmetricKey) throws -> Data {
let sealedBox = try ChaChaPoly.seal(data, using: key)
return sealedBox.combined
}
static func decrypt(data: Data, key: SymmetricKey) throws -> Data {
let sealedBox = try ChaChaPoly.SealedBox(combined: data)
return try ChaChaPoly.open(sealedBox, using: key)
}
}
Complete Secure Messaging Example
import CryptoKit
import Foundation
/// End-to-end encrypted messaging using Curve25519 key exchange + AES-GCM
final class SecureMessenger {
let identityKey: Curve25519.KeyAgreement.PrivateKey
var peerPublicKey: Curve25519.KeyAgreement.PublicKey?
var publicKeyData: Data {
identityKey.publicKey.rawRepresentation
}
init() {
identityKey = Curve25519.KeyAgreement.PrivateKey()
}
/// Set the peer's public key (received over network)
func setPeerPublicKey(_ data: Data) throws {
peerPublicKey = try Curve25519.KeyAgreement.PublicKey(rawRepresentation: data)
}
/// Derive the shared encryption key
private func sharedKey() throws -> SymmetricKey {
guard let peerPublicKey else { throw MessengerError.noPeerKey }
let shared = try identityKey.sharedSecretFromKeyAgreement(with: peerPublicKey)
return shared.hkdfDerivedSymmetricKey(
using: SHA256.self,
salt: Data("SecureMessenger-v1".utf8),
sharedInfo: Data(),
outputByteCount: 32
)
}
/// Encrypt a message
func encrypt(_ plaintext: String) throws -> Data {
let key = try sharedKey()
let data = Data(plaintext.utf8)
let sealed = try AES.GCM.seal(data, using: key)
guard let combined = sealed.combined else {
throw MessengerError.encryptionFailed
}
return combined
}
/// Decrypt a message
func decrypt(_ ciphertext: Data) throws -> String {
let key = try sharedKey()
let box = try AES.GCM.SealedBox(combined: ciphertext)
let decrypted = try AES.GCM.open(box, using: key)
guard let message = String(data: decrypted, encoding: .utf8) else {
throw MessengerError.decodingFailed
}
return message
}
/// Sign a message for authenticity
func sign(_ data: Data) throws -> Data {
let signingKey = Curve25519.Signing.PrivateKey()
let signature = try signingKey.signature(for: data)
return signature
}
}
enum MessengerError: LocalizedError {
case noPeerKey, encryptionFailed, decodingFailed
var errorDescription: String? {
switch self {
case .noPeerKey: "Peer public key not set."
case .encryptionFailed: "Message encryption failed."
case .decodingFailed: "Failed to decode decrypted message."
}
}
}
// Usage
let alice = SecureMessenger()
let bob = SecureMessenger()
try alice.setPeerPublicKey(bob.publicKeyData)
try bob.setPeerPublicKey(alice.publicKeyData)
let encrypted = try alice.encrypt("Hello, Bob!")
let decrypted = try bob.decrypt(encrypted) // "Hello, Bob!"
---
# SwiftData and Core Data Concurrency
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-data-concurrency.html
← Back to all frameworks & guides
All articles →Data Management · Reference guideSwiftData and Core Data ConcurrencyRepository guidance for Data Concurrency. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
The One Rule
SwiftData
The main-actor context
@ModelActor for background work
Re-fetching by identifier
Getting changes back to the UI
Autosave
SwiftData anti-patterns
Core Data
Context topology
Background work
Crossing the boundary with NSManagedObjectID
Batch operations skip the object graph entirely
Core Data anti-patterns
Choosing a Strategy
Testing
Checklist
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Identify data ownership
↓2
Isolate mutable state
↓3
Cross boundaries with safe values
↓4
Test concurrent operations
02 / ArchitectureResponsibility boundariesBoundary 1
UI actorBoundary 2
Data service boundaryBoundary 3
Persistent storageConnected responsibilities, not a required class hierarchy or an execution trace.
Load this when:
importing or syncing data in the background, seeing
"context is not thread safe" or
Sendable
errors around a model type, writing
more than a few dozen objects at once, or a list stutters during a save.
docs/frameworks/swiftdata.md
and
docs/frameworks/core-data.md
cover the APIs.
This document covers the single rule that both frameworks enforce and the
patterns that follow from it.
The One Rule
A managed object belongs to the context that fetched it, and that context
belongs to one actor. Never pass the object across a boundary — pass its ID.
Framework
Object
Context
Cross-boundary token
SwiftData
@Model class
ModelContext
PersistentIdentifier
Core Data
NSManagedObject
NSManagedObjectContext
NSManagedObjectID
@Model
classes are
not
Sendable
, and neither is
ModelContext
. Any code
that appears to move one between actors is either failing to compile under Swift 6
or silently corrupting the store under Swift 5.
// WRONG — hands a model object to another actor.
let trip = try context.fetch(descriptor).first!
await backgroundWorker.process(trip) // Sendable error / crash
// RIGHT — hand over the identifier and re-fetch on the far side.
let id = trip.persistentModelID
await backgroundWorker.process(id)
SwiftData
The main-actor context
@Environment(\.modelContext)
is main-actor-bound and backed by the container's
mainContext
. Use it for everything the user directly touches: a tap that
toggles a flag, a form that inserts one record, a
@Query
-driven list.
struct TripList: View {
@Query(sort: \Trip.startDate) private var trips: [Trip]
@Environment(\.modelContext) private var context
var body: some View {
List(trips) { trip in
TripRow(trip: trip)
.swipeActions {
Button(role: .destructive) {
context.delete(trip) // main actor, immediate, correct
} label: { Label("Delete", systemImage: "trash") }
}
}
}
}
Do not move this work off the main actor. A single insert is microseconds; the
actor hop costs more than the save.
@ModelActor
for background work
Import a thousand records, run a migration, reconcile a sync — that belongs on
its own actor with its own context.
@ModelActor
actor DataImporter {
/// The macro synthesizes `init(modelContainer:)` plus an isolated
/// `modelContext` created for THIS actor. Never construct a context yourself.
func importTrips(from payloads: [TripPayload]) throws {
for (index, payload) in payloads.enumerated() {
modelContext.insert(Trip(payload: payload))
// Batch saves. Saving per-record is orders of magnitude slower;
// never saving at all balloons memory until the loop ends.
if index % 500 == 499 {
try modelContext.save()
}
}
try modelContext.save()
}
/// Returns IDs, not objects. The caller re-fetches on its own context.
func staleTripIDs(before date: Date) throws -> [PersistentIdentifier] {
let descriptor = FetchDescriptor<Trip>(
predicate: #Predicate { $0.updatedAt < date }
)
return try modelContext.fetch(descriptor).map(\.persistentModelID)
}
}
// Calling it — the container is Sendable, so it crosses freely.
@MainActor
@Observable
final class TripListModel {
private let container: ModelContainer
private(set) var isImporting = false
init(container: ModelContainer) { self.container = container }
func runImport(_ payloads: [TripPayload]) async {
isImporting = true
defer { isImporting = false }
let importer = DataImporter(modelContainer: container)
do {
try await importer.importTrips(from: payloads)
// @Query on the main context picks up the change automatically.
} catch {
// surface it
}
}
}
Re-fetching by identifier
@ModelActor
actor TripEditor {
func markComplete(_ id: PersistentIdentifier) throws {
// `model(for:)` resolves an identifier against THIS actor's context.
guard let trip = modelContext.model(for: id) as? Trip else { return }
trip.isComplete = true
try modelContext.save()
}
}
PersistentIdentifier
is
Sendable
and stable, which is exactly why it — and
not the object — is what crosses.
Getting changes back to the UI
Two mechanisms, in order of preference:
@Query.
A background save on a context from the same container
propagates automatically. Nothing to write.
Explicit re-fetch.
When you are not using
@Query
, re-fetch on the main
context after the background actor finishes.
func refresh() {
trips = (try? context.fetch(FetchDescriptor<Trip>(sort: [.init(\.startDate)]))) ?? []
}
Do
not
try to observe a background context's objects from the UI. They belong
to the other actor.
Autosave
ModelContainer
autosaves by default on the main context. For a background
importer, turn it off so your batching is the only thing writing:
let container = try ModelContainer(
for: Trip.self,
configurations: ModelConfiguration(isStoredInMemoryOnly: false)
)
container.mainContext.autosaveEnabled = true // default; fine for UI edits
SwiftData anti-patterns
// 1. Constructing a context by hand for background work.
let context = ModelContext(container) // not actor-isolated — unsafe
Task.detached { context.insert(…) }
// Use @ModelActor.
// 2. Returning model objects from an actor.
func fetchTrips() -> [Trip] { … } // Sendable violation
func fetchTripIDs() -> [PersistentIdentifier] { … } // correct
// 3. Saving inside a tight loop.
for payload in payloads {
context.insert(Trip(payload: payload))
try context.save() // one transaction per record
}
// 4. Never saving.
for payload in tenThousandPayloads { context.insert(…) }
try context.save() // peak memory holds all 10k
// 5. Doing a large import on the main actor.
@MainActor func importAll() { … } // freezes the UI
// 6. Storing a @Model object on an @Observable view model as source of truth.
@Observable final class VM { var trip: Trip } // lifetime tied to a context
// Store the ID, or a Sendable value-type snapshot, and re-fetch.
Core Data
Context topology
NSPersistentContainer
├── viewContext (main queue) — UI reads, @FetchRequest
└── newBackgroundContext (private queue) — imports, batch work
final class PersistenceController {
static let shared = PersistenceController()
let container: NSPersistentContainer
init(inMemory: Bool = false) {
container = NSPersistentContainer(name: "Model")
if inMemory {
container.persistentStoreDescriptions.first?.url = URL(fileURLWithPath: "/dev/null")
}
container.loadPersistentStores { _, error in
if let error { fatalError("Store failed to load: \(error)") }
}
// Background saves merge into the UI context automatically.
container.viewContext.automaticallyMergesChangesFromParent = true
// Last write wins on a property-by-property basis. Choose deliberately:
// this is correct for server-authoritative sync, wrong for local edits
// the user expects to keep.
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
container.viewContext.undoManager = nil // perf: UI contexts rarely need it
}
}
Background work
extension PersistenceController {
/// `performBackgroundTask` gives you a context on its own private queue.
/// The closure body is the ONLY place that context may be touched.
func importTrips(_ payloads: [TripPayload]) async throws {
try await container.performBackgroundTask { context in
context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
for (index, payload) in payloads.enumerated() {
let trip = Trip(context: context)
trip.id = payload.id
trip.name = payload.name
if index % 500 == 499 {
try context.save()
context.reset() // release the object graph
}
}
if context.hasChanges { try context.save() }
}
}
}
context.reset()
after each batch is what keeps memory flat on a large import.
Without it the context retains every object it has ever materialised.
Crossing the boundary with
NSManagedObjectID
// Background -> main
let objectIDs: [NSManagedObjectID] = try await container.performBackgroundTask { context in
try context.fetch(request).map(\.objectID)
}
await MainActor.run {
let viewContext = container.viewContext
let trips = objectIDs.compactMap { viewContext.object(with: $0) as? Trip }
// safe: these belong to viewContext
}
Note
object(with:)
returns a fault and will throw if the row is gone; use
existingObject(with:)
when the object may have been deleted.
Batch operations skip the object graph entirely
For deletes and updates of many rows,
NSBatchDeleteRequest
/
NSBatchUpdateRequest
execute in SQL and never materialise objects — orders of
magnitude faster. The cost is that in-memory contexts do not know, so you must
merge the changes yourself.
func deleteTrips(olderThan date: Date) async throws {
try await container.performBackgroundTask { context in
let fetch = NSFetchRequest<NSFetchRequestResult>(entityName: "Trip")
fetch.predicate = NSPredicate(format: "updatedAt < %@", date as NSDate)
let request = NSBatchDeleteRequest(fetchRequest: fetch)
request.resultType = .resultTypeObjectIDs
let result = try context.execute(request) as? NSBatchDeleteResult
guard let ids = result?.result as? [NSManagedObjectID] else { return }
// Without this the UI keeps showing deleted rows until relaunch.
NSManagedObjectContext.mergeChanges(
fromRemoteContextSave: [NSDeletedObjectsKey: ids],
into: [self.container.viewContext]
)
}
}
Core Data anti-patterns
// 1. Touching a context outside its queue.
let context = container.newBackgroundContext()
context.insert(obj) // wrong queue
context.perform { context.insert(obj) } // correct
// 2. Passing NSManagedObject between queues.
DispatchQueue.main.async { label.text = trip.name } // trip is not thread-safe
// Read the value inside perform, pass the String.
// 3. Saving viewContext for a large import — blocks the UI.
// 4. Deleting thousands of rows one at a time instead of NSBatchDeleteRequest.
// 5. Forgetting to merge batch-operation results into viewContext.
// 6. automaticallyMergesChangesFromParent left false, then wondering why the
// UI does not update after a background save.
Choosing a Strategy
Situation
SwiftData
Core Data
One-off user edit
@Environment(\.modelContext)
viewContext
List display
@Query
@FetchRequest / NSFetchedResultsController
Import of 100+ records
@ModelActor
performBackgroundTask
Delete many rows
fetch IDs, delete on a @ModelActor
NSBatchDeleteRequest + merge
Cross-boundary reference
PersistentIdentifier
NSManagedObjectID
Conflict resolution
ModelConfiguration
mergePolicy
Testing
Use an in-memory store so tests are isolated and fast.
// SwiftData
@MainActor
@Suite("TripStore")
struct TripStoreTests {
private func makeContainer() throws -> ModelContainer {
try ModelContainer(
for: Trip.self,
configurations: ModelConfiguration(isStoredInMemoryOnly: true)
)
}
@Test("import writes every record")
func importAll() async throws {
let container = try makeContainer()
let importer = DataImporter(modelContainer: container)
try await importer.importTrips(from: TripPayload.samples)
let count = try container.mainContext.fetchCount(FetchDescriptor<Trip>())
#expect(count == TripPayload.samples.count)
}
}
// Core Data
let controller = PersistenceController(inMemory: true)
Previews get the same treatment — see the SwiftData preview container in
docs/design/interaction-standards.md
.
Checklist
[ ] No
@Model
object or
NSManagedObject
crosses an actor or queue boundary.
[ ] Background work uses
@ModelActor
(SwiftData) or
performBackgroundTask
(Core Data) — never a hand-rolled context on a detached task.
[ ] Bulk writes save in batches (~500) rather than per-record or once at the end.
[ ] Core Data:
automaticallyMergesChangesFromParent = true
on
viewContext
.
[ ] Core Data: a deliberate
mergePolicy
, not the default error-on-conflict.
[ ] Batch delete/update results are merged into
viewContext
.
[ ] Actors return identifiers, never model objects.
[ ] Tests and previews use an in-memory store.
---
# Device Integrity (DeviceCheck & App Attest)
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-device-integrity.html
← Back to all frameworks & guides
All articles →Authentication, Security, and Privacy · Reference guideDevice Integrity (DeviceCheck & App Attest)Repository guidance for Device Integrity. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
DeviceCheck — Per-Device Bits
DCAppAttestService — Full Attestation Flow
Step 1: Generate a Key
Step 2: Attest the Key with Apple
Step 3: Generate Assertions for API Calls
Server-Side Verification (Reference)
Fraud Prevention Patterns
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Request server challenge
↓2
Generate device attestation
↓3
Validate proof on server
↓4
Apply a server-side policy
02 / ArchitectureResponsibility boundariesBoundary 1
Client attestationBoundary 2
Challenge verificationBoundary 3
Server policyConnected responsibilities, not a required class hierarchy or an execution trace.
DeviceCheck — Per-Device Bits
DeviceCheck lets you store
two bits
per device on Apple's servers, persisting across app reinstalls.
import DeviceCheck
final class DeviceCheckManager {
private let device = DCDevice.current
/// Check if DeviceCheck is supported
var isSupported: Bool {
device.isSupported
}
/// Generate a device token to send to your server
func generateToken() async throws -> Data {
guard isSupported else {
throw IntegrityError.deviceCheckNotSupported
}
return try await device.generateToken()
}
/// Server-side: Query Apple for the two bits
/// POST https://api.development.devicecheck.apple.com/v1/query_two_bits
///
/// Request body:
/// {
/// "device_token": "<base64 token>",
/// "transaction_id": "<uuid>",
/// "timestamp": <milliseconds since epoch>
/// }
///
/// Response:
/// { "bit0": true, "bit1": false, "last_update_time": "2025-01" }
/// Server-side: Update the two bits
/// POST https://api.development.devicecheck.apple.com/v1/update_two_bits
///
/// Request body:
/// {
/// "device_token": "<base64 token>",
/// "transaction_id": "<uuid>",
/// "timestamp": <milliseconds since epoch>,
/// "bit0": true,
/// "bit1": false
/// }
/// Example: Mark device as having redeemed a promo
func markPromoRedeemed() async throws {
let token = try await generateToken()
let payload: [String: Any] = [
"device_token": token.base64EncodedString(),
"transaction_id": UUID().uuidString,
"timestamp": Int(Date().timeIntervalSince1970 * 1000),
"bit0": true, // bit0 = promo redeemed
"bit1": false
]
var request = URLRequest(url: URL(string: "https://api.yourserver.com/devicecheck/update")!)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONSerialization.data(withJSONObject: payload)
let (_, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
throw IntegrityError.serverError
}
}
}
DCAppAttestService — Full Attestation Flow
App Attest cryptographically proves that API requests come from a genuine, unmodified copy of your app.
Step 1: Generate a Key
import DeviceCheck
final class AppAttestManager {
private let attest = DCAppAttestService.shared
/// Check if App Attest is supported
var isSupported: Bool {
attest.isSupported
}
/// Step 1: Generate an attestation key pair (stored in Secure Enclave)
func generateKey() async throws -> String {
guard isSupported else {
throw IntegrityError.appAttestNotSupported
}
let keyId = try await attest.generateKey()
// Store keyId in Keychain for later use
try KeychainHelper.save(keyId, forKey: "appAttestKeyId")
return keyId
}
}
Step 2: Attest the Key with Apple
extension AppAttestManager {
/// Step 2: Attest the key — do this ONCE per key
/// Send the attestation object to your server for verification
func attestKey(_ keyId: String) async throws -> Data {
// 1. Get a one-time challenge from your server
let challenge = try await fetchChallenge()
// 2. Create a hash of the challenge
let challengeHash = Data(SHA256.hash(data: challenge))
// 3. Request attestation from Apple
let attestation = try await attest.attestKey(keyId, clientDataHash: challengeHash)
// 4. Send attestation + challenge to your server for verification
try await verifyAttestationOnServer(
keyId: keyId,
attestation: attestation,
challenge: challenge
)
return attestation
}
private func fetchChallenge() async throws -> Data {
let url = URL(string: "https://api.yourserver.com/attest/challenge")!
let (data, _) = try await URLSession.shared.data(from: url)
return data
}
private func verifyAttestationOnServer(
keyId: String,
attestation: Data,
challenge: Data
) async throws {
var request = URLRequest(url: URL(string: "https://api.yourserver.com/attest/verify")!)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let body: [String: String] = [
"keyId": keyId,
"attestation": attestation.base64EncodedString(),
"challenge": challenge.base64EncodedString()
]
request.httpBody = try JSONEncoder().encode(body)
let (_, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
throw IntegrityError.attestationFailed
}
}
}
Step 3: Generate Assertions for API Calls
import CryptoKit
extension AppAttestManager {
/// Step 3: Generate an assertion for each sensitive API request
func generateAssertion(for requestData: Data) async throws -> Data {
guard let keyId = try KeychainHelper.load(forKey: "appAttestKeyId") else {
throw IntegrityError.noKeyFound
}
// Hash the request data (the payload you want to protect)
let clientDataHash = Data(SHA256.hash(data: requestData))
// Generate assertion
let assertion = try await attest.generateAssertion(keyId, clientDataHash: clientDataHash)
return assertion
}
/// Make an attested API request
func makeAttestedRequest(
url: URL,
method: String = "POST",
body: Data
) async throws -> (Data, HTTPURLResponse) {
let assertion = try await generateAssertion(for: body)
var request = URLRequest(url: url)
request.httpMethod = method
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue(assertion.base64EncodedString(), forHTTPHeaderField: "X-App-Assertion")
request.httpBody = body
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw IntegrityError.serverError
}
return (data, http)
}
}
Server-Side Verification (Reference)
Your server must verify attestations and assertions with Apple.
/// Server-side pseudocode (Node.js / Python / Swift on server):
///
/// Attestation verification:
/// 1. Decode the CBOR attestation object
/// 2. Verify the x5c certificate chain leads to Apple's App Attest root CA
/// 3. Extract the public key from the leaf certificate
/// 4. Verify nonce = SHA256(SHA256(challenge) + attestation.authData)
/// 5. Verify the App ID (team ID + bundle ID) in the credential certificate
/// 6. Store the public key and counter for the keyId
///
/// Assertion verification:
/// 1. Decode the CBOR assertion
/// 2. Compute authenticatorData + SHA256(clientData)
/// 3. Verify the signature using the stored public key for this keyId
/// 4. Verify the counter is greater than the stored counter (replay protection)
/// 5. Update the stored counter
///
/// Apple App Attest root certificate:
/// https://www.apple.com/certificateauthority/Apple_App_Attestation_Root_CA.pem
Fraud Prevention Patterns
/// Comprehensive integrity guard combining DeviceCheck + App Attest
final class IntegrityGuard {
private let appAttest = AppAttestManager()
private let deviceCheck = DeviceCheckManager()
private var isAttested = false
/// Call during app launch or first sensitive action
func initialize() async {
// 1. Generate and attest key (one-time setup)
guard appAttest.isSupported else {
// Fallback: use DeviceCheck token for basic verification
return
}
do {
let existingKeyId = try? KeychainHelper.load(forKey: "appAttestKeyId")
if existingKeyId == nil {
let keyId = try await appAttest.generateKey()
try await appAttest.attestKey(keyId)
}
isAttested = true
} catch {
// Handle attestation failure — device may be compromised
// Log and potentially restrict features
}
}
/// Protect a sensitive API call
func protectedRequest(
url: URL,
body: Encodable
) async throws -> Data {
let bodyData = try JSONEncoder().encode(body)
if isAttested {
let (data, response) = try await appAttest.makeAttestedRequest(
url: url,
body: bodyData
)
guard response.statusCode == 200 else {
throw IntegrityError.serverError
}
return data
} else {
// Fallback: use DeviceCheck token
let token = try await deviceCheck.generateToken()
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue(token.base64EncodedString(), forHTTPHeaderField: "X-Device-Token")
request.httpBody = bodyData
let (data, _) = try await URLSession.shared.data(for: request)
return data
}
}
}
/// Keychain helper for storing the attest key ID
enum KeychainHelper {
static func save(_ value: String, forKey key: String) throws {
let data = Data(value.utf8)
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
]
SecItemDelete(query as CFDictionary)
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else {
throw IntegrityError.keychainError
}
}
static func load(forKey key: String) throws -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess, let data = result as? Data else {
return nil
}
return String(data: data, encoding: .utf8)
}
}
enum IntegrityError: LocalizedError {
case deviceCheckNotSupported
case appAttestNotSupported
case attestationFailed
case noKeyFound
case serverError
case keychainError
var errorDescription: String? {
switch self {
case .deviceCheckNotSupported: "DeviceCheck is not supported on this device."
case .appAttestNotSupported: "App Attest is not supported on this device."
case .attestationFailed: "Key attestation failed."
case .noKeyFound: "No attestation key found. Re-enrollment required."
case .serverError: "Server verification failed."
case .keychainError: "Keychain operation failed."
}
}
}
---
# Extended Apple Frameworks
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-extended-apple-frameworks.html
← Back to all frameworks & guides
All articles →Core UI and Apps · Reference guideExtended Apple FrameworksRepository guidance for QuickLook, LinkPresentation, UniformTypeIdentifiers, PDFKit, PencilKit, Core Animation, Core Haptics, MLX, File Provider, SQLite, Multipeer Connectivity, Nearby Interaction, Network Extension, SpriteKit, GameplayKit, Game Controller, RoomPlan, Object Capture, AVKit, Core Media, ReplayKit, MusicKit, ShazamKit, App Store Server API, Wallet Orders, Security Framework, AccessorySetupKit, ExternalAccessory, SensorKit. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
Context
Core UI and document workflows
QuickLook
LinkPresentation
UniformTypeIdentifiers
PDFKit
PencilKit
Design, motion, and feedback
Core Animation
Core Haptics
AI and signal processing
MLX
Sound Analysis
Data and file providers
File Provider
SQLite
Networking and connectivity
Multipeer Connectivity
Nearby Interaction
Network Extension
Games, spatial capture, and controllers
SpriteKit
GameplayKit
Game Controller
RoomPlan
Object Capture
Camera, media, and audio
AVKit
Core Media
ReplayKit
MusicKit
ShazamKit
Commerce and wallet server flows
App Store Server API
Wallet Orders
Security and hardware access
Security Framework
AccessorySetupKit
ExternalAccessory
SensorKit
Reporting standard
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Identify the platform task
↓2
Choose a focused framework
↓3
Check permissions and support
↓4
Verify representative inputs
02 / ArchitectureResponsibility boundariesBoundary 1
App requirementBoundary 2
Framework adapterBoundary 3
Platform capabilityConnected responsibilities, not a required class hierarchy or an execution trace.
Context
Load this guide when a requested Apple technology is tracked in
frameworks.json
but does not have a dedicated deep-dive file. It covers the remaining framework routes needed for 100% coverage of this repository's Apple technology catalog.
This is not a replacement for Apple documentation. Treat the Apple Developer link in
frameworks.json
as canonical, then use this file for agent routing, first implementation choices, verification, and common failure modes.
Core UI and document workflows
QuickLook
Use Quick Look when the app needs system previews for documents, images, PDFs, audio, video, Live Photos, Office/iWork files, text files, or USDZ objects. Prefer
QLPreviewController
for user-facing previews. For custom file types, provide a Quick Look preview extension and declare supported UTTypes in
QLSupportedContentTypes
.
Avoid Quick Look when the app needs advanced playback controls, heavily customized document rendering, or overlays inside the preview. Use AVFoundation, PDFKit, or a custom renderer instead.
Verification: open representative file types on device and simulator, verify security-scoped resource access for document-picker URLs, and test missing/unsupported file behavior.
LinkPresentation
Use LinkPresentation to render rich URL previews with title, icon, image, and metadata. Cache
LPLinkMetadata
results so lists do not repeatedly fetch metadata while scrolling.
Rules: never block initial UI on metadata fetching, handle URLs with no preview data, and avoid treating metadata as trusted content.
Verification: test slow networks, private URLs, malformed URLs, and reused cells in scrolling views.
UniformTypeIdentifiers
Use Uniform Type Identifiers for file import/export, document pickers, share sheets, drag and drop, pasteboard, and Quick Look extension declarations. Prefer
UTType
over stringly typed MIME or extension checks.
Rules: check conformance with
conforms(to:)
, define exported/imported types in app metadata when the app owns a custom file format, and keep accepted types narrow.
Verification: test files with uppercase extensions, missing extensions, ambiguous MIME types, iCloud Drive, Files app, AirDrop, and share-sheet imports.
PDFKit
Use PDFKit for native PDF display, navigation, search, annotations, selections, and document metadata. Use SwiftUI wrappers when a SwiftUI app needs
PDFView
.
Rules: do not load very large PDFs on the main actor if parsing or search can be deferred, avoid assuming every PDF has selectable text, and test password-protected or malformed PDFs.
Verification: test zoom, rotation, memory pressure, search, annotation persistence, dark-mode container contrast, and VoiceOver navigation.
PencilKit
Use PencilKit when the feature needs Apple Pencil drawing, markup, sketches, handwriting surfaces, or low-latency ink. Store
PKDrawing
data, not screenshots, when strokes need to remain editable.
Rules: configure tool picker ownership per scene, preserve scale when compositing drawings over images or PDFs, and design finger-vs-pencil behavior intentionally.
Verification: test Apple Pencil, finger drawing, undo/redo, iPad multitasking, orientation changes, and drawing persistence.
Design, motion, and feedback
Core Animation
Use Core Animation for layer-backed animation, custom transitions, masks, shape layers, gradients, replicators, and performance-sensitive visual effects below SwiftUI or UIKit.
Rules: prefer transform and opacity animation over layout animation, keep layer tree changes on the main thread, avoid offscreen-rendering surprises, and set final model-layer values to match animations.
Verification: profile with Core Animation and Instruments, inspect dropped frames, and test Reduce Motion alternatives.
Core Haptics
Use Core Haptics for custom tactile feedback patterns, synchronized audio-haptic experiences, and game-like interactions. Use UIKit feedback generators for simple selection, impact, and notification feedback.
Rules: check hardware capability, handle audio-session interruptions, start/stop engines around app lifecycle events, and provide a non-haptic fallback.
Verification: test on physical devices because simulators cannot prove haptic quality.
AI and signal processing
MLX
Use MLX for Apple-silicon-focused machine-learning experimentation and local model workflows when the project explicitly targets MLX. For app-integrated Apple-platform ML, prefer Foundation Models, Core ML, Vision, Natural Language, Speech, or Accelerate unless MLX is a stated requirement.
Rules: isolate MLX experiments from App Store app code until deployment constraints are clear, document hardware assumptions, and keep model artifacts out of source control unless intentionally small.
Verification: record device/Mac chip, memory footprint, model size, latency, and fallback path.
Sound Analysis
Use Sound Analysis for classifying audio, detecting sound events, or running Core ML models over audio streams. Route speech transcription to Speech, music recognition to ShazamKit, and low-level capture/playback to AVFoundation.
Rules: request microphone permission only when live audio is required, process streaming audio off the main actor, and design privacy copy that explains what audio is analyzed.
Verification: test microphone permission states, background/foreground transitions, noisy rooms, silence, and model confidence thresholds.
Data and file providers
File Provider
Use File Provider when the app exposes remote or app-managed documents through the system Files experience. It is an extension architecture, not a generic file picker.
Rules: model stable item identifiers, sync metadata separately from file contents, handle eviction/materialization, and treat extension memory/time limits as hard constraints.
Verification: test Files app browsing, open-in-place, offline behavior, conflicts, deletion, rename, and large-file download cancellation.
SQLite
Use SQLite when the app needs relational storage, portable database files, full control over queries, or compatibility with an existing SQLite schema. Prefer SwiftData/Core Data for object graph persistence when their model fits.
Rules: put database access behind an actor or serial queue, use prepared statements, run migrations transactionally, and avoid doing database I/O on the main actor.
Verification: test migrations from real older schemas, corruption recovery, concurrent access, large imports, and backup/restore behavior.
Networking and connectivity
Multipeer Connectivity
Use Multipeer Connectivity for nearby peer-to-peer discovery and data exchange over infrastructure Wi-Fi, peer-to-peer Wi-Fi, and Bluetooth-backed discovery. It fits local collaboration, nearby sharing, and offline peer sessions.
Rules: design explicit trust and invitation UX, handle peers appearing/disappearing, bound payload size, and keep session state observable from the main actor.
Verification: test multiple physical devices, locked screens, app backgrounding, network changes, and duplicate peer names.
Nearby Interaction
Use Nearby Interaction for spatially aware nearby-device experiences with supported hardware and compatible peer discovery. Pair it with Multipeer Connectivity or another channel for token exchange.
Rules: guard hardware support, request permission with clear value, and provide a non-UWB fallback.
Verification: test supported and unsupported devices, permission denial, interrupted sessions, distance/orientation accuracy, and multi-user environments.
Network Extension
Use Network Extension for VPN, content filtering, DNS proxying, packet tunnels, app proxying, and related managed-network capabilities. This area often requires entitlements and App Review justification.
Rules: verify entitlement availability before designing the feature, isolate extension code, keep privacy claims exact, and avoid using Network Extension for ordinary HTTP requests.
Verification: test entitlement provisioning, on-device behavior, managed configuration, reconnects, sleep/wake, and App Store policy fit.
Games, spatial capture, and controllers
SpriteKit
Use SpriteKit for 2D games, particle effects, tile maps, sprite animation, physics-driven 2D scenes, and lightweight interactive visual experiences.
Rules: keep game state deterministic, separate rendering from game rules, load atlases intentionally, and avoid mixing SwiftUI layout assumptions into the SpriteKit scene.
Verification: test frame pacing, texture memory, touch/controller input, pause/resume, scene transitions, and different refresh rates.
GameplayKit
Use GameplayKit for game architecture helpers: state machines, entity-component design, pathfinding, randomization, agents, goals, and rule systems.
Rules: keep the simulation model independent from SpriteKit/SceneKit/RealityKit rendering, seed randomness for reproducible tests, and keep state transitions explicit.
Verification: unit-test state machines, pathfinding edge cases, deterministic random sequences, and save/restore behavior.
Game Controller
Use Game Controller for physical controllers, keyboard/mouse game input, controller discovery, button/axis state, haptics, and player indexing.
Rules: support remapping where appropriate, provide touch fallback on iPhone/iPad, handle connect/disconnect live, and avoid assuming a specific controller layout.
Verification: test Xbox/PlayStation/MFi controllers, keyboard input, tvOS focus, disconnects, and multiple players.
RoomPlan
Use RoomPlan for LiDAR-backed room scanning and structured room-capture output. Route general AR placement to ARKit/RealityKit.
Rules: guard device support, explain scanning privacy, handle incomplete scans, and give users editing/review affordances before export.
Verification: test small/large rooms, poor lighting, reflective surfaces, interrupted scans, export, and memory use.
Object Capture
Use Object Capture for photogrammetry workflows that create 3D assets from image sets. Treat it as a capture pipeline with strict input quality needs, not a one-click magic feature.
Rules: guide users through coverage, lighting, focus, and overlap; validate image sets before processing; and plan for long-running work and large outputs.
Verification: test small and reflective objects, incomplete image sets, processing failure, output scale/orientation, and USDZ import into RealityKit.
Camera, media, and audio
AVKit
Use AVKit for system playback UI, Picture in Picture, player controls, and platform-native media presentation. Use AVFoundation when custom capture, editing, composition, or low-level playback control is required.
Rules: manage
AVPlayer
lifetime, observe playback state safely, support interruptions, and avoid rebuilding players on every SwiftUI body update.
Verification: test streaming, local files, AirPlay, PiP, background audio policy, captions, interruption, and poor networks.
Core Media
Use Core Media for timestamps, sample buffers, format descriptions, time ranges, and low-level media pipelines. It usually appears with AVFoundation, VideoToolbox, Core Video, or Metal.
Rules: preserve timebase correctness, avoid copying sample buffers unnecessarily, and keep buffer lifetimes explicit.
Verification: test frame timing, audio/video sync, dropped frames, format changes, and memory pressure.
ReplayKit
Use ReplayKit for screen recording, broadcast upload extensions, and user-controlled capture of app or game sessions.
Rules: make recording consent obvious, handle unavailable recording states, protect sensitive screens, and separate broadcast extension constraints from main-app assumptions.
Verification: test start/stop, microphone on/off, interruptions, broadcast extension memory, and App Review privacy expectations.
MusicKit
Use MusicKit for Apple Music catalog access, playback integration, user library access, and music subscription-aware experiences.
Rules: request music authorization only when needed, handle subscription and region availability, and avoid assuming catalog identifiers are playable for every user.
Verification: test authorized/denied states, no subscription, different regions, offline playback expectations, and storefront changes.
ShazamKit
Use ShazamKit for audio matching against Shazam's catalog or a custom signature catalog. Route generic audio classification to Sound Analysis.
Rules: explain microphone use, handle noisy environments, and keep matching UI resilient to no-match outcomes.
Verification: test live microphone matching, file matching, low volume, background noise, and custom catalog updates.
Commerce and wallet server flows
App Store Server API
Use App Store Server API for server-side transaction lookup, subscription status, transaction history, refund/consumption workflows, and App Store Server Notifications integration. Keep StoreKit 2 in the app for on-device purchase flow and transaction listening.
Rules: never put App Store Server API private keys in the app, validate signed data on the server, and model idempotency for notifications.
Verification: test sandbox, production environment separation, notification retries, revoked/refunded transactions, subscription grace periods, and key rotation.
Wallet Orders
Use Wallet Orders for order tracking experiences in Apple Wallet when the product and merchant flow fit Apple's Wallet order model.
Rules: keep order state accurate, privacy-preserving, and synchronized with backend status; avoid using Wallet Orders as a generic notification system.
Verification: test order updates, cancellation/refund states, backend signing, user removal from Wallet, and localization.
Security and hardware access
Security Framework
Use the Security framework for Keychain, certificates, identities, trust evaluation, secure transport-adjacent trust objects, and lower-level security services. Prefer CryptoKit for modern cryptographic operations when it fits.
Rules: store secrets in Keychain, use access control intentionally, avoid custom crypto, and keep certificate-pinning rotation plans realistic.
Verification: test device lock state, biometric changes, keychain migration, iCloud Keychain expectations, certificate expiry, and failure paths.
AccessorySetupKit
Use AccessorySetupKit for guided setup and authorization of compatible accessories. Route ongoing Bluetooth communication to Core Bluetooth or ExternalAccessory as appropriate.
Rules: design setup around user consent, accessory discovery limits, and clear recovery from failed pairing.
Verification: test first setup, re-setup, permission denial, nearby multiple accessories, and accessory firmware differences.
ExternalAccessory
Use ExternalAccessory for Made for iPhone/iPad accessory communication using supported protocols. Do not use it for generic BLE; use Core Bluetooth for that.
Rules: verify protocol strings, entitlement needs, connection lifecycle, and background behavior before implementation.
Verification: test physical accessories, cable/Bluetooth transport, disconnects, background/foreground transitions, and unsupported firmware.
SensorKit
Use SensorKit only when the app has the required entitlement and a legitimate sensor-research or approved data-use case. It is not a general sensor API.
Rules: verify entitlement availability first, keep consent and privacy language precise, minimize retention, and design data export/deletion.
Verification: test authorization states, entitlement provisioning, data availability, privacy disclosures, and fallback behavior when unavailable.
Reporting standard
For any technology in this guide, report with:
Status: VERIFIED / INSPECTED / UNVERIFIED
Apple source: framework URL from frameworks.json
Local guide: docs/frameworks/extended-apple-frameworks.md
Implementation route: framework choice and fallback
Evidence: build, device/simulator run, entitlement check, Instruments, logs, or static review
Remaining risk: unavailable hardware, entitlement, account, device, or OS coverage
---
# Foundation Models
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-foundation-models.html
← Back to all frameworks & guides
All articles →AI and Machine Learning · Reference guideFoundation ModelsRepository guidance for Foundation Models. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
1. Basic session
Check availability before you show UI
2. Structured output with @Generable
Streaming partial results
3. Tool calling
Built-in system tools (iOS 27+)
Controlling when tools are used
4. Multimodal prompts (iOS 27+)
5. Model selection
Bringing your own model (iOS 27+)
6. Dynamic Profiles (iOS 27+)
Two orchestration patterns
7. Context, tokens, and cost
8. Concurrency
9. Error handling
10. Availability
11. Testing and evaluation
Anti-Patterns
Checklist
Released routing APIs and a compiled starting point
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Check model readiness
↓2
Create a scoped session
↓3
Request structured output or tools
↓4
Validate and handle fallback
02 / ArchitectureResponsibility boundariesBoundary 1
App requestBoundary 2
Model sessionBoundary 3
Tools and typed outputConnected responsibilities, not a required class hierarchy or an execution trace.
Load this when:
adding on-device or Private Cloud Compute language-model
features, generating structured Swift data from a prompt, building tool-calling
or agentic behavior, or integrating a third-party LLM through Apple's model
abstraction.
Foundation Models gives you a Swift API over Apple's language models — on-device
via
SystemLanguageModel
, server-side via
PrivateCloudComputeLanguageModel
,
and any other model through the open
LanguageModel
protocol.
docs/frameworks/ml/on-device-ai.md
covers the wider on-device AI landscape
(MLX Swift, Core ML). This document is the Foundation Models reference.
Availability:
the framework and
SystemLanguageModel
are iOS 26+/macOS 26+.
PrivateCloudComputeLanguageModel
, Dynamic Profiles, image attachments, and the
open
LanguageModel
protocol are
iOS 27+
. Guard accordingly — see
Availability
below.
1. Basic session
import FoundationModels
@available(iOS 26.0, macOS 26.0, *)
@MainActor
@Observable
final class SummarizerModel {
private(set) var summary: String = ""
private(set) var isResponding = false
private let session: LanguageModelSession
init() {
session = LanguageModelSession(
instructions: "You summarize articles in two sentences. Be concrete."
)
}
func summarize(_ article: String) async {
isResponding = true
defer { isResponding = false }
do {
let response = try await session.respond(to: article)
summary = response.content
} catch is CancellationError {
return
} catch {
summary = ""
// surface the error — see Error handling below
}
}
}
LanguageModelSession
is
stateful
: every prompt and response is appended to
its
transcript
, and that transcript is what the model sees on the next turn.
One session per conversation; do not reuse one session for unrelated tasks.
Check availability before you show UI
The model is not present on every device or in every region. Branch on it rather
than letting the call fail:
switch SystemLanguageModel.default.availability {
case .available:
ShowFeature()
case .unavailable(let reason):
// .deviceNotEligible, .appleIntelligenceNotEnabled, .modelNotReady
FeatureUnavailableView(reason: reason)
}
Never ship an entry point that only fails at tap time. This is the
@available
check's runtime counterpart, and both are required.
2. Structured output with
@Generable
Do not ask for JSON and parse it yourself.
@Generable
constrains decoding so
the model returns a real Swift value.
@available(iOS 26.0, macOS 26.0, *)
@Generable
struct Recipe {
@Guide(description: "Dish name, title case, no punctuation")
let name: String
@Guide(description: "Ingredients with quantities", .count(3...12))
let ingredients: [String]
@Guide(description: "Total minutes, cooking plus prep", .range(5...240))
let minutes: Int
@Guide(description: "Difficulty for a home cook")
let difficulty: Difficulty
@Generable
enum Difficulty: String {
case easy, medium, hard
}
}
let response = try await session.respond(
to: "A weeknight pasta using pantry staples.",
generating: Recipe.self
)
let recipe: Recipe = response.content // typed, no JSON parsing
@Guide
is what makes the output usable. A bare
let minutes: Int
invites any
integer;
.range(5...240)
makes an out-of-range answer unrepresentable.
Streaming partial results
@Generable
synthesizes a
PartiallyGenerated
type whose properties fill in as
the model produces them — the right way to avoid a spinner on a long generation.
@MainActor
@Observable
final class RecipeModel {
private(set) var partial: Recipe.PartiallyGenerated?
func generate(_ prompt: String) async throws {
let stream = session.streamResponse(to: prompt, generating: Recipe.self)
for try await partial in stream {
self.partial = partial // main-actor write, view updates per chunk
}
}
}
// In the view — render what exists, leave the rest as placeholders.
if let name = model.partial?.name {
Text(name).font(.headline)
} else {
Text("Generating…").redacted(reason: .placeholder)
}
3. Tool calling
A
Tool
lets the model call your code. Use it for anything the model cannot
know: live data, the user's own content, or an action with a side effect.
@available(iOS 26.0, macOS 26.0, *)
struct FindRecipesTool: Tool {
let name = "findRecipes"
let description = "Search the user's saved recipes by ingredient."
// Non-Sendable dependencies must be captured safely — this store is an actor.
let store: RecipeStore
@Generable
struct Arguments {
@Guide(description: "Ingredient to search for, singular, lowercase")
let ingredient: String
}
func call(arguments: Arguments) async throws -> String {
let matches = try await store.search(ingredient: arguments.ingredient)
guard !matches.isEmpty else { return "No saved recipes with that ingredient." }
return matches.map(\.name).joined(separator: ", ")
}
}
let session = LanguageModelSession(
tools: [FindRecipesTool(store: store)],
instructions: "Help the user cook using recipes they have saved."
)
Tool rules that matter in practice:
description is the routing signal.
The model decides whether to call your
tool by reading it. Vague description, tool never fires.
Tools must be Sendable
and are called from a concurrent context. Hold
dependencies as actors or immutable values — see
Concurrency
below.
Return a short, factual string.
Not JSON, not prose. The model reads it.
Throwing ends the tool call.
Handle expected failures by returning a
sentence the model can use ("no results"), and reserve
throw
for real errors.
Built-in system tools (iOS 27+)
Vision-backed tools ship with the framework — do not reimplement them:
Tool
Does
OCRTool
Text extraction from an image
BarcodeReaderTool
Barcode and QR reading
Spotlight search tool
Local retrieval (RAG) over the user's indexed content
Controlling when tools are used
let response = try await session.respond(
to: "Write out the instructions for folding a paper crane.",
options: GenerationOptions(toolCallingMode: .required) // .allowed | .disallowed | .required
)
.required
forces a tool call — and can loop forever if nothing ends it. Always
give it an exit: flip to
.disallowed
once the tool has run, or have the tool
throw
CancellationError
to break the loop.
4. Multimodal prompts (iOS 27+)
Images attach directly to a prompt. Accepted:
UIImage
,
NSImage
,
CGImage
,
Core Image images,
CVPixelBuffer
, and file URLs.
@available(iOS 27.0, *)
func identify(_ image: UIImage) async throws -> String {
let response = try await session.respond {
"What animal is this? Answer with the species only."
Attachment(image)
}
return response.content
}
Combine with
@Generable
when you need structure rather than a sentence.
5. Model selection
Model
Where it runs
Use for
SystemLanguageModel
On device
Default. Private, offline, free, low latency
PrivateCloudComputeLanguageModel (iOS 27+)
Private Cloud Compute
Larger context (32K), harder reasoning
CoreAILanguageModel, MLXLanguageModel
On device
Open-source conformances for custom weights
Third-party packages
Varies
Anthropic and Google publish conforming Swift packages
@available(iOS 27.0, *)
let session = LanguageModelSession(
model: PrivateCloudComputeLanguageModel(),
instructions: "You are a careful technical reviewer."
)
let response = try await session.respond(
to: prompt,
contextOptions: ContextOptions(reasoningLevel: .deep) // .light | .deep
)
Default to on-device.
Reach for Private Cloud Compute only when the task
genuinely needs the bigger context or deeper reasoning — it costs latency and
requires a network. PCC retains no prompt data; see
docs/frameworks/apple-intelligence.md
for the privacy model.
Bringing your own model (iOS 27+)
The abstraction is open. A custom provider conforms to two protocols:
public protocol LanguageModel: Sendable {
var capabilities: LanguageModelCapabilities { get }
var executorConfiguration: Executor.Configuration { get }
}
public protocol LanguageModelExecutor: Sendable {
init(configuration: Configuration) throws
func prewarm(model: Model, transcript: Transcript)
func respond(
to request: LanguageModelExecutorGenerationRequest,
model: Model,
streamingInto channel: LanguageModelExecutorGenerationChannel
) async throws
}
@available(iOS 27.0, *)
struct MyLanguageModel: LanguageModel {
typealias Executor = MyLanguageModelExecutor
var capabilities: LanguageModelCapabilities {
LanguageModelCapabilities(capabilities: [.toolCalling, .guidedGeneration, .reasoning])
}
var executorConfiguration: Executor.Configuration {
Executor.Configuration(endpoint: endpoint, apiKeyIdentifier: keyID)
}
}
Two things to get right:
Configuration is the cache key.
The framework caches executors by its
hash, which is what preserves the KV cache across calls. Do not put per-request
values in it.
Always implement streaming.
The one-shot API collects deltas internally, so
a streaming
respond
gives you both for free.
Declare only capabilities you actually support — claiming
.guidedGeneration
you cannot honor produces malformed output rather than a clean error.
6. Dynamic Profiles (iOS 27+)
A
DynamicProfile
swaps instructions, tools, and even the model
within one
session
, based on your app's state. This is the primitive for agentic features.
@available(iOS 27.0, *)
struct CookingProfile: LanguageModelSession.DynamicProfile {
let state: CookingState
var body: some DynamicProfile {
switch state.mode {
case .browsing:
Profile {
Instructions("Help the user pick a recipe. Be brief.")
FindRecipesTool(store: state.store)
}
.model(state.systemModel)
case .cooking:
Profile {
Instructions("Guide the user step by step. One step at a time.")
TimerTool()
}
.model(state.pccModel)
.reasoningLevel(.deep)
}
}
}
let session = LanguageModelSession(profile: CookingProfile(state: state))
The transcript is preserved across mode switches, so the model keeps context
while its instructions and tools change underneath it.
Two orchestration patterns
Baton pass
— profiles share the full transcript; a tool flips the mode:
Profile {
BrainstormInstructions()
HandoffTool()
}
.onToolCall { state.mode = .planning }
Phone a friend
— a tool spawns a short-lived child session with its own
isolated transcript, so a subtask cannot pollute the main conversation:
struct SummarizeTool: Tool {
let name = "summarize"
let description = "Summarize the discussion so far into one paragraph."
func call(arguments: Arguments) async throws -> String {
let child = LanguageModelSession(profile: SummaryProfile())
return try await child.respond(to: arguments.text).content
}
}
This mirrors the subagent model in
docs/orchestration/subagents.md
: isolated
context for the subtask, one result handed back to the caller.
7. Context, tokens, and cost
Context is finite. Long conversations will hit the window.
let model = SystemLanguageModel()
print(model.contextSize) // e.g. 8192
let count = try await model.tokenCount(for: prompt) // iOS 26.4+
let response = try await session.respond(to: prompt)
print(response.usage.input.totalTokenCount)
print(response.usage.input.cachedTokenCount)
print(response.usage.output.totalTokenCount)
When a transcript grows past the window, transform it rather than letting the
call fail — a rolling window, or dropping completed tool calls:
Profile { CoachInstructions() }
.historyTransform { history in
// Keep the most recent exchanges; drop resolved tool traffic.
history.suffix(40)
}
Appending preserves the KV cache; rewriting history invalidates it
and adds
latency. Prefer appending. Measure before you optimize — Xcode's Foundation
Models instrument shows cache behavior directly.
8. Concurrency
Foundation Models is
async
throughout and interacts with the isolation rules in
docs/swift/swift-concurrency.md
.
// RIGHT — @MainActor model, session owned by it, tool dependencies are actors.
@available(iOS 26.0, *)
@MainActor
@Observable
final class ChatModel {
private(set) var messages: [Message] = []
private let session: LanguageModelSession
private var task: Task<Void, Never>?
func send(_ text: String) async {
task?.cancel() // supersede the in-flight response
let task = Task { await stream(text) }
self.task = task
await task.value
}
private func stream(_ text: String) async {
do {
for try await partial in session.streamResponse(to: text) {
try Task.checkCancellation()
messages[messages.count - 1].body = partial
}
} catch is CancellationError {
return
} catch {
// surface it
}
}
}
Rules:
LanguageModelSession
is
not
re-entrant in a useful way. Check
session.isResponding
, or hold a single in-flight
Task
, before sending
another prompt. Overlapping calls interleave into one transcript.
Tools run off the main actor.
Their dependencies must be
Sendable
—
use an
actor
store rather than
@unchecked Sendable
.
Streaming loops must honour cancellation, since
.task
cancels on disappear.
Never hold a
LanguageModelSession
in a nonisolated
@Observable
— same
data-race rule as any other UI-facing model.
9. Error handling
do {
let response = try await session.respond(to: prompt)
handle(response.content)
} catch is CancellationError {
return // user moved on — not a failure
} catch let error as LanguageModelSession.GenerationError {
switch error {
case .exceededContextWindowSize:
await compactTranscript() // then retry
case .guardrailViolation:
message = String(localized: "Let's try a different question.")
case .unsupportedLanguageOrLocale:
message = String(localized: "Not available in this language yet.")
default:
message = String(localized: "Something went wrong. Try again.")
}
} catch {
message = String(localized: "Something went wrong. Try again.")
}
Guardrail violations are
expected
, not exceptional — the model declining is
normal operation. Handle it as a product state with a real message, never as a
crash or a silent empty result.
10. Availability
Two versions are in play. Do not collapse them.
// Baseline framework + on-device model.
@available(iOS 26.0, macOS 26.0, *)
// PCC model, Dynamic Profiles, image attachments, custom LanguageModel providers.
@available(iOS 27.0, *)
func makeSession() -> LanguageModelSession? {
if #available(iOS 27.0, *) {
return LanguageModelSession(profile: CookingProfile(state: state))
} else if #available(iOS 26.0, *) {
return LanguageModelSession(instructions: fallbackInstructions)
} else {
return nil // feature hidden entirely below iOS 26
}
}
An app supporting iOS 17+ (this skill's baseline) must treat every Foundation
Models feature as additive. The non-AI path is the product; the AI path is an
enhancement.
11. Testing and evaluation
Model output is non-deterministic, so assert on
shape and constraints
, not
exact strings.
@Test("recipe generation respects guides")
func recipeConstraints() async throws {
let response = try await session.respond(to: "A quick pasta.", generating: Recipe.self)
let recipe = response.content
#expect((5...240).contains(recipe.minutes))
#expect((3...12).contains(recipe.ingredients.count))
#expect(!recipe.name.isEmpty)
}
For UI and unit tests, put the model behind a protocol like every other
dependency (
docs/testing/mocking-strategy.md
), so tests do not invoke a real
model:
protocol RecipeGenerating: Sendable {
func generate(from prompt: String) async throws -> Recipe
}
struct StubRecipeGenerator: RecipeGenerating {
var result: Recipe = .sample
func generate(from prompt: String) async throws -> Recipe { result }
}
For measuring output
quality
across prompt changes, use the
Evaluations
framework
rather than eyeballing — it quantifies whether a prompt tweak
actually helped.
Anti-Patterns
// 1. Asking for JSON and parsing it by hand.
let json = try await session.respond(to: "Return JSON with name and minutes")
let recipe = try JSONDecoder().decode(Recipe.self, from: Data(json.content.utf8))
// Use @Generable. The model is not a reliable JSON emitter.
// 2. A @Generable type with no @Guide.
@Generable struct Recipe { let minutes: Int } // accepts 0, accepts 99999
@Generable struct Recipe {
@Guide(description: "Total minutes", .range(5...240)) let minutes: Int
}
// 3. Shipping the feature without an availability check.
LanguageModelSession(...) // fails on ineligible devices
// Check SystemLanguageModel.default.availability first, and gate the entry point.
// 4. One session reused for unrelated tasks.
// The transcript is shared — earlier turns leak into later answers.
// One session per conversation.
// 5. Overlapping prompts on one session.
Button("Send") { Task { await model.send(text) } } // tap twice, transcripts interleave
// Guard with session.isResponding or a single in-flight Task.
// 6. Treating a guardrail violation as a crash.
let response = try! await session.respond(to: userText)
// Declining is normal. Handle it as a product state.
// 7. A tool whose description does not say when to use it.
let description = "Recipe tool" // never gets called
let description = "Search the user's saved recipes by ingredient."
// 8. Non-Sendable state captured in a Tool.
struct MyTool: Tool { let cache: NSMutableDictionary } // data race
struct MyTool: Tool { let cache: CacheActor }
// 9. Private Cloud Compute by default.
LanguageModelSession(model: PrivateCloudComputeLanguageModel())
// Costs latency and needs a network. Default on-device; escalate deliberately.
// 10. .required tool calling with no exit condition.
// Loops until the context window fills. Flip to .disallowed after the call.
// 11. Blocking the UI on a long generation.
// Stream with PartiallyGenerated and render as it arrives.
// 12. Asserting exact model output in a test.
#expect(recipe.name == "Garlic Pasta") // flaky by construction
#expect(!recipe.name.isEmpty) // assert shape and constraints
Checklist
[ ]
@available
guard matches the feature: iOS 26 for the baseline, iOS 27 for
PCC, Dynamic Profiles, attachments, and custom providers.
[ ]
SystemLanguageModel.default.availability
checked before the entry point
is shown, with a real unavailable state.
[ ] Structured output uses
@Generable
with
@Guide
constraints — never
hand-parsed JSON.
[ ] Long generations stream via
PartiallyGenerated
.
[ ] Every
Tool
has a description saying
when
to use it, and is
Sendable
.
[ ]
.required
tool calling has an exit condition.
[ ] One session per conversation; overlapping prompts are guarded.
[ ] Guardrail violations and context-window overflow are handled as product
states with real messages.
[ ] Cancellation is honoured in streaming loops.
[ ] On-device is the default; PCC is a deliberate escalation.
[ ] Tests assert shape and constraints, and unit tests use a protocol double.
[ ] The feature is additive — the app still works with no model available.
Released routing APIs and a compiled starting point
Reviewed 2026-09-16 against
Apple’s Foundation Models updates
. iOS 27 adds the
LanguageModel
protocol for provider adapters, alongside Apple’s on-device and Private Cloud Compute models. A shared protocol does not provide provider credentials, entitlements, pricing, or identical privacy guarantees. Confirm the actual vendor adapter and model capability before offering Claude or Gemini; this repository does not bundle or claim a verified adapter for either.
Apple links
CoreAILanguageModel
and
MLXLanguageModel
for local model integrations. Device memory, model licensing, conversion and workload size still constrain what can run locally; “full-scale models” is not an unlimited capability guarantee.
ReadingAssistant.swift
is a complete Foundation Models tool-calling composition with an injected reading catalog, per-request session, runtime availability check and local-search fallback. It uses the iOS 26 baseline so the actual implementation compiles on our installed toolchain. Tests call the real
Tool
and exercise fallback/error/cancellation through an injected backend; they do not assert a model chose the tool or generated a correct answer. Xcode 27-only routing remains a separate validation task.
---
# Foundation Framework
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-foundation.html
← Back to all frameworks & guides
All articles →Data Management · Reference guideFoundation FrameworkRepository guidance for Foundation. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
URLSession — Async Networking
FileManager
UserDefaults and @AppStorage
JSONEncoder/JSONDecoder and Codable
Formatters
NotificationCenter
Timer and RunLoop
ProcessInfo and Bundle
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Define a data contract
↓2
Transform or decode values
↓3
Handle dates files and URLs
↓4
Test malformed input
02 / ArchitectureResponsibility boundariesBoundary 1
Input valuesBoundary 2
Foundation operationsBoundary 3
Domain valuesConnected responsibilities, not a required class hierarchy or an execution trace.
URLSession — Async Networking
// Basic async data request
func fetchUser(id: Int) async throws -> User {
let url = URL(string: "https://api.example.com/users/\(id)")!
let (data, response) = try await URLSession.shared.data(from: url)
guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode) else {
throw URLError(.badServerResponse)
}
return try JSONDecoder().decode(User.self, from: data)
}
// Download with progress
func downloadFile(from url: URL) async throws -> URL {
let (localURL, response) = try await URLSession.shared.download(from: url)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
throw URLError(.badServerResponse)
}
// Move from temp to permanent location
let destination = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
.appendingPathComponent(url.lastPathComponent)
try FileManager.default.moveItem(at: localURL, to: destination)
return destination
}
// Upload data
func uploadImage(_ imageData: Data) async throws -> UploadResponse {
var request = URLRequest(url: URL(string: "https://api.example.com/upload")!)
request.httpMethod = "POST"
request.setValue("image/jpeg", forHTTPHeaderField: "Content-Type")
let (data, _) = try await URLSession.shared.upload(for: request, from: imageData)
return try JSONDecoder().decode(UploadResponse.self, from: data)
}
// Custom URLSession configuration
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 30
config.waitsForConnectivity = true
config.requestCachePolicy = .returnCacheDataElseLoad
config.httpAdditionalHeaders = ["Authorization": "Bearer \(token)"]
let session = URLSession(configuration: config)
FileManager
let fm = FileManager.default
// App directories
let documentsURL = fm.urls(for: .documentDirectory, in: .userDomainMask).first!
let cachesURL = fm.urls(for: .cachesDirectory, in: .userDomainMask).first!
let appSupportURL = fm.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
// Create directory
let logsDir = documentsURL.appendingPathComponent("Logs", isDirectory: true)
try fm.createDirectory(at: logsDir, withIntermediateDirectories: true)
// Write and read string
let filePath = documentsURL.appendingPathComponent("notes.txt")
try "Hello, world!".write(to: filePath, atomically: true, encoding: .utf8)
let contents = try String(contentsOf: filePath, encoding: .utf8)
// Write and read JSON
let encoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted
let jsonData = try encoder.encode(myModel)
try jsonData.write(to: documentsURL.appendingPathComponent("data.json"))
// File attributes
let attrs = try fm.attributesOfItem(atPath: filePath.path)
let fileSize = attrs[.size] as? UInt64
let modified = attrs[.modificationDate] as? Date
// List directory contents
let items = try fm.contentsOfDirectory(
at: documentsURL,
includingPropertiesForKeys: [.fileSizeKey, .creationDateKey],
options: .skipsHiddenFiles
)
// Check existence and delete
if fm.fileExists(atPath: filePath.path) {
try fm.removeItem(at: filePath)
}
// Copy and move
try fm.copyItem(at: source, to: destination)
try fm.moveItem(at: source, to: destination)
UserDefaults and @AppStorage
// Basic UserDefaults
let defaults = UserDefaults.standard
defaults.set("dark", forKey: "theme")
defaults.set(true, forKey: "notificationsEnabled")
defaults.set(42, forKey: "launchCount")
let theme = defaults.string(forKey: "theme") ?? "system"
// Register defaults (call in AppDelegate or App init)
UserDefaults.standard.register(defaults: [
"theme": "system",
"notificationsEnabled": true,
"launchCount": 0,
])
// App Groups for sharing between app and extensions
let sharedDefaults = UserDefaults(suiteName: "group.com.myapp.shared")
sharedDefaults?.set(true, forKey: "isPremium")
// @AppStorage in SwiftUI — auto-persists to UserDefaults
struct SettingsView: View {
@AppStorage("theme") private var theme = "system"
@AppStorage("fontSize") private var fontSize = 16.0
@AppStorage("isPremium", store: UserDefaults(suiteName: "group.com.myapp.shared"))
private var isPremium = false
var body: some View {
Form {
Picker("Theme", selection: $theme) {
Text("System").tag("system")
Text("Light").tag("light")
Text("Dark").tag("dark")
}
Slider(value: $fontSize, in: 12...24, step: 1) {
Text("Font Size: \(Int(fontSize))")
}
}
}
}
JSONEncoder/JSONDecoder and Codable
// Basic Codable model
struct Article: Codable, Identifiable {
let id: Int
let title: String
let body: String
let publishedAt: Date
let author: Author
let tags: [String]
struct Author: Codable {
let name: String
let avatarURL: URL
enum CodingKeys: String, CodingKey {
case name
case avatarURL = "avatar_url"
}
}
}
// Configured decoder
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
decoder.dateDecodingStrategy = .iso8601
let articles = try decoder.decode([Article].self, from: jsonData)
// Configured encoder
let encoder = JSONEncoder()
encoder.keyEncodingStrategy = .convertToSnakeCase
encoder.dateEncodingStrategy = .iso8601
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
let data = try encoder.encode(articles)
// Custom Codable for complex JSON
struct SearchResult: Codable {
let query: String
let results: [Item]
struct Item: Codable {
let id: String
let score: Double
}
enum CodingKeys: String, CodingKey {
case query = "q"
case results = "hits"
}
// Custom decoding
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
query = try container.decode(String.self, forKey: .query)
results = try container.decodeIfPresent([Item].self, forKey: .results) ?? []
}
}
Formatters
// DateFormatter
let dateFormatter = DateFormatter()
dateFormatter.dateStyle = .medium
dateFormatter.timeStyle = .short
dateFormatter.locale = Locale(identifier: "en_US")
let dateString = dateFormatter.string(from: Date()) // "Mar 30, 2026 at 3:45 PM"
// ISO 8601
let isoFormatter = ISO8601DateFormatter()
isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
let isoString = isoFormatter.string(from: Date())
// RelativeDateTimeFormatter
let relativeFormatter = RelativeDateTimeFormatter()
relativeFormatter.unitsStyle = .full
relativeFormatter.string(from: Date().addingTimeInterval(-3600)) // "1 hour ago"
// NumberFormatter
let currencyFormatter = NumberFormatter()
currencyFormatter.numberStyle = .currency
currencyFormatter.locale = Locale(identifier: "en_US")
currencyFormatter.string(from: 49.99) // "$49.99"
let percentFormatter = NumberFormatter()
percentFormatter.numberStyle = .percent
percentFormatter.maximumFractionDigits = 1
percentFormatter.string(from: 0.856) // "85.6%"
// Modern formatted API (iOS 15+)
let formatted = Date.now.formatted(.dateTime.month(.wide).day().year())
let price = 49.99.formatted(.currency(code: "USD"))
let percent = 0.856.formatted(.percent.precision(.fractionLength(1)))
NotificationCenter
// Define custom notification
extension Notification.Name {
static let userDidLogin = Notification.Name("userDidLogin")
static let cartUpdated = Notification.Name("cartUpdated")
}
// Post notification with userInfo
NotificationCenter.default.post(
name: .userDidLogin,
object: nil,
userInfo: ["userId": "abc123", "timestamp": Date()]
)
// Observe with closure (returns token — store it)
let token = NotificationCenter.default.addObserver(
forName: .cartUpdated,
object: nil,
queue: .main
) { notification in
if let count = notification.userInfo?["itemCount"] as? Int {
print("Cart has \(count) items")
}
}
// Observe with async sequence (iOS 17+)
func observeLoginEvents() async {
for await notification in NotificationCenter.default.notifications(named: .userDidLogin) {
if let userId = notification.userInfo?["userId"] as? String {
print("User logged in: \(userId)")
}
}
}
// Remove observer
NotificationCenter.default.removeObserver(token)
Timer and RunLoop
// Scheduled repeating timer
let timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { timer in
print("Tick")
}
timer.invalidate() // Stop the timer
// Timer with tolerance for battery efficiency
let efficientTimer = Timer.scheduledTimer(withTimeInterval: 60.0, repeats: true) { _ in
refreshData()
}
efficientTimer.tolerance = 5.0 // System can delay up to 5 seconds
// Async timer pattern
func startCountdown(from seconds: Int) async {
for remaining in stride(from: seconds, through: 0, by: -1) {
print("\(remaining)...")
try? await Task.sleep(for: .seconds(1))
}
}
// Timer publisher (Combine)
import Combine
let timerPublisher = Timer.publish(every: 1.0, on: .main, in: .common).autoconnect()
ProcessInfo and Bundle
// ProcessInfo
let processInfo = ProcessInfo.processInfo
let systemUptime = processInfo.systemUptime
let isLowPowerMode = processInfo.isLowPowerModeEnabled
let thermalState = processInfo.thermalState // .nominal, .fair, .serious, .critical
let osVersion = processInfo.operatingSystemVersion // (major: 18, minor: 0, patch: 0)
// Check for Xcode preview or simulator
#if DEBUG
let isPreview = processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1"
#endif
// Bundle
let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
let buildNumber = Bundle.main.infoDictionary?["CFBundleVersion"] as? String
let bundleId = Bundle.main.bundleIdentifier
// Load bundled resource
if let url = Bundle.main.url(forResource: "config", withExtension: "json"),
let data = try? Data(contentsOf: url) {
let config = try JSONDecoder().decode(AppConfig.self, from: data)
}
// Localized strings
let greeting = Bundle.main.localizedString(forKey: "greeting", value: nil, table: nil)
// Or use the macro: String(localized: "greeting")
---
# CoreBluetooth
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-hardware-core-bluetooth.html
← Back to all frameworks & guides
All articles →Networking and Connectivity · Reference guideCoreBluetoothRepository guidance for Core Bluetooth. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
CBCentralManager Setup and State Handling
Scanning for Peripherals
Connecting and Discovering Services/Characteristics
Reading, Writing, and Subscribing to Characteristics
CBPeripheralManager — Acting as a Peripheral
Background BLE Execution Modes
Error Handling and Timeout Patterns
Complete BLE Heart Rate Monitor Example
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Check Bluetooth state
↓2
Discover eligible peripherals
↓3
Connect and exchange values
↓4
Recover from disconnection
02 / ArchitectureResponsibility boundariesBoundary 1
Central managerBoundary 2
Peripheral servicesBoundary 3
App connection stateConnected responsibilities, not a required class hierarchy or an execution trace.
CBCentralManager Setup and State Handling
Add to
Info.plist
:
-
NSBluetoothAlwaysUsageDescription
— required for Bluetooth access
-
UIBackgroundModes
— include
bluetooth-central
and/or
bluetooth-peripheral
for background BLE
import CoreBluetooth
@Observable
class BLEManager: NSObject, CBCentralManagerDelegate {
static let shared = BLEManager()
var centralManager: CBCentralManager!
var isBluetoothReady = false
var bluetoothState: CBManagerState = .unknown
var discoveredPeripherals: [CBPeripheral] = []
var connectedPeripheral: CBPeripheral?
override init() {
super.init()
centralManager = CBCentralManager(delegate: self, queue: nil)
}
func centralManagerDidUpdateState(_ central: CBCentralManager) {
bluetoothState = central.state
switch central.state {
case .poweredOn:
isBluetoothReady = true
case .poweredOff:
isBluetoothReady = false
case .unauthorized:
isBluetoothReady = false
case .unsupported:
isBluetoothReady = false
case .resetting:
isBluetoothReady = false
case .unknown:
break
@unknown default:
break
}
}
}
Scanning for Peripherals
// Define service UUIDs to scan for
let heartRateServiceUUID = CBUUID(string: "180D")
let batteryServiceUUID = CBUUID(string: "180F")
extension BLEManager {
func startScanning() {
guard isBluetoothReady else { return }
// Scan for specific services (recommended) or pass nil for all
centralManager.scanForPeripherals(
withServices: [heartRateServiceUUID],
options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]
)
}
func stopScanning() {
centralManager.stopScan()
}
func centralManager(
_ central: CBCentralManager,
didDiscover peripheral: CBPeripheral,
advertisementData: [String: Any],
rssi RSSI: NSNumber
) {
// Filter by signal strength
guard RSSI.intValue > -80 else { return }
// Parse advertisement data
let localName = advertisementData[CBAdvertisementDataLocalNameKey] as? String
let isConnectable = advertisementData[CBAdvertisementDataIsConnectable] as? Bool ?? false
if !discoveredPeripherals.contains(where: { $0.identifier == peripheral.identifier }) {
discoveredPeripherals.append(peripheral)
}
}
}
Connecting and Discovering Services/Characteristics
extension BLEManager: CBPeripheralDelegate {
func connect(to peripheral: CBPeripheral) {
centralManager.stopScan()
peripheral.delegate = self
centralManager.connect(peripheral, options: nil)
}
func disconnect() {
guard let peripheral = connectedPeripheral else { return }
centralManager.cancelPeripheralConnection(peripheral)
}
// Connection callbacks
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
connectedPeripheral = peripheral
// Discover services after connecting
peripheral.discoverServices([heartRateServiceUUID, batteryServiceUUID])
}
func centralManager(
_ central: CBCentralManager,
didFailToConnect peripheral: CBPeripheral,
error: Error?
) {
connectedPeripheral = nil
}
func centralManager(
_ central: CBCentralManager,
didDisconnectPeripheral peripheral: CBPeripheral,
error: Error?
) {
connectedPeripheral = nil
// Auto-reconnect if unexpected disconnect
if error != nil {
centralManager.connect(peripheral, options: nil)
}
}
// Service discovery
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
guard let services = peripheral.services else { return }
for service in services {
peripheral.discoverCharacteristics(nil, for: service)
}
}
// Characteristic discovery
func peripheral(
_ peripheral: CBPeripheral,
didDiscoverCharacteristicsFor service: CBService,
error: Error?
) {
guard let characteristics = service.characteristics else { return }
for characteristic in characteristics {
if characteristic.properties.contains(.notify) {
peripheral.setNotifyValue(true, for: characteristic)
}
if characteristic.properties.contains(.read) {
peripheral.readValue(for: characteristic)
}
}
}
}
Reading, Writing, and Subscribing to Characteristics
let heartRateMeasurementUUID = CBUUID(string: "2A37")
let bodySensorLocationUUID = CBUUID(string: "2A38")
extension BLEManager {
// Read a characteristic value
func readCharacteristic(_ characteristic: CBCharacteristic) {
guard let peripheral = connectedPeripheral else { return }
peripheral.readValue(for: characteristic)
}
// Write a value to a characteristic
func writeCharacteristic(
_ characteristic: CBCharacteristic,
data: Data,
withResponse: Bool = true
) {
guard let peripheral = connectedPeripheral else { return }
let type: CBCharacteristicWriteType = withResponse ? .withResponse : .withoutResponse
peripheral.writeValue(data, for: characteristic, type: type)
}
// Subscribe to notifications
func subscribeToCharacteristic(_ characteristic: CBCharacteristic) {
guard let peripheral = connectedPeripheral else { return }
peripheral.setNotifyValue(true, for: characteristic)
}
// Handle updated values
func peripheral(
_ peripheral: CBPeripheral,
didUpdateValueFor characteristic: CBCharacteristic,
error: Error?
) {
guard let data = characteristic.value, error == nil else { return }
switch characteristic.uuid {
case heartRateMeasurementUUID:
let heartRate = parseHeartRate(from: data)
// Update UI with heart rate
case bodySensorLocationUUID:
let location = parseSensorLocation(from: data)
// Update UI with sensor location
default:
break
}
}
// Write confirmation
func peripheral(
_ peripheral: CBPeripheral,
didWriteValueFor characteristic: CBCharacteristic,
error: Error?
) {
if let error {
// Handle write error
}
}
private func parseHeartRate(from data: Data) -> Int {
let bytes = [UInt8](https://github.com/Nagarjuna2997/ios-agent-skill/blob/main/docs/frameworks/hardware/data)
// Bit 0 of first byte indicates format: 0 = UInt8, 1 = UInt16
let isUInt16 = (bytes[0] & 0x01) == 1
if isUInt16 {
return Int(UInt16(bytes[1]) | (UInt16(bytes[2]) << 8))
} else {
return Int(bytes[1])
}
}
private func parseSensorLocation(from data: Data) -> String {
let bytes = [UInt8](https://github.com/Nagarjuna2997/ios-agent-skill/blob/main/docs/frameworks/hardware/data)
switch bytes[0] {
case 0: return "Other"
case 1: return "Chest"
case 2: return "Wrist"
case 3: return "Finger"
case 4: return "Hand"
case 5: return "Ear Lobe"
case 6: return "Foot"
default: return "Unknown"
}
}
}
CBPeripheralManager — Acting as a Peripheral
@Observable
class BLEPeripheralManager: NSObject, CBPeripheralManagerDelegate {
var peripheralManager: CBPeripheralManager!
var isAdvertising = false
private var heartRateCharacteristic: CBMutableCharacteristic?
private var subscribedCentrals: [CBCentral] = []
override init() {
super.init()
peripheralManager = CBPeripheralManager(delegate: self, queue: nil)
}
func peripheralManagerDidUpdateState(_ peripheral: CBPeripheralManager) {
guard peripheral.state == .poweredOn else { return }
setupServices()
}
private func setupServices() {
// Create characteristic
let heartRateChar = CBMutableCharacteristic(
type: CBUUID(string: "2A37"),
properties: [.notify, .read],
value: nil,
permissions: [.readable]
)
heartRateCharacteristic = heartRateChar
// Create service
let heartRateService = CBMutableService(
type: CBUUID(string: "180D"),
primary: true
)
heartRateService.characteristics = [heartRateChar]
peripheralManager.add(heartRateService)
}
func peripheralManager(
_ peripheral: CBPeripheralManager,
didAdd service: CBService,
error: Error?
) {
guard error == nil else { return }
startAdvertising()
}
func startAdvertising() {
peripheralManager.startAdvertising([
CBAdvertisementDataServiceUUIDsKey: [CBUUID(string: "180D")],
CBAdvertisementDataLocalNameKey: "MyHeartMonitor"
])
isAdvertising = true
}
func stopAdvertising() {
peripheralManager.stopAdvertising()
isAdvertising = false
}
// Handle subscription from central
func peripheralManager(
_ peripheral: CBPeripheralManager,
central: CBCentral,
didSubscribeTo characteristic: CBCharacteristic
) {
subscribedCentrals.append(central)
}
// Send updated heart rate to subscribers
func updateHeartRate(_ bpm: Int) {
guard let characteristic = heartRateCharacteristic else { return }
var data = Data()
data.append(UInt8(0x00)) // Flags: UInt8 format
data.append(UInt8(bpm))
let didSend = peripheralManager.updateValue(
data,
for: characteristic,
onSubscribedCentrals: nil
)
if !didSend {
// Queue is full; will retry when peripheralManagerIsReady is called
}
}
func peripheralManagerIsReadyToUpdateSubscribers(_ peripheral: CBPeripheralManager) {
// Retry sending queued updates
}
}
Background BLE Execution Modes
Add to
Info.plist
:
<key>UIBackgroundModes</key>
<array>
<string>bluetooth-central</string>
<string>bluetooth-peripheral</string>
</array>
// Initialize with state restoration for background operation
class BackgroundBLEManager: NSObject, CBCentralManagerDelegate {
static let restorationIdentifier = "com.app.ble.central"
var centralManager: CBCentralManager!
override init() {
super.init()
centralManager = CBCentralManager(
delegate: self,
queue: nil,
options: [
CBCentralManagerOptionRestoreIdentifierKey: Self.restorationIdentifier
]
)
}
func centralManagerDidUpdateState(_ central: CBCentralManager) {
if central.state == .poweredOn {
// Scanning in background requires specific service UUIDs
central.scanForPeripherals(
withServices: [CBUUID(string: "180D")],
options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]
)
}
}
// State restoration — called when app is relaunched in background
func centralManager(
_ central: CBCentralManager,
willRestoreState dict: [String: Any]
) {
// Restore previously connected peripherals
if let peripherals = dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral] {
for peripheral in peripherals {
peripheral.delegate = self // reassign delegate
// Re-discover services if needed
if peripheral.state == .connected {
peripheral.discoverServices([CBUUID(string: "180D")])
}
}
}
// Restore scan services
if let scanServices = dict[CBCentralManagerRestoredStateScanServicesKey] as? [CBUUID] {
// App was scanning for these services
}
}
}
extension BackgroundBLEManager: CBPeripheralDelegate {
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {}
func peripheral(
_ peripheral: CBPeripheral,
didDiscoverCharacteristicsFor service: CBService,
error: Error?
) {}
func peripheral(
_ peripheral: CBPeripheral,
didUpdateValueFor characteristic: CBCharacteristic,
error: Error?
) {}
}
Error Handling and Timeout Patterns
enum BLEError: LocalizedError {
case bluetoothUnavailable
case connectionTimeout
case connectionFailed(Error?)
case serviceNotFound
case characteristicNotFound
case writeFailed(Error?)
case readFailed(Error?)
var errorDescription: String? {
switch self {
case .bluetoothUnavailable:
return "Bluetooth is not available on this device."
case .connectionTimeout:
return "Connection to the device timed out."
case .connectionFailed(let error):
return "Failed to connect: \(error?.localizedDescription ?? "Unknown error")"
case .serviceNotFound:
return "Required service not found on device."
case .characteristicNotFound:
return "Required characteristic not found."
case .writeFailed(let error):
return "Write failed: \(error?.localizedDescription ?? "Unknown error")"
case .readFailed(let error):
return "Read failed: \(error?.localizedDescription ?? "Unknown error")"
}
}
}
actor BLEConnectionManager {
private var connectionContinuation: CheckedContinuation<CBPeripheral, Error>?
private var centralManager: CBCentralManager
private var delegate: BLEConnectionDelegate
init(centralManager: CBCentralManager) {
self.centralManager = centralManager
self.delegate = BLEConnectionDelegate()
}
func connect(to peripheral: CBPeripheral, timeout: Duration = .seconds(10)) async throws -> CBPeripheral {
try await withThrowingTaskGroup(of: CBPeripheral.self) { group in
group.addTask {
try await withCheckedThrowingContinuation { continuation in
Task { await self.storeContinuation(continuation) }
self.centralManager.connect(peripheral, options: nil)
}
}
group.addTask {
try await Task.sleep(for: timeout)
throw BLEError.connectionTimeout
}
let result = try await group.next()!
group.cancelAll()
return result
}
}
private func storeContinuation(_ continuation: CheckedContinuation<CBPeripheral, Error>) {
connectionContinuation = continuation
}
func didConnect(_ peripheral: CBPeripheral) {
connectionContinuation?.resume(returning: peripheral)
connectionContinuation = nil
}
func didFailToConnect(_ peripheral: CBPeripheral, error: Error?) {
connectionContinuation?.resume(throwing: BLEError.connectionFailed(error))
connectionContinuation = nil
}
}
private class BLEConnectionDelegate: NSObject {}
Complete BLE Heart Rate Monitor Example
import SwiftUI
import CoreBluetooth
@Observable
class HeartRateMonitor: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate {
var heartRate: Int = 0
var sensorLocation: String = "Unknown"
var isConnected = false
var isScanning = false
var discoveredDevices: [CBPeripheral] = []
var statusMessage = "Tap Scan to find devices"
private var centralManager: CBCentralManager!
private var heartRatePeripheral: CBPeripheral?
private let heartRateServiceUUID = CBUUID(string: "180D")
private let heartRateMeasurementUUID = CBUUID(string: "2A37")
private let bodySensorLocationUUID = CBUUID(string: "2A38")
override init() {
super.init()
centralManager = CBCentralManager(delegate: self, queue: nil)
}
func startScan() {
guard centralManager.state == .poweredOn else {
statusMessage = "Bluetooth is not ready"
return
}
discoveredDevices.removeAll()
centralManager.scanForPeripherals(
withServices: [heartRateServiceUUID],
options: nil
)
isScanning = true
statusMessage = "Scanning..."
}
func stopScan() {
centralManager.stopScan()
isScanning = false
}
func connect(to peripheral: CBPeripheral) {
stopScan()
heartRatePeripheral = peripheral
peripheral.delegate = self
centralManager.connect(peripheral, options: nil)
statusMessage = "Connecting to \(peripheral.name ?? "device")..."
}
func disconnectDevice() {
guard let peripheral = heartRatePeripheral else { return }
centralManager.cancelPeripheralConnection(peripheral)
}
// MARK: - CBCentralManagerDelegate
func centralManagerDidUpdateState(_ central: CBCentralManager) {
switch central.state {
case .poweredOn:
statusMessage = "Ready to scan"
case .poweredOff:
statusMessage = "Bluetooth is off"
case .unauthorized:
statusMessage = "Bluetooth permission denied"
default:
statusMessage = "Bluetooth unavailable"
}
}
func centralManager(
_ central: CBCentralManager,
didDiscover peripheral: CBPeripheral,
advertisementData: [String: Any],
rssi RSSI: NSNumber
) {
if !discoveredDevices.contains(where: { $0.identifier == peripheral.identifier }) {
discoveredDevices.append(peripheral)
}
}
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
isConnected = true
statusMessage = "Connected to \(peripheral.name ?? "device")"
peripheral.discoverServices([heartRateServiceUUID])
}
func centralManager(
_ central: CBCentralManager,
didDisconnectPeripheral peripheral: CBPeripheral,
error: Error?
) {
isConnected = false
heartRate = 0
statusMessage = "Disconnected"
}
// MARK: - CBPeripheralDelegate
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
guard let services = peripheral.services else { return }
for service in services where service.uuid == heartRateServiceUUID {
peripheral.discoverCharacteristics(
[heartRateMeasurementUUID, bodySensorLocationUUID],
for: service
)
}
}
func peripheral(
_ peripheral: CBPeripheral,
didDiscoverCharacteristicsFor service: CBService,
error: Error?
) {
guard let characteristics = service.characteristics else { return }
for characteristic in characteristics {
switch characteristic.uuid {
case heartRateMeasurementUUID:
peripheral.setNotifyValue(true, for: characteristic)
case bodySensorLocationUUID:
peripheral.readValue(for: characteristic)
default:
break
}
}
}
func peripheral(
_ peripheral: CBPeripheral,
didUpdateValueFor characteristic: CBCharacteristic,
error: Error?
) {
guard let data = characteristic.value else { return }
let bytes = [UInt8](https://github.com/Nagarjuna2997/ios-agent-skill/blob/main/docs/frameworks/hardware/data)
switch characteristic.uuid {
case heartRateMeasurementUUID:
let isUInt16 = (bytes[0] & 0x01) == 1
heartRate = isUInt16
? Int(UInt16(bytes[1]) | (UInt16(bytes[2]) << 8))
: Int(bytes[1])
case bodySensorLocationUUID:
let locations = ["Other", "Chest", "Wrist", "Finger", "Hand", "Ear Lobe", "Foot"]
let index = Int(bytes[0])
sensorLocation = index < locations.count ? locations[index] : "Unknown"
default:
break
}
}
}
struct HeartRateView: View {
@State private var monitor = HeartRateMonitor()
var body: some View {
NavigationStack {
VStack(spacing: 24) {
// Heart rate display
VStack(spacing: 8) {
Image(systemName: "heart.fill")
.font(.system(size: 60))
.foregroundStyle(.red)
.symbolEffect(.pulse, isActive: monitor.isConnected)
Text("\(monitor.heartRate)")
.font(.system(size: 72, weight: .bold, design: .rounded))
.contentTransition(.numericText())
.animation(.spring, value: monitor.heartRate)
Text("BPM")
.font(.title3)
.foregroundStyle(.secondary)
Text("Sensor: \(monitor.sensorLocation)")
.font(.caption)
.foregroundStyle(.tertiary)
}
.padding()
Text(monitor.statusMessage)
.font(.subheadline)
.foregroundStyle(.secondary)
// Device list
if !monitor.discoveredDevices.isEmpty {
List(monitor.discoveredDevices, id: \.identifier) { device in
Button {
monitor.connect(to: device)
} label: {
HStack {
Image(systemName: "heart.circle")
Text(device.name ?? "Unknown Device")
Spacer()
Image(systemName: "chevron.right")
.foregroundStyle(.tertiary)
}
}
}
.listStyle(.insetGrouped)
}
Spacer()
}
.navigationTitle("Heart Rate")
.toolbar {
if monitor.isConnected {
Button("Disconnect") {
monitor.disconnectDevice()
}
} else {
Button(monitor.isScanning ? "Stop" : "Scan") {
if monitor.isScanning {
monitor.stopScan()
} else {
monitor.startScan()
}
}
}
}
}
}
}
---
# CoreMotion
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-hardware-core-motion.html
← Back to all frameworks & guides
All articles →Health, Hardware, and Sensors · Reference guideCoreMotionRepository guidance for Core Motion. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
CMMotionManager Setup
Accelerometer Data (CMAccelerometerData)
Gyroscope Data (CMGyroData)
Device Motion (CMDeviceMotion)
CMPedometer — Steps, Distance, Floors, Cadence
CMMotionActivityManager — Activity Recognition
CMAltimeter — Relative Altitude
Motion Data Frequency and Battery Considerations
Complete Pedometer and Motion Tracking Examples
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Check sensor availability
↓2
Start required updates
↓3
Process motion samples
↓4
Stop updates on exit
02 / ArchitectureResponsibility boundariesBoundary 1
Device sensorsBoundary 2
Motion processingBoundary 3
Feature stateConnected responsibilities, not a required class hierarchy or an execution trace.
CMMotionManager Setup
Only one
CMMotionManager
should exist per app. Create it as a shared instance.
import CoreMotion
@Observable
class MotionManager {
static let shared = MotionManager()
let motionManager = CMMotionManager()
var accelerometerData: CMAccelerometerData?
var gyroData: CMGyroData?
var deviceMotion: CMDeviceMotion?
var isAccelerometerAvailable: Bool { motionManager.isAccelerometerAvailable }
var isGyroAvailable: Bool { motionManager.isGyroAvailable }
var isDeviceMotionAvailable: Bool { motionManager.isDeviceMotionAvailable }
deinit {
stopAllUpdates()
}
func stopAllUpdates() {
motionManager.stopAccelerometerUpdates()
motionManager.stopGyroUpdates()
motionManager.stopDeviceMotionUpdates()
}
}
Accelerometer Data (CMAccelerometerData)
Measures acceleration along x, y, z axes in G-force units. Includes gravity.
extension MotionManager {
/// Start accelerometer updates at the given frequency
func startAccelerometer(frequency: Double = 60.0) {
guard isAccelerometerAvailable else { return }
motionManager.accelerometerUpdateInterval = 1.0 / frequency
motionManager.startAccelerometerUpdates(to: .main) { [weak self] data, error in
guard let data, error == nil else { return }
self?.accelerometerData = data
// Access raw values
let x = data.acceleration.x // lateral
let y = data.acceleration.y // longitudinal
let z = data.acceleration.z // vertical
let magnitude = sqrt(x * x + y * y + z * z)
}
}
/// Poll-based accelerometer reading (no callback)
func readAccelerometer() -> CMAccelerometerData? {
motionManager.startAccelerometerUpdates()
return motionManager.accelerometerData
}
func stopAccelerometer() {
motionManager.stopAccelerometerUpdates()
}
}
Gyroscope Data (CMGyroData)
Measures rotation rate in radians per second around x, y, z axes.
extension MotionManager {
func startGyroscope(frequency: Double = 60.0) {
guard isGyroAvailable else { return }
motionManager.gyroUpdateInterval = 1.0 / frequency
motionManager.startGyroUpdates(to: .main) { [weak self] data, error in
guard let data, error == nil else { return }
self?.gyroData = data
let rotationX = data.rotationRate.x // pitch rate
let rotationY = data.rotationRate.y // yaw rate
let rotationZ = data.rotationRate.z // roll rate
}
}
func stopGyroscope() {
motionManager.stopGyroUpdates()
}
}
Device Motion (CMDeviceMotion)
Combines accelerometer, gyroscope, and magnetometer into processed motion data. Separates user acceleration from gravity and provides attitude (orientation).
extension MotionManager {
func startDeviceMotion(
frequency: Double = 60.0,
referenceFrame: CMAttitudeReferenceFrame = .xArbitraryZVertical
) {
guard isDeviceMotionAvailable else { return }
motionManager.deviceMotionUpdateInterval = 1.0 / frequency
motionManager.startDeviceMotionUpdates(
using: referenceFrame,
to: .main
) { [weak self] motion, error in
guard let motion, error == nil else { return }
self?.deviceMotion = motion
// Attitude — device orientation
let pitch = motion.attitude.pitch // radians, nose up/down
let roll = motion.attitude.roll // radians, tilt left/right
let yaw = motion.attitude.yaw // radians, compass heading
// Rotation rate (processed, bias removed)
let rotRate = motion.rotationRate
// Gravity vector (unit gravity direction in device frame)
let gx = motion.gravity.x
let gy = motion.gravity.y
let gz = motion.gravity.z
// User acceleration (gravity removed)
let ux = motion.userAcceleration.x
let uy = motion.userAcceleration.y
let uz = motion.userAcceleration.z
// Magnetic field (calibrated)
let field = motion.magneticField.field
let accuracy = motion.magneticField.accuracy
}
}
func stopDeviceMotion() {
motionManager.stopDeviceMotionUpdates()
}
/// Detect device orientation from gravity
func currentOrientation() -> String {
guard let gravity = deviceMotion?.gravity else { return "Unknown" }
if gravity.z < -0.8 { return "Face Up" }
if gravity.z > 0.8 { return "Face Down" }
if gravity.x < -0.8 { return "Landscape Left" }
if gravity.x > 0.8 { return "Landscape Right" }
if gravity.y < -0.8 { return "Portrait" }
if gravity.y > 0.8 { return "Portrait Upside Down" }
return "Unknown"
}
}
CMPedometer — Steps, Distance, Floors, Cadence
No special permissions needed; data is available if the hardware supports it. Use
CMPedometer
instead of
CMMotionManager
for step counting.
@Observable
class PedometerManager {
let pedometer = CMPedometer()
var steps: Int = 0
var distance: Double = 0 // meters
var floorsAscended: Int = 0
var floorsDescended: Int = 0
var currentCadence: Double = 0 // steps per second
var currentPace: Double = 0 // seconds per meter
var isAvailable: Bool { CMPedometer.isStepCountingAvailable() }
/// Query historical pedometer data
func fetchSteps(from start: Date, to end: Date = .now) async throws -> CMPedometerData {
try await withCheckedThrowingContinuation { continuation in
pedometer.queryPedometerData(from: start, to: end) { data, error in
if let error {
continuation.resume(throwing: error)
return
}
guard let data else {
continuation.resume(throwing: PedometerError.noData)
return
}
continuation.resume(returning: data)
}
}
}
/// Start live pedometer updates from a given date
func startLiveUpdates(from date: Date = Calendar.current.startOfDay(for: .now)) {
guard isAvailable else { return }
pedometer.startUpdates(from: date) { [weak self] data, error in
guard let data, error == nil else { return }
Task { @MainActor in
self?.steps = data.numberOfSteps.intValue
self?.distance = data.distance?.doubleValue ?? 0
self?.floorsAscended = data.floorsAscended?.intValue ?? 0
self?.floorsDescended = data.floorsDescended?.intValue ?? 0
self?.currentCadence = data.currentCadence?.doubleValue ?? 0
self?.currentPace = data.currentPace?.doubleValue ?? 0
}
}
}
func stopLiveUpdates() {
pedometer.stopUpdates()
}
}
enum PedometerError: LocalizedError {
case noData
case notAvailable
var errorDescription: String? {
switch self {
case .noData: return "No pedometer data available."
case .notAvailable: return "Step counting is not available on this device."
}
}
}
CMMotionActivityManager — Activity Recognition
Detects whether the user is walking, running, driving, cycling, or stationary. Requires
NSMotionUsageDescription
in
Info.plist
.
import CoreMotion
@Observable
class ActivityManager {
let activityManager = CMMotionActivityManager()
var currentActivity: String = "Unknown"
var confidence: CMMotionActivityConfidence = .low
var isStationary = false
var isWalking = false
var isRunning = false
var isAutomotive = false
var isCycling = false
var isAvailable: Bool { CMMotionActivityManager.isActivityAvailable() }
func startActivityUpdates() {
guard isAvailable else { return }
activityManager.startActivityUpdates(to: .main) { [weak self] activity in
guard let activity, let self else { return }
self.confidence = activity.confidence
self.isStationary = activity.stationary
self.isWalking = activity.walking
self.isRunning = activity.running
self.isAutomotive = activity.automotive
self.isCycling = activity.cycling
self.currentActivity = self.activityDescription(activity)
}
}
func stopActivityUpdates() {
activityManager.stopActivityUpdates()
}
/// Query historical activities
func fetchActivities(from start: Date, to end: Date = .now) async throws -> [CMMotionActivity] {
try await withCheckedThrowingContinuation { continuation in
activityManager.queryActivityStarting(
from: start,
to: end,
to: .main
) { activities, error in
if let error {
continuation.resume(throwing: error)
return
}
continuation.resume(returning: activities ?? [])
}
}
}
private func activityDescription(_ activity: CMMotionActivity) -> String {
if activity.running { return "Running" }
if activity.cycling { return "Cycling" }
if activity.automotive { return "Driving" }
if activity.walking { return "Walking" }
if activity.stationary { return "Stationary" }
return "Unknown"
}
}
CMAltimeter — Relative Altitude
Measures relative altitude changes using the barometric pressure sensor.
@Observable
class AltimeterManager {
let altimeter = CMAltimeter()
var relativeAltitude: Double = 0 // meters, relative to start
var pressure: Double = 0 // kilopascals
var isAvailable: Bool { CMAltimeter.isRelativeAltitudeAvailable() }
func startAltimeterUpdates() {
guard isAvailable else { return }
altimeter.startRelativeAltitudeUpdates(to: .main) { [weak self] data, error in
guard let data, error == nil else { return }
self?.relativeAltitude = data.relativeAltitude.doubleValue
self?.pressure = data.pressure.doubleValue
}
}
func stopAltimeterUpdates() {
altimeter.stopRelativeAltitudeUpdates()
}
}
Motion Data Frequency and Battery Considerations
/// Frequency guidelines:
/// - UI updates (tilt, orientation): 10-30 Hz
/// - Games and AR: 60 Hz
/// - Gesture detection: 50-100 Hz
/// - Avoid > 100 Hz unless necessary — major battery drain
extension MotionManager {
/// Configure for low-power UI-driven usage
func configureLowPower() {
motionManager.accelerometerUpdateInterval = 1.0 / 10.0 // 10 Hz
motionManager.gyroUpdateInterval = 1.0 / 10.0
motionManager.deviceMotionUpdateInterval = 1.0 / 10.0
}
/// Configure for high-fidelity game/AR usage
func configureHighFidelity() {
motionManager.accelerometerUpdateInterval = 1.0 / 60.0 // 60 Hz
motionManager.gyroUpdateInterval = 1.0 / 60.0
motionManager.deviceMotionUpdateInterval = 1.0 / 60.0
}
}
/// Best practices:
/// - Stop updates when the app enters the background
/// - Use the lowest frequency that meets your needs
/// - Prefer CMDeviceMotion over raw accelerometer/gyro (sensor fusion is better)
/// - Use CMPedometer for step counting instead of raw accelerometer
/// - Process data on a background queue, update UI on main queue
Complete Pedometer and Motion Tracking Examples
import SwiftUI
import CoreMotion
struct PedometerView: View {
@State private var pedometerManager = PedometerManager()
@State private var activityManager = ActivityManager()
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 24) {
// Step counter ring
ZStack {
Circle()
.stroke(.gray.opacity(0.2), lineWidth: 12)
Circle()
.trim(from: 0, to: min(Double(pedometerManager.steps) / 10000.0, 1.0))
.stroke(
.green.gradient,
style: StrokeStyle(lineWidth: 12, lineCap: .round)
)
.rotationEffect(.degrees(-90))
.animation(.spring(duration: 0.8), value: pedometerManager.steps)
VStack(spacing: 4) {
Image(systemName: "figure.walk")
.font(.largeTitle)
.foregroundStyle(.green)
Text("\(pedometerManager.steps)")
.font(.system(size: 48, weight: .bold, design: .rounded))
.contentTransition(.numericText())
Text("steps today")
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
.frame(width: 200, height: 200)
// Current activity
HStack {
Image(systemName: activityIcon)
.font(.title2)
.foregroundStyle(.blue)
.frame(width: 44, height: 44)
.background(.blue.opacity(0.1), in: Circle())
VStack(alignment: .leading) {
Text("Current Activity")
.font(.caption)
.foregroundStyle(.secondary)
Text(activityManager.currentActivity)
.font(.headline)
}
Spacer()
}
.padding()
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16))
.padding(.horizontal)
// Metrics grid
LazyVGrid(columns: [
GridItem(.flexible()),
GridItem(.flexible())
], spacing: 16) {
MotionMetricCard(
title: "Distance",
value: String(
format: "%.1f",
pedometerManager.distance / 1000
),
unit: "km",
icon: "location.fill",
color: .blue
)
MotionMetricCard(
title: "Floors Up",
value: "\(pedometerManager.floorsAscended)",
unit: "floors",
icon: "arrow.up",
color: .orange
)
MotionMetricCard(
title: "Cadence",
value: String(
format: "%.0f",
pedometerManager.currentCadence * 60
),
unit: "steps/min",
icon: "metronome.fill",
color: .purple
)
MotionMetricCard(
title: "Floors Down",
value: "\(pedometerManager.floorsDescended)",
unit: "floors",
icon: "arrow.down",
color: .teal
)
}
.padding(.horizontal)
}
.padding(.top)
}
.navigationTitle("Pedometer")
.onAppear {
pedometerManager.startLiveUpdates()
activityManager.startActivityUpdates()
}
.onDisappear {
pedometerManager.stopLiveUpdates()
activityManager.stopActivityUpdates()
}
}
}
var activityIcon: String {
if activityManager.isRunning { return "figure.run" }
if activityManager.isCycling { return "figure.outdoor.cycle" }
if activityManager.isAutomotive { return "car.fill" }
if activityManager.isWalking { return "figure.walk" }
return "figure.stand"
}
}
struct MotionMetricCard: View {
let title: String
let value: String
let unit: String
let icon: String
let color: Color
var body: some View {
VStack(spacing: 8) {
Image(systemName: icon)
.foregroundStyle(color)
.font(.title3)
Text(value)
.font(.system(.title2, design: .rounded, weight: .bold))
.contentTransition(.numericText())
Text(unit)
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
.padding()
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16))
}
}
struct MotionTrackerView: View {
@State private var motionManager = MotionManager()
var body: some View {
NavigationStack {
List {
if let motion = motionManager.deviceMotion {
Section("Attitude (degrees)") {
MotionRow(label: "Pitch", value: degrees(motion.attitude.pitch))
MotionRow(label: "Roll", value: degrees(motion.attitude.roll))
MotionRow(label: "Yaw", value: degrees(motion.attitude.yaw))
}
Section("User Acceleration (G)") {
MotionRow(label: "X", value: motion.userAcceleration.x)
MotionRow(label: "Y", value: motion.userAcceleration.y)
MotionRow(label: "Z", value: motion.userAcceleration.z)
}
Section("Gravity (G)") {
MotionRow(label: "X", value: motion.gravity.x)
MotionRow(label: "Y", value: motion.gravity.y)
MotionRow(label: "Z", value: motion.gravity.z)
}
Section("Rotation Rate (rad/s)") {
MotionRow(label: "X", value: motion.rotationRate.x)
MotionRow(label: "Y", value: motion.rotationRate.y)
MotionRow(label: "Z", value: motion.rotationRate.z)
}
Section("Orientation") {
Text(motionManager.currentOrientation())
.font(.headline)
}
} else {
ContentUnavailableView(
"No Motion Data",
systemImage: "gyroscope",
description: Text("Waiting for device motion updates.")
)
}
}
.navigationTitle("Motion Tracker")
.onAppear {
motionManager.startDeviceMotion(frequency: 30)
}
.onDisappear {
motionManager.stopDeviceMotion()
}
}
}
func degrees(_ radians: Double) -> Double {
radians * 180.0 / .pi
}
}
struct MotionRow: View {
let label: String
let value: Double
var body: some View {
HStack {
Text(label)
.foregroundStyle(.secondary)
Spacer()
Text(String(format: "%.3f", value))
.monospacedDigit()
.contentTransition(.numericText())
}
}
}
---
# CoreNFC
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-hardware-core-nfc.html
← Back to all frameworks & guides
All articles →Health, Hardware, and Sensors · Reference guideCoreNFCRepository guidance for Core NFC. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
NFCNDEFReaderSession Setup and Entitlements
Reading NDEF Tags
Parsing NDEF Payloads — URL, Text, Custom
Writing to NDEF Tags
NFCTagReaderSession — ISO 14443, ISO 15693, FeliCa
Background Tag Reading
Error Handling
Complete NFC Tag Reader/Writer Example
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Check reading capability
↓2
Start a foreground session
↓3
Read supported tag data
↓4
Invalidate the session
02 / ArchitectureResponsibility boundariesBoundary 1
Reader sessionBoundary 2
NFC tagBoundary 3
Validated domain dataConnected responsibilities, not a required class hierarchy or an execution trace.
NFCNDEFReaderSession Setup and Entitlements
Requirements:
- iPhone 7 or later
- Add
Near Field Communication Tag Reading
capability in Xcode
- Add
NFCReaderUsageDescription
to
Info.plist
- Add NFC entitlement to your provisioning profile
Info.plist
entries:
<key>NFCReaderUsageDescription</key>
<string>This app reads NFC tags to retrieve information.</string>
<!-- For background tag reading -->
<key>com.apple.developer.associated-application-identifier</key>
<string>$(TeamIdentifierPrefix)com.example.app</string>
Entitlements file (
.entitlements
):
<key>com.apple.developer.nfc.readersession.formats</key>
<array>
<string>NDEF</string>
<string>TAG</string>
</array>
import CoreNFC
@Observable
class NFCManager: NSObject {
var scannedMessage: String = ""
var scannedRecords: [NFCRecord] = []
var isScanning = false
var error: Error?
private var ndefSession: NFCNDEFReaderSession?
private var writeMessage: NFCNDEFMessage?
var isNFCAvailable: Bool {
NFCNDEFReaderSession.readingAvailable
}
}
struct NFCRecord: Identifiable {
let id = UUID()
let type: String
let payload: String
let rawData: Data
}
Reading NDEF Tags
extension NFCManager: NFCNDEFReaderSessionDelegate {
func startReading() {
guard isNFCAvailable else {
error = NFCError.notAvailable
return
}
ndefSession = NFCNDEFReaderSession(
delegate: self,
queue: nil,
invalidateAfterFirstRead: true
)
ndefSession?.alertMessage = "Hold your iPhone near an NFC tag."
ndefSession?.begin()
isScanning = true
}
// MARK: - NFCNDEFReaderSessionDelegate
func readerSessionDidBecomeActive(_ session: NFCNDEFReaderSession) {
// Session is active and scanning
}
func readerSession(
_ session: NFCNDEFReaderSession,
didDetectNDEFs messages: [NFCNDEFMessage]
) {
var records: [NFCRecord] = []
for message in messages {
for record in message.records {
let parsed = parseNDEFRecord(record)
records.append(parsed)
}
}
Task { @MainActor in
self.scannedRecords = records
self.scannedMessage = records.map(\.payload).joined(separator: "\n")
self.isScanning = false
}
}
func readerSession(
_ session: NFCNDEFReaderSession,
didInvalidateWithError error: Error
) {
let readerError = error as? NFCReaderError
Task { @MainActor in
// User cancelled is not a real error
if readerError?.code != .readerSessionInvalidationErrorUserCanceled {
self.error = error
}
self.isScanning = false
}
}
}
Parsing NDEF Payloads — URL, Text, Custom
extension NFCManager {
func parseNDEFRecord(_ record: NFCNDEFPayload) -> NFCRecord {
let typeString = String(data: record.type, encoding: .utf8) ?? "Unknown"
switch record.typeNameFormat {
case .nfcWellKnown:
return parseWellKnownRecord(record, typeString: typeString)
case .media:
let mimeType = typeString
let payload = String(data: record.payload, encoding: .utf8) ?? ""
return NFCRecord(type: mimeType, payload: payload, rawData: record.payload)
case .absoluteURI:
let uri = String(data: record.payload, encoding: .utf8) ?? ""
return NFCRecord(type: "URI", payload: uri, rawData: record.payload)
case .nfcExternal:
let payload = String(data: record.payload, encoding: .utf8) ?? ""
return NFCRecord(type: "External: \(typeString)", payload: payload, rawData: record.payload)
default:
return NFCRecord(
type: "Unknown",
payload: record.payload.map { String(format: "%02x", $0) }.joined(),
rawData: record.payload
)
}
}
private func parseWellKnownRecord(
_ record: NFCNDEFPayload,
typeString: String
) -> NFCRecord {
switch typeString {
case "T":
// Text record
return parseTextRecord(record)
case "U":
// URI record
return parseURIRecord(record)
default:
let payload = String(data: record.payload, encoding: .utf8) ?? ""
return NFCRecord(type: typeString, payload: payload, rawData: record.payload)
}
}
private func parseTextRecord(_ record: NFCNDEFPayload) -> NFCRecord {
let payload = record.payload
guard !payload.isEmpty else {
return NFCRecord(type: "Text", payload: "", rawData: payload)
}
let statusByte = payload[0]
let languageCodeLength = Int(statusByte & 0x3F)
let isUTF16 = (statusByte & 0x80) != 0
let textStartIndex = 1 + languageCodeLength
guard textStartIndex < payload.count else {
return NFCRecord(type: "Text", payload: "", rawData: payload)
}
let textData = payload.subdata(in: textStartIndex..<payload.count)
let encoding: String.Encoding = isUTF16 ? .utf16 : .utf8
let text = String(data: textData, encoding: encoding) ?? ""
return NFCRecord(type: "Text", payload: text, rawData: payload)
}
private func parseURIRecord(_ record: NFCNDEFPayload) -> NFCRecord {
// Use Apple's built-in helper
if let url = record.wellKnownTypeURIPayload() {
return NFCRecord(type: "URL", payload: url.absoluteString, rawData: record.payload)
}
// Manual parsing fallback
let prefixes = [
0x00: "",
0x01: "http://www.",
0x02: "https://www.",
0x03: "http://",
0x04: "https://",
0x05: "tel:",
0x06: "mailto:"
]
let payload = record.payload
guard !payload.isEmpty else {
return NFCRecord(type: "URL", payload: "", rawData: payload)
}
let prefix = prefixes[Int(payload[0])] ?? ""
let remainder = String(data: payload.subdata(in: 1..<payload.count), encoding: .utf8) ?? ""
return NFCRecord(type: "URL", payload: prefix + remainder, rawData: payload)
}
}
Writing to NDEF Tags
extension NFCManager {
func startWriting(text: String) {
guard isNFCAvailable else {
error = NFCError.notAvailable
return
}
// Create NDEF message to write
guard let textPayload = NFCNDEFPayload.wellKnownTypeTextPayload(
string: text,
locale: .current
) else {
error = NFCError.invalidPayload
return
}
writeMessage = NFCNDEFMessage(records: [textPayload])
ndefSession = NFCNDEFReaderSession(
delegate: self,
queue: nil,
invalidateAfterFirstRead: false // false to allow writing
)
ndefSession?.alertMessage = "Hold your iPhone near an NFC tag to write."
ndefSession?.begin()
isScanning = true
}
func startWritingURL(_ url: URL) {
guard isNFCAvailable else {
error = NFCError.notAvailable
return
}
guard let urlPayload = NFCNDEFPayload.wellKnownTypeURIPayload(url: url) else {
error = NFCError.invalidPayload
return
}
writeMessage = NFCNDEFMessage(records: [urlPayload])
ndefSession = NFCNDEFReaderSession(
delegate: self,
queue: nil,
invalidateAfterFirstRead: false
)
ndefSession?.alertMessage = "Hold your iPhone near an NFC tag to write."
ndefSession?.begin()
isScanning = true
}
// Called when a writable tag is detected
func readerSession(
_ session: NFCNDEFReaderSession,
didDetect tags: [any NFCNDEFTag]
) {
guard let tag = tags.first else {
session.invalidate(errorMessage: "No tag found.")
return
}
session.connect(to: tag) { [weak self] connectionError in
guard connectionError == nil, let self else {
session.invalidate(errorMessage: "Connection failed.")
return
}
tag.queryNDEFStatus { status, capacity, error in
guard error == nil else {
session.invalidate(errorMessage: "Failed to query tag.")
return
}
switch status {
case .notSupported:
session.invalidate(errorMessage: "Tag is not NDEF formatted.")
case .readOnly:
session.invalidate(errorMessage: "Tag is read-only.")
case .readWrite:
guard let message = self.writeMessage else {
session.invalidate(errorMessage: "No message to write.")
return
}
// Check capacity
let messageLength = message.length
guard messageLength <= capacity else {
session.invalidate(
errorMessage: "Message too large for tag (\(messageLength)/\(capacity) bytes)."
)
return
}
tag.writeNDEF(message) { writeError in
if let writeError {
session.invalidate(errorMessage: "Write failed: \(writeError.localizedDescription)")
} else {
session.alertMessage = "Successfully wrote to tag!"
session.invalidate()
}
}
@unknown default:
session.invalidate(errorMessage: "Unknown tag status.")
}
}
}
}
}
NFCTagReaderSession — ISO 14443, ISO 15693, FeliCa
@Observable
class NFCTagReader: NSObject, NFCTagReaderSessionDelegate {
var tagID: String = ""
var tagType: String = ""
private var tagSession: NFCTagReaderSession?
func startTagReading(pollingOption: NFCTagReaderSession.PollingOption = .iso14443) {
guard NFCTagReaderSession.readingAvailable else { return }
tagSession = NFCTagReaderSession(
pollingOption: pollingOption,
delegate: self,
queue: nil
)
tagSession?.alertMessage = "Hold your iPhone near the tag."
tagSession?.begin()
}
func tagReaderSessionDidBecomeActive(_ session: NFCTagReaderSession) {}
func tagReaderSession(
_ session: NFCTagReaderSession,
didInvalidateWithError error: Error
) {}
func tagReaderSession(
_ session: NFCTagReaderSession,
didDetect tags: [NFCTag]
) {
guard let tag = tags.first else { return }
session.connect(to: tag) { error in
guard error == nil else {
session.invalidate(errorMessage: "Connection failed.")
return
}
switch tag {
case .iso7816(let iso7816Tag):
// ISO 14443 Type A/B — used by payment cards, passports
let identifier = iso7816Tag.identifier
.map { String(format: "%02x", $0) }
.joined(separator: ":")
Task { @MainActor in
self.tagID = identifier
self.tagType = "ISO 7816 (ISO 14443)"
}
// Send APDU command
let apdu = NFCISO7816APDU(
instructionClass: 0x00,
instructionCode: 0xB0,
p1Parameter: 0x00,
p2Parameter: 0x00,
data: Data(),
expectedResponseLength: 256
)
iso7816Tag.sendCommand(apdu: apdu) { responseData, sw1, sw2, error in
// Process response
session.alertMessage = "Tag read successfully."
session.invalidate()
}
case .iso15693(let iso15693Tag):
// ISO 15693 — NFC-V, vicinity cards
let identifier = iso15693Tag.identifier
.map { String(format: "%02x", $0) }
.joined(separator: ":")
Task { @MainActor in
self.tagID = identifier
self.tagType = "ISO 15693"
}
session.alertMessage = "Tag read successfully."
session.invalidate()
case .feliCa(let feliCaTag):
// FeliCa — used in Japan (Suica, etc.)
let idm = feliCaTag.currentIDm
.map { String(format: "%02x", $0) }
.joined(separator: ":")
Task { @MainActor in
self.tagID = idm
self.tagType = "FeliCa"
}
session.alertMessage = "Tag read successfully."
session.invalidate()
case .miFare(let miFareTag):
// MiFare — NXP tags
let identifier = miFareTag.identifier
.map { String(format: "%02x", $0) }
.joined(separator: ":")
Task { @MainActor in
self.tagID = identifier
self.tagType = "MiFare (\(miFareTag.mifareFamily))"
}
session.alertMessage = "Tag read successfully."
session.invalidate()
@unknown default:
session.invalidate(errorMessage: "Unsupported tag type.")
}
}
}
}
Background Tag Reading
Add to
Info.plist
for Universal Links-style NFC launch:
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:example.com</string>
</array>
import SwiftUI
// Handle background tag reading in your App
@main
struct NFCApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.onContinueUserActivity(
NSUserActivityTypeBrowsingWeb
) { userActivity in
// Handle NFC tag scanned in background
guard let url = userActivity.webpageURL else { return }
handleNFCURL(url)
}
}
}
func handleNFCURL(_ url: URL) {
// Process the URL from the NFC tag
// This is called when the user taps the NFC notification
}
}
Error Handling
enum NFCError: LocalizedError {
case notAvailable
case invalidPayload
case tagNotWritable
case messageTooLarge(needed: Int, capacity: Int)
case connectionFailed
case readFailed(Error)
case writeFailed(Error)
var errorDescription: String? {
switch self {
case .notAvailable:
return "NFC is not available on this device."
case .invalidPayload:
return "The NFC payload is invalid."
case .tagNotWritable:
return "This NFC tag is read-only."
case .messageTooLarge(let needed, let capacity):
return "Message (\(needed) bytes) exceeds tag capacity (\(capacity) bytes)."
case .connectionFailed:
return "Failed to connect to the NFC tag."
case .readFailed(let error):
return "Read failed: \(error.localizedDescription)"
case .writeFailed(let error):
return "Write failed: \(error.localizedDescription)"
}
}
}
Complete NFC Tag Reader/Writer Example
import SwiftUI
import CoreNFC
struct NFCReaderWriterView: View {
@State private var nfcManager = NFCManager()
@State private var writeText = ""
@State private var writeURL = ""
@State private var selectedMode: NFCMode = .read
enum NFCMode: String, CaseIterable {
case read = "Read"
case writeText = "Write Text"
case writeURL = "Write URL"
}
var body: some View {
NavigationStack {
VStack(spacing: 24) {
// Mode picker
Picker("Mode", selection: $selectedMode) {
ForEach(NFCMode.allCases, id: \.self) { mode in
Text(mode.rawValue).tag(mode)
}
}
.pickerStyle(.segmented)
.padding(.horizontal)
switch selectedMode {
case .read:
readModeView
case .writeText:
writeTextView
case .writeURL:
writeURLView
}
Spacer()
// NFC availability indicator
if !nfcManager.isNFCAvailable {
Label("NFC not available on this device", systemImage: "xmark.circle")
.foregroundStyle(.red)
.font(.caption)
}
}
.navigationTitle("NFC Tags")
}
}
var readModeView: some View {
VStack(spacing: 20) {
Image(systemName: "wave.3.right")
.font(.system(size: 60))
.foregroundStyle(.blue)
.symbolEffect(.variableColor.iterative, isActive: nfcManager.isScanning)
Button {
nfcManager.startReading()
} label: {
Label("Scan NFC Tag", systemImage: "sensor.tag.radiowaves.forward.fill")
.frame(maxWidth: .infinity)
.padding()
.background(.blue.gradient, in: RoundedRectangle(cornerRadius: 14))
.foregroundStyle(.white)
.font(.headline)
}
.disabled(!nfcManager.isNFCAvailable)
.padding(.horizontal)
// Results
if !nfcManager.scannedRecords.isEmpty {
List(nfcManager.scannedRecords) { record in
VStack(alignment: .leading, spacing: 4) {
Text(record.type)
.font(.caption)
.foregroundStyle(.secondary)
.padding(.horizontal, 8)
.padding(.vertical, 2)
.background(.blue.opacity(0.1), in: Capsule())
Text(record.payload)
.font(.body)
.textSelection(.enabled)
}
.padding(.vertical, 4)
}
.listStyle(.insetGrouped)
}
}
}
var writeTextView: some View {
VStack(spacing: 20) {
TextField("Enter text to write", text: $writeText, axis: .vertical)
.textFieldStyle(.roundedBorder)
.lineLimit(3...6)
.padding(.horizontal)
Button {
nfcManager.startWriting(text: writeText)
} label: {
Label("Write to Tag", systemImage: "square.and.pencil")
.frame(maxWidth: .infinity)
.padding()
.background(.green.gradient, in: RoundedRectangle(cornerRadius: 14))
.foregroundStyle(.white)
.font(.headline)
}
.disabled(writeText.isEmpty || !nfcManager.isNFCAvailable)
.padding(.horizontal)
}
}
var writeURLView: some View {
VStack(spacing: 20) {
TextField("https://example.com", text: $writeURL)
.textFieldStyle(.roundedBorder)
.keyboardType(.URL)
.autocapitalization(.none)
.padding(.horizontal)
Button {
if let url = URL(string: writeURL) {
nfcManager.startWritingURL(url)
}
} label: {
Label("Write URL to Tag", systemImage: "link")
.frame(maxWidth: .infinity)
.padding()
.background(.orange.gradient, in: RoundedRectangle(cornerRadius: 14))
.foregroundStyle(.white)
.font(.headline)
}
.disabled(URL(string: writeURL) == nil || !nfcManager.isNFCAvailable)
.padding(.horizontal)
}
}
}
---
# HealthKit
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-hardware-healthkit.html
← Back to all frameworks & guides
All articles →Health, Hardware, and Sensors · Reference guideHealthKitRepository guidance for HealthKit. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
HKHealthStore Setup and Authorization
HKSampleType, HKQuantityType, HKCategoryType
Reading Health Data — HKSampleQuery
HKStatisticsQuery for Aggregated Data
Writing Health Data
HKStatisticsCollectionQuery — Aggregated Data Over Time
HKObserverQuery — Background Delivery
HKWorkoutSession and HKLiveWorkoutBuilder
Workout Routes with CoreLocation
Complete Step Counter and Workout Tracker
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Explain requested health access
↓2
Request scoped authorization
↓3
Query or save permitted data
↓4
Handle unavailable data
02 / ArchitectureResponsibility boundariesBoundary 1
Health permissionsBoundary 2
Health storeBoundary 3
Health featureConnected responsibilities, not a required class hierarchy or an execution trace.
HKHealthStore Setup and Authorization
Add to
Info.plist
:
-
NSHealthShareUsageDescription
— required for reading health data
-
NSHealthUpdateUsageDescription
— required for writing health data
Add the HealthKit capability in Xcode under Signing & Capabilities.
import HealthKit
@Observable
class HealthKitManager {
static let shared = HealthKitManager()
let healthStore = HKHealthStore()
var isAuthorized = false
var authorizationError: Error?
// Check availability
var isHealthDataAvailable: Bool {
HKHealthStore.isHealthDataAvailable()
}
func requestAuthorization() async throws {
guard isHealthDataAvailable else {
throw HealthKitError.notAvailable
}
// Types to read
let readTypes: Set<HKObjectType> = [
HKQuantityType(.stepCount),
HKQuantityType(.heartRate),
HKQuantityType(.activeEnergyBurned),
HKQuantityType(.distanceWalkingRunning),
HKQuantityType(.bodyMass),
HKCategoryType(.sleepAnalysis),
HKObjectType.workoutType(),
HKObjectType.activitySummaryType()
]
// Types to write
let writeTypes: Set<HKSampleType> = [
HKQuantityType(.stepCount),
HKQuantityType(.bodyMass),
HKQuantityType(.activeEnergyBurned),
HKObjectType.workoutType()
]
try await healthStore.requestAuthorization(
toShare: writeTypes,
read: readTypes
)
isAuthorized = true
}
}
enum HealthKitError: LocalizedError {
case notAvailable
case notAuthorized
case noData
case writeFailed(Error)
var errorDescription: String? {
switch self {
case .notAvailable:
return "HealthKit is not available on this device."
case .notAuthorized:
return "HealthKit authorization was not granted."
case .noData:
return "No health data found."
case .writeFailed(let error):
return "Failed to write data: \(error.localizedDescription)"
}
}
}
HKSampleType, HKQuantityType, HKCategoryType
// Quantity types — numeric values with units
let stepCount = HKQuantityType(.stepCount)
let heartRate = HKQuantityType(.heartRate)
let bodyMass = HKQuantityType(.bodyMass)
let height = HKQuantityType(.height)
let activeEnergy = HKQuantityType(.activeEnergyBurned)
let distance = HKQuantityType(.distanceWalkingRunning)
// Category types — enum-based values
let sleepAnalysis = HKCategoryType(.sleepAnalysis)
let mindfulSession = HKCategoryType(.mindfulSession)
// Workout type
let workoutType = HKObjectType.workoutType()
// Units
let bpm = HKUnit.count().unitDivided(by: .minute())
let kg = HKUnit.gramUnit(with: .kilo)
let miles = HKUnit.mile()
let kcal = HKUnit.kilocalorie()
let steps = HKUnit.count()
Reading Health Data — HKSampleQuery
extension HealthKitManager {
/// Query recent heart rate samples
func fetchHeartRateSamples(limit: Int = 10) async throws -> [HKQuantitySample] {
let heartRateType = HKQuantityType(.heartRate)
let sortDescriptor = NSSortDescriptor(
key: HKSampleSortIdentifierStartDate,
ascending: false
)
return try await withCheckedThrowingContinuation { continuation in
let query = HKSampleQuery(
sampleType: heartRateType,
predicate: nil,
limit: limit,
sortDescriptors: [sortDescriptor]
) { _, samples, error in
if let error {
continuation.resume(throwing: error)
return
}
let quantitySamples = samples as? [HKQuantitySample] ?? []
continuation.resume(returning: quantitySamples)
}
healthStore.execute(query)
}
}
/// Query samples within a date range
func fetchSamples(
type: HKQuantityType,
start: Date,
end: Date = .now,
limit: Int = HKObjectQueryNoLimit
) async throws -> [HKQuantitySample] {
let predicate = HKQuery.predicateForSamples(
withStart: start,
end: end,
options: .strictStartDate
)
let sortDescriptor = NSSortDescriptor(
key: HKSampleSortIdentifierStartDate,
ascending: false
)
return try await withCheckedThrowingContinuation { continuation in
let query = HKSampleQuery(
sampleType: type,
predicate: predicate,
limit: limit,
sortDescriptors: [sortDescriptor]
) { _, samples, error in
if let error {
continuation.resume(throwing: error)
return
}
let quantitySamples = samples as? [HKQuantitySample] ?? []
continuation.resume(returning: quantitySamples)
}
healthStore.execute(query)
}
}
}
HKStatisticsQuery for Aggregated Data
extension HealthKitManager {
/// Get today's total step count
func fetchTodayStepCount() async throws -> Double {
let stepType = HKQuantityType(.stepCount)
let startOfDay = Calendar.current.startOfDay(for: .now)
let predicate = HKQuery.predicateForSamples(
withStart: startOfDay,
end: .now,
options: .strictStartDate
)
return try await withCheckedThrowingContinuation { continuation in
let query = HKStatisticsQuery(
quantityType: stepType,
quantitySamplePredicate: predicate,
options: .cumulativeSum
) { _, statistics, error in
if let error {
continuation.resume(throwing: error)
return
}
let count = statistics?.sumQuantity()?.doubleValue(for: .count()) ?? 0
continuation.resume(returning: count)
}
healthStore.execute(query)
}
}
/// Get average resting heart rate for a period
func fetchAverageHeartRate(start: Date, end: Date = .now) async throws -> Double {
let heartRateType = HKQuantityType(.heartRate)
let predicate = HKQuery.predicateForSamples(
withStart: start,
end: end,
options: .strictStartDate
)
let bpmUnit = HKUnit.count().unitDivided(by: .minute())
return try await withCheckedThrowingContinuation { continuation in
let query = HKStatisticsQuery(
quantityType: heartRateType,
quantitySamplePredicate: predicate,
options: .discreteAverage
) { _, statistics, error in
if let error {
continuation.resume(throwing: error)
return
}
let avg = statistics?.averageQuantity()?.doubleValue(for: bpmUnit) ?? 0
continuation.resume(returning: avg)
}
healthStore.execute(query)
}
}
}
Writing Health Data
extension HealthKitManager {
/// Save a body mass measurement
func saveBodyMass(kg value: Double, date: Date = .now) async throws {
let bodyMassType = HKQuantityType(.bodyMass)
let quantity = HKQuantity(unit: .gramUnit(with: .kilo), doubleValue: value)
let sample = HKQuantitySample(
type: bodyMassType,
quantity: quantity,
start: date,
end: date
)
try await healthStore.save(sample)
}
/// Save a step count entry
func saveSteps(count: Double, start: Date, end: Date) async throws {
let stepType = HKQuantityType(.stepCount)
let quantity = HKQuantity(unit: .count(), doubleValue: count)
let sample = HKQuantitySample(
type: stepType,
quantity: quantity,
start: start,
end: end
)
try await healthStore.save(sample)
}
/// Save a sleep analysis entry
func saveSleep(start: Date, end: Date, value: HKCategoryValueSleepAnalysis) async throws {
let sleepType = HKCategoryType(.sleepAnalysis)
let sample = HKCategorySample(
type: sleepType,
value: value.rawValue,
start: start,
end: end
)
try await healthStore.save(sample)
}
}
HKStatisticsCollectionQuery — Aggregated Data Over Time
extension HealthKitManager {
/// Get daily step counts for the last 7 days
func fetchDailySteps(days: Int = 7) async throws -> [(date: Date, steps: Double)] {
let stepType = HKQuantityType(.stepCount)
let calendar = Calendar.current
let endDate = Date.now
guard let startDate = calendar.date(byAdding: .day, value: -days, to: endDate) else {
throw HealthKitError.noData
}
let anchorDate = calendar.startOfDay(for: endDate)
let daily = DateComponents(day: 1)
let predicate = HKQuery.predicateForSamples(
withStart: startDate,
end: endDate,
options: .strictStartDate
)
return try await withCheckedThrowingContinuation { continuation in
let query = HKStatisticsCollectionQuery(
quantityType: stepType,
quantitySamplePredicate: predicate,
options: .cumulativeSum,
anchorDate: anchorDate,
intervalComponents: daily
)
query.initialResultsHandler = { _, collection, error in
if let error {
continuation.resume(throwing: error)
return
}
var results: [(date: Date, steps: Double)] = []
collection?.enumerateStatistics(from: startDate, to: endDate) { statistics, _ in
let count = statistics.sumQuantity()?.doubleValue(for: .count()) ?? 0
results.append((date: statistics.startDate, steps: count))
}
continuation.resume(returning: results)
}
healthStore.execute(query)
}
}
}
HKObserverQuery — Background Delivery
extension HealthKitManager {
/// Enable background delivery for step count updates
func enableBackgroundStepDelivery() async throws {
let stepType = HKQuantityType(.stepCount)
try await healthStore.enableBackgroundDelivery(
for: stepType,
frequency: .hourly
)
}
/// Observe step count changes in real time
func observeStepChanges(handler: @escaping (Double) -> Void) {
let stepType = HKQuantityType(.stepCount)
let observerQuery = HKObserverQuery(
sampleType: stepType,
predicate: nil
) { [weak self] _, completionHandler, error in
guard error == nil, let self else {
completionHandler()
return
}
Task {
let steps = try? await self.fetchTodayStepCount()
await MainActor.run {
handler(steps ?? 0)
}
completionHandler()
}
}
healthStore.execute(observerQuery)
}
}
HKWorkoutSession and HKLiveWorkoutBuilder
import HealthKit
@Observable
class WorkoutManager: NSObject {
let healthStore = HKHealthStore()
var workoutSession: HKWorkoutSession?
var workoutBuilder: HKLiveWorkoutBuilder?
var isWorkoutActive = false
var heartRate: Double = 0
var activeCalories: Double = 0
var distance: Double = 0
var elapsedTime: TimeInterval = 0
func startWorkout(type: HKWorkoutActivityType) async throws {
let configuration = HKWorkoutConfiguration()
configuration.activityType = type
configuration.locationType = .outdoor
workoutSession = try HKWorkoutSession(
healthStore: healthStore,
configuration: configuration
)
workoutBuilder = workoutSession?.associatedWorkoutBuilder()
workoutBuilder?.dataSource = HKLiveWorkoutDataSource(
healthStore: healthStore,
workoutConfiguration: configuration
)
workoutSession?.delegate = self
workoutBuilder?.delegate = self
let startDate = Date.now
workoutSession?.startActivity(with: startDate)
try await workoutBuilder?.beginCollection(at: startDate)
isWorkoutActive = true
}
func pauseWorkout() {
workoutSession?.pause()
}
func resumeWorkout() {
workoutSession?.resume()
}
func endWorkout() async throws {
workoutSession?.end()
guard let builder = workoutBuilder else { return }
try await builder.endCollection(at: .now)
try await builder.finishWorkout()
isWorkoutActive = false
workoutSession = nil
workoutBuilder = nil
}
}
extension WorkoutManager: HKWorkoutSessionDelegate {
func workoutSession(
_ workoutSession: HKWorkoutSession,
didChangeTo toState: HKWorkoutSessionState,
from fromState: HKWorkoutSessionState,
date: Date
) {
switch toState {
case .running:
isWorkoutActive = true
case .paused:
isWorkoutActive = false
case .ended:
isWorkoutActive = false
default:
break
}
}
func workoutSession(
_ workoutSession: HKWorkoutSession,
didFailWithError error: Error
) {
isWorkoutActive = false
}
}
extension WorkoutManager: HKLiveWorkoutBuilderDelegate {
func workoutBuilderDidCollectEvent(_ workoutBuilder: HKLiveWorkoutBuilder) {}
func workoutBuilder(
_ workoutBuilder: HKLiveWorkoutBuilder,
didCollectDataOf collectedTypes: Set<HKSampleType>
) {
for type in collectedTypes {
guard let quantityType = type as? HKQuantityType else { continue }
let statistics = workoutBuilder.statistics(for: quantityType)
switch quantityType {
case HKQuantityType(.heartRate):
let bpmUnit = HKUnit.count().unitDivided(by: .minute())
heartRate = statistics?.mostRecentQuantity()?.doubleValue(for: bpmUnit) ?? 0
case HKQuantityType(.activeEnergyBurned):
activeCalories = statistics?.sumQuantity()?.doubleValue(for: .kilocalorie()) ?? 0
case HKQuantityType(.distanceWalkingRunning):
distance = statistics?.sumQuantity()?.doubleValue(for: .meter()) ?? 0
default:
break
}
}
elapsedTime = workoutBuilder.elapsedTime
}
}
Workout Routes with CoreLocation
import CoreLocation
extension WorkoutManager {
/// Add a route to a completed workout
func addRoute(locations: [CLLocation], to workout: HKWorkout) async throws {
let routeBuilder = HKWorkoutRouteBuilder(
healthStore: healthStore,
device: nil
)
try await routeBuilder.insertRouteData(locations)
try await routeBuilder.finishRoute(with: workout, metadata: nil)
}
}
Complete Step Counter and Workout Tracker
import SwiftUI
import HealthKit
@Observable
class StepCounterViewModel {
private let manager = HealthKitManager.shared
var todaySteps: Double = 0
var weeklySteps: [(date: Date, steps: Double)] = []
var goalProgress: Double = 0
var isLoading = false
var error: Error?
let dailyGoal: Double = 10_000
func loadData() async {
isLoading = true
defer { isLoading = false }
do {
try await manager.requestAuthorization()
todaySteps = try await manager.fetchTodayStepCount()
goalProgress = min(todaySteps / dailyGoal, 1.0)
weeklySteps = try await manager.fetchDailySteps(days: 7)
} catch {
self.error = error
}
}
}
struct StepCounterView: View {
@State private var viewModel = StepCounterViewModel()
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 24) {
// Circular progress
ZStack {
Circle()
.stroke(.gray.opacity(0.2), lineWidth: 16)
Circle()
.trim(from: 0, to: viewModel.goalProgress)
.stroke(
.green.gradient,
style: StrokeStyle(lineWidth: 16, lineCap: .round)
)
.rotationEffect(.degrees(-90))
.animation(.spring(duration: 1.0), value: viewModel.goalProgress)
VStack(spacing: 4) {
Image(systemName: "figure.walk")
.font(.title)
.foregroundStyle(.green)
Text("\(Int(viewModel.todaySteps))")
.font(.system(size: 44, weight: .bold, design: .rounded))
.contentTransition(.numericText())
Text("of \(Int(viewModel.dailyGoal)) steps")
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
.frame(width: 220, height: 220)
.padding(.top)
// Weekly chart
VStack(alignment: .leading, spacing: 12) {
Text("This Week")
.font(.headline)
HStack(alignment: .bottom, spacing: 8) {
ForEach(viewModel.weeklySteps, id: \.date) { entry in
VStack(spacing: 4) {
RoundedRectangle(cornerRadius: 6)
.fill(
entry.steps >= viewModel.dailyGoal
? .green.gradient
: .blue.gradient
)
.frame(
height: max(
4,
CGFloat(entry.steps / viewModel.dailyGoal) * 120
)
)
Text(entry.date.formatted(.dateTime.weekday(.narrow)))
.font(.caption2)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
}
}
.frame(height: 140)
}
.padding()
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16))
.padding(.horizontal)
}
}
.navigationTitle("Steps")
.task {
await viewModel.loadData()
}
.refreshable {
await viewModel.loadData()
}
}
}
}
struct WorkoutTrackerView: View {
@State private var workoutManager = WorkoutManager()
var body: some View {
NavigationStack {
VStack(spacing: 24) {
if workoutManager.isWorkoutActive {
// Active workout display
VStack(spacing: 20) {
// Timer
Text(
Duration.seconds(workoutManager.elapsedTime)
.formatted(.time(pattern: .hourMinuteSecond))
)
.font(.system(size: 48, weight: .bold, design: .monospaced))
// Metrics grid
LazyVGrid(columns: [
GridItem(.flexible()),
GridItem(.flexible())
], spacing: 16) {
MetricCard(
title: "Heart Rate",
value: "\(Int(workoutManager.heartRate))",
unit: "BPM",
icon: "heart.fill",
color: .red
)
MetricCard(
title: "Calories",
value: "\(Int(workoutManager.activeCalories))",
unit: "kcal",
icon: "flame.fill",
color: .orange
)
MetricCard(
title: "Distance",
value: String(format: "%.2f", workoutManager.distance / 1000),
unit: "km",
icon: "figure.run",
color: .green
)
}
.padding(.horizontal)
// Controls
HStack(spacing: 24) {
Button {
workoutManager.pauseWorkout()
} label: {
Image(systemName: "pause.fill")
.font(.title2)
.frame(width: 60, height: 60)
.background(.yellow.gradient, in: Circle())
.foregroundStyle(.black)
}
Button {
Task { try? await workoutManager.endWorkout() }
} label: {
Image(systemName: "stop.fill")
.font(.title2)
.frame(width: 60, height: 60)
.background(.red.gradient, in: Circle())
.foregroundStyle(.white)
}
}
}
} else {
// Workout type selection
VStack(spacing: 16) {
Text("Start a Workout")
.font(.title2.bold())
ForEach(workoutTypes, id: \.type) { workout in
Button {
Task {
try? await workoutManager.startWorkout(type: workout.type)
}
} label: {
HStack {
Image(systemName: workout.icon)
.font(.title2)
.frame(width: 44)
Text(workout.name)
.font(.headline)
Spacer()
Image(systemName: "chevron.right")
.foregroundStyle(.tertiary)
}
.padding()
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
}
.buttonStyle(.plain)
}
}
.padding()
}
}
.navigationTitle("Workout")
}
}
var workoutTypes: [(name: String, type: HKWorkoutActivityType, icon: String)] {
[
("Outdoor Run", .running, "figure.run"),
("Outdoor Walk", .walking, "figure.walk"),
("Cycling", .cycling, "figure.outdoor.cycle"),
("Swimming", .swimming, "figure.pool.swim"),
("Strength Training", .traditionalStrengthTraining, "dumbbell.fill")
]
}
}
struct MetricCard: View {
let title: String
let value: String
let unit: String
let icon: String
let color: Color
var body: some View {
VStack(spacing: 8) {
Image(systemName: icon)
.foregroundStyle(color)
.font(.title3)
Text(value)
.font(.system(.title, design: .rounded, weight: .bold))
.contentTransition(.numericText())
Text(unit)
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
.padding()
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16))
}
}
---
# HomeKit and Matter
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-hardware-homekit.html
← Back to all frameworks & guides
All articles →System Integration · Reference guideHomeKit and MatterRepository guidance for HomeKit. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
HMHomeManager Setup
Homes, Rooms, and Zones
Accessories and Services
Characteristics — Reading, Writing, Subscribing
Automations and Triggers
Action Sets and Scenes
Matter Support
Complete Smart Home Controller Example
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Request home access
↓2
Discover accessories
↓3
Read or write characteristics
↓4
Handle unreachable devices
02 / ArchitectureResponsibility boundariesBoundary 1
Home managerBoundary 2
Accessories and servicesBoundary 3
Home controlsConnected responsibilities, not a required class hierarchy or an execution trace.
HMHomeManager Setup
Add the HomeKit capability in Xcode under Signing & Capabilities. Add
NSHomeKitUsageDescription
to
Info.plist
.
import HomeKit
@Observable
class HomeManager: NSObject, HMHomeManagerDelegate {
static let shared = HomeManager()
let homeManager = HMHomeManager()
var homes: [HMHome] = []
var primaryHome: HMHome?
var isReady = false
var error: Error?
override init() {
super.init()
homeManager.delegate = self
}
func homeManagerDidUpdateHomes(_ manager: HMHomeManager) {
homes = manager.homes
primaryHome = manager.primaryHome
isReady = true
}
func homeManagerDidUpdatePrimaryHome(_ manager: HMHomeManager) {
primaryHome = manager.primaryHome
}
}
Homes, Rooms, and Zones
extension HomeManager {
// MARK: - Homes
func addHome(named name: String) async throws -> HMHome {
try await withCheckedThrowingContinuation { continuation in
homeManager.addHome(withName: name) { home, error in
if let error {
continuation.resume(throwing: error)
} else if let home {
continuation.resume(returning: home)
}
}
}
}
func removeHome(_ home: HMHome) async throws {
try await withCheckedThrowingContinuation { continuation in
homeManager.removeHome(home) { error in
if let error {
continuation.resume(throwing: error)
} else {
continuation.resume()
}
}
}
}
// MARK: - Rooms
func addRoom(named name: String, to home: HMHome) async throws -> HMRoom {
try await withCheckedThrowingContinuation { continuation in
home.addRoom(withName: name) { room, error in
if let error {
continuation.resume(throwing: error)
} else if let room {
continuation.resume(returning: room)
}
}
}
}
// MARK: - Zones
func addZone(named name: String, to home: HMHome) async throws -> HMZone {
try await withCheckedThrowingContinuation { continuation in
home.addZone(withName: name) { zone, error in
if let error {
continuation.resume(throwing: error)
} else if let zone {
continuation.resume(returning: zone)
}
}
}
}
func addRoom(_ room: HMRoom, to zone: HMZone) async throws {
try await withCheckedThrowingContinuation { continuation in
zone.addRoom(room) { error in
if let error {
continuation.resume(throwing: error)
} else {
continuation.resume()
}
}
}
}
}
Accessories and Services
@Observable
class AccessoryManager: NSObject, HMHomeDelegate {
var accessories: [HMAccessory] = []
var home: HMHome
init(home: HMHome) {
self.home = home
super.init()
home.delegate = self
accessories = home.accessories
}
// Add accessory via setup code
func addAccessory() async throws {
// This presents the system UI for adding accessories
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
home.addAndSetupAccessories { error in
if let error {
continuation.resume(throwing: error)
} else {
continuation.resume()
}
}
}
accessories = home.accessories
}
// Remove an accessory
func removeAccessory(_ accessory: HMAccessory) async throws {
try await withCheckedThrowingContinuation { continuation in
home.removeAccessory(accessory) { error in
if let error {
continuation.resume(throwing: error)
} else {
continuation.resume()
}
}
}
accessories = home.accessories
}
// Assign accessory to a room
func assignAccessory(_ accessory: HMAccessory, to room: HMRoom) async throws {
try await withCheckedThrowingContinuation { continuation in
home.assignAccessory(accessory, to: room) { error in
if let error {
continuation.resume(throwing: error)
} else {
continuation.resume()
}
}
}
}
// Get services for an accessory
func services(for accessory: HMAccessory) -> [HMService] {
accessory.services.filter { service in
// Filter out information services
service.serviceType != HMServiceTypeAccessoryInformation
}
}
// HMHomeDelegate — accessory updates
func home(_ home: HMHome, didAdd accessory: HMAccessory) {
accessories = home.accessories
}
func home(_ home: HMHome, didRemove accessory: HMAccessory) {
accessories = home.accessories
}
func home(
_ home: HMHome,
didUpdate room: HMRoom,
for accessory: HMAccessory
) {
// Accessory moved to a different room
}
}
Characteristics — Reading, Writing, Subscribing
extension AccessoryManager {
// Read a characteristic value
func readCharacteristic(_ characteristic: HMCharacteristic) async throws -> Any? {
try await withCheckedThrowingContinuation { continuation in
characteristic.readValue { error in
if let error {
continuation.resume(throwing: error)
} else {
continuation.resume(returning: characteristic.value)
}
}
}
}
// Write a characteristic value
func writeCharacteristic(_ characteristic: HMCharacteristic, value: Any) async throws {
try await withCheckedThrowingContinuation { continuation in
characteristic.writeValue(value) { error in
if let error {
continuation.resume(throwing: error)
} else {
continuation.resume()
}
}
}
}
// Subscribe to characteristic changes
func subscribeToCharacteristic(_ characteristic: HMCharacteristic) async throws {
try await withCheckedThrowingContinuation { continuation in
characteristic.enableNotification(true) { error in
if let error {
continuation.resume(throwing: error)
} else {
continuation.resume()
}
}
}
}
// Common operations
func toggleLight(_ service: HMService) async throws {
guard let powerState = service.characteristics.first(where: {
$0.characteristicType == HMCharacteristicTypePowerState
}) else { return }
let currentValue = powerState.value as? Bool ?? false
try await writeCharacteristic(powerState, value: !currentValue)
}
func setBrightness(_ service: HMService, to value: Int) async throws {
guard let brightness = service.characteristics.first(where: {
$0.characteristicType == HMCharacteristicTypeBrightness
}) else { return }
let clamped = max(0, min(100, value))
try await writeCharacteristic(brightness, value: clamped)
}
func setThermostat(_ service: HMService, targetTemp: Double) async throws {
guard let targetTemperature = service.characteristics.first(where: {
$0.characteristicType == HMCharacteristicTypeTargetTemperature
}) else { return }
try await writeCharacteristic(targetTemperature, value: targetTemp)
}
func getLockState(_ service: HMService) async throws -> Bool {
guard let lockState = service.characteristics.first(where: {
$0.characteristicType == HMCharacteristicTypeCurrentLockMechanismState
}) else { return false }
let value = try await readCharacteristic(lockState)
// 0 = unsecured, 1 = secured
return (value as? Int) == 1
}
}
Automations and Triggers
extension HomeManager {
// MARK: - Timer Trigger (time-based automation)
func createTimerTrigger(
name: String,
fireDate: DateComponents,
recurrence: DateComponents? = nil,
actionSet: HMActionSet,
in home: HMHome
) async throws {
let trigger = HMTimerTrigger(
name: name,
fireDate: fireDate,
timeZone: .current,
recurrence: recurrence,
recurrenceCalendar: .current
)
try await withCheckedThrowingContinuation { continuation in
home.addTrigger(trigger) { error in
if let error {
continuation.resume(throwing: error)
} else {
continuation.resume()
}
}
}
// Add action set to trigger
try await withCheckedThrowingContinuation { continuation in
trigger.addActionSet(actionSet) { error in
if let error {
continuation.resume(throwing: error)
} else {
continuation.resume()
}
}
}
// Enable the trigger
try await withCheckedThrowingContinuation { continuation in
trigger.enable(true) { error in
if let error {
continuation.resume(throwing: error)
} else {
continuation.resume()
}
}
}
}
// MARK: - Event Trigger (condition-based automation)
func createSunsetTrigger(
name: String,
offset: TimeInterval = 0,
actionSet: HMActionSet,
in home: HMHome
) async throws {
let sunsetEvent = HMSignificantTimeEvent(
significantEvent: .sunset,
offset: DateComponents(second: Int(offset))
)
let trigger = HMEventTrigger(
name: name,
events: [sunsetEvent],
predicate: nil
)
try await withCheckedThrowingContinuation { continuation in
home.addTrigger(trigger) { error in
if let error {
continuation.resume(throwing: error)
} else {
continuation.resume()
}
}
}
try await withCheckedThrowingContinuation { continuation in
trigger.addActionSet(actionSet) { error in
if let error {
continuation.resume(throwing: error)
} else {
continuation.resume()
}
}
}
}
// Create a characteristic event trigger (when a value changes)
func createCharacteristicTrigger(
name: String,
characteristic: HMCharacteristic,
targetValue: Any,
actionSet: HMActionSet,
in home: HMHome
) async throws {
let event = HMCharacteristicEvent(
characteristic: characteristic,
triggerValue: targetValue as? NSCopying
)
let trigger = HMEventTrigger(
name: name,
events: [event],
predicate: nil
)
try await withCheckedThrowingContinuation { continuation in
home.addTrigger(trigger) { error in
if let error {
continuation.resume(throwing: error)
} else {
continuation.resume()
}
}
}
try await withCheckedThrowingContinuation { continuation in
trigger.addActionSet(actionSet) { error in
if let error {
continuation.resume(throwing: error)
} else {
continuation.resume()
}
}
}
}
}
Action Sets and Scenes
extension HomeManager {
// Create an action set (scene)
func createScene(
named name: String,
actions: [(characteristic: HMCharacteristic, value: Any)],
in home: HMHome
) async throws -> HMActionSet {
let actionSet = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<HMActionSet, Error>) in
home.addActionSet(withName: name) { actionSet, error in
if let error {
continuation.resume(throwing: error)
} else if let actionSet {
continuation.resume(returning: actionSet)
}
}
}
// Add actions to the set
for action in actions {
let writeAction = HMCharacteristicWriteAction(
characteristic: action.characteristic,
targetValue: action.value as! NSCopying
)
try await withCheckedThrowingContinuation { continuation in
actionSet.addAction(writeAction) { error in
if let error {
continuation.resume(throwing: error)
} else {
continuation.resume()
}
}
}
}
return actionSet
}
// Execute a scene
func executeScene(_ actionSet: HMActionSet, in home: HMHome) async throws {
try await withCheckedThrowingContinuation { continuation in
home.executeActionSet(actionSet) { error in
if let error {
continuation.resume(throwing: error)
} else {
continuation.resume()
}
}
}
}
// Get built-in action sets
func builtInScenes(for home: HMHome) -> (
homeArrive: HMActionSet?,
homeLeave: HMActionSet?,
sleep: HMActionSet?,
wake: HMActionSet?
) {
let sets = home.actionSets
return (
homeArrive: sets.first { $0.actionSetType == HMActionSetTypeHomeArrival },
homeLeave: sets.first { $0.actionSetType == HMActionSetTypHomeDeparture },
sleep: sets.first { $0.actionSetType == HMActionSetTypeSleep },
wake: sets.first { $0.actionSetType == HMActionSetTypeWakeUp }
)
}
}
Matter Support
Requires iOS 16.1+. Add the Matter capability in Xcode.
import MatterSupport
@Observable
class MatterManager {
/// Request to add a Matter device to the home
func addMatterDevice() async throws {
let topology = MatterAddDeviceRequest.Topology(
ecosystemName: "My Home App",
homes: [
MatterAddDeviceRequest.Home(displayName: "My Home")
]
)
let request = MatterAddDeviceRequest(topology: topology)
// This presents the system Matter pairing UI
try await request.perform()
}
}
// Handle Matter setup in your App struct
extension MatterManager {
/// Commission a device with a setup code
func addDeviceWithSetupCode() async throws {
let topology = MatterAddDeviceRequest.Topology(
ecosystemName: "My Home App",
homes: [
MatterAddDeviceRequest.Home(displayName: "My Home")
]
)
let request = MatterAddDeviceRequest(
topology: topology,
setupPayload: nil // System will prompt user to scan QR or enter code
)
try await request.perform()
}
}
Complete Smart Home Controller Example
import SwiftUI
import HomeKit
struct SmartHomeView: View {
@State private var homeManager = HomeManager.shared
@State private var selectedHome: HMHome?
@State private var showAddAccessory = false
var body: some View {
NavigationStack {
Group {
if homeManager.isReady, let home = selectedHome ?? homeManager.primaryHome {
HomeDetailView(home: home)
} else if !homeManager.isReady {
ProgressView("Loading homes...")
} else {
ContentUnavailableView(
"No Home",
systemImage: "house",
description: Text("Add a home to get started.")
)
}
}
.navigationTitle("My Home")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Menu {
ForEach(homeManager.homes, id: \.uniqueIdentifier) { home in
Button(home.name) {
selectedHome = home
}
}
Divider()
Button("Add Home", systemImage: "plus") {
Task {
_ = try? await homeManager.addHome(named: "New Home")
}
}
} label: {
Image(systemName: "house.fill")
}
}
}
}
}
}
struct HomeDetailView: View {
let home: HMHome
@State private var accessoryManager: AccessoryManager?
var body: some View {
ScrollView {
LazyVStack(spacing: 16) {
// Rooms section
ForEach(home.rooms, id: \.uniqueIdentifier) { room in
RoomCard(room: room, accessoryManager: accessoryManager)
}
// Scenes section
if !home.actionSets.isEmpty {
VStack(alignment: .leading, spacing: 12) {
Text("Scenes")
.font(.headline)
.padding(.horizontal)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 12) {
ForEach(home.actionSets, id: \.uniqueIdentifier) { scene in
SceneButton(
scene: scene,
home: home
)
}
}
.padding(.horizontal)
}
}
}
}
.padding(.vertical)
}
.onAppear {
accessoryManager = AccessoryManager(home: home)
}
}
}
struct RoomCard: View {
let room: HMRoom
let accessoryManager: AccessoryManager?
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text(room.name)
.font(.headline)
let roomAccessories = room.accessories
if roomAccessories.isEmpty {
Text("No accessories")
.font(.caption)
.foregroundStyle(.secondary)
} else {
LazyVGrid(columns: [
GridItem(.flexible()),
GridItem(.flexible())
], spacing: 10) {
ForEach(roomAccessories, id: \.uniqueIdentifier) { accessory in
AccessoryTile(
accessory: accessory,
accessoryManager: accessoryManager
)
}
}
}
}
.padding()
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16))
.padding(.horizontal)
}
}
struct AccessoryTile: View {
let accessory: HMAccessory
let accessoryManager: AccessoryManager?
@State private var isOn = false
var body: some View {
Button {
toggleAccessory()
} label: {
VStack(spacing: 8) {
Image(systemName: iconForAccessory)
.font(.title2)
.foregroundStyle(isOn ? .yellow : .secondary)
Text(accessory.name)
.font(.caption)
.lineLimit(1)
.foregroundStyle(.primary)
Text(isOn ? "On" : "Off")
.font(.caption2)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
.padding()
.background(
isOn ? Color.yellow.opacity(0.15) : Color.gray.opacity(0.1),
in: RoundedRectangle(cornerRadius: 12)
)
}
.buttonStyle(.plain)
.task { await loadState() }
}
var iconForAccessory: String {
for service in accessory.services {
switch service.serviceType {
case HMServiceTypeLightbulb: return "lightbulb.fill"
case HMServiceTypeFan: return "fan.fill"
case HMServiceTypeThermostat: return "thermometer"
case HMServiceTypeLockMechanism: return "lock.fill"
case HMServiceTypeGarageDoorOpener: return "door.garage.closed"
case HMServiceTypeSwitch: return "switch.2"
default: continue
}
}
return "house.fill"
}
func loadState() async {
for service in accessory.services {
if let powerChar = service.characteristics.first(where: {
$0.characteristicType == HMCharacteristicTypePowerState
}) {
let value = try? await accessoryManager?.readCharacteristic(powerChar)
isOn = value as? Bool ?? false
return
}
}
}
func toggleAccessory() {
for service in accessory.services {
if let powerChar = service.characteristics.first(where: {
$0.characteristicType == HMCharacteristicTypePowerState
}) {
Task {
try? await accessoryManager?.writeCharacteristic(powerChar, value: !isOn)
isOn.toggle()
}
return
}
}
}
}
struct SceneButton: View {
let scene: HMActionSet
let home: HMHome
var body: some View {
Button {
Task {
try? await HomeManager.shared.executeScene(scene, in: home)
}
} label: {
VStack(spacing: 8) {
Image(systemName: iconForScene)
.font(.title2)
.foregroundStyle(.blue)
Text(scene.name)
.font(.caption)
.foregroundStyle(.primary)
}
.frame(width: 80, height: 80)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16))
}
.buttonStyle(.plain)
}
var iconForScene: String {
switch scene.actionSetType {
case HMActionSetTypeHomeArrival: return "house.fill"
case HMActionSetTypHomeDeparture: return "figure.walk"
case HMActionSetTypeSleep: return "moon.fill"
case HMActionSetTypeWakeUp: return "sun.max.fill"
default: return "sparkles"
}
}
}
---
# LocalAuthentication
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-local-authentication.html
← Back to all frameworks & guides
All articles →Authentication, Security, and Privacy · Reference guideLocalAuthenticationRepository guidance for LocalAuthentication. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
1. The one thing to understand before writing any of this
2. The seam
3. The implementation
4. The view model
5. The real boundary: Keychain access control
6. Info.plist
Anti-Patterns
Testing
Checklist
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Check available policy
↓2
Explain authentication reason
↓3
Evaluate local authentication
↓4
Handle cancellation or fallback
02 / ArchitectureResponsibility boundariesBoundary 1
Protected actionBoundary 2
Authentication contextBoundary 3
Authorization resultConnected responsibilities, not a required class hierarchy or an execution trace.
Load this when:
gating a screen or an action behind Face ID, Touch ID, or
Optic ID; unlocking a value from the Keychain with biometrics; or deciding what
to do when biometry is unavailable, locked out, or refused.
Covers
LAContext
, the policies, the error cases that actually occur, and the
line between LocalAuthentication and Keychain access control.
Availability:
framework iOS 8+;
.biometryAny
/
.biometryCurrentSet
Keychain flags iOS 11.3+; Optic ID visionOS 1+;
LAContext
is
not
Sendable
.
1. The one thing to understand before writing any of this
evaluatePolicy returning true is not a security boundary. It is a UI
event.
It tells you the system displayed a prompt and the user satisfied it. It does
not protect anything. An attacker with your binary can patch the branch, and a
jailbroken device can lie about the result. If the only thing standing between
someone and the data is
if success { showSecrets() }
, the data is not
protected — it is merely inconvenient to reach.
The real boundary is the
Keychain
, with a
SecAccessControl
that names
biometry. There, the Secure Enclave refuses to release the bytes until biometry
succeeds; there is no branch to patch, because the decryption key is never
handed over. See §5.
Use
evaluatePolicy
when the goal is
presence
— "confirm it's you before
transferring money", "re-authenticate after backgrounding". Use Keychain access
control when the goal is
secrecy
.
A second fact worth knowing up front:
LAContext
caches a successful
evaluation for the lifetime of the context object. Reusing one context across
screens means the second screen silently unlocks with no prompt. That is
occasionally what you want (
touchIDAuthenticationAllowableReuseDuration
) and
usually a bug.
One context per authentication.
2. The seam
Biometrics is a dependency like any other. Behind a protocol it is injectable,
so a
#Preview
and a unit test never trigger a real prompt — which they cannot
do headlessly anyway, so a design that skips the seam is a design with no tests.
import Foundation
import LocalAuthentication
/// What the app actually needs. Not "an LAContext".
public protocol BiometricAuthenticating: Sendable {
/// What the device offers right now, including why it cannot be used.
func availability() -> BiometricAvailability
/// Prompts, and returns only on success. Throws `BiometricError` otherwise.
func authenticate(reason: String) async throws
}
public enum Biometry: Equatable, Sendable {
case none, touchID, faceID, opticID
}
public enum BiometricAvailability: Equatable, Sendable {
case available(Biometry)
/// Hardware exists, but the user has not enrolled a face or finger.
case notEnrolled(Biometry)
/// Too many failures. Only a device passcode clears this.
case lockedOut(Biometry)
/// No biometric hardware, or the user disabled it for this app.
case unavailable
}
public enum BiometricError: LocalizedError, Equatable {
case cancelledByUser
case cancelledBySystem
case fellBackToPasscode
case notEnrolled
case lockedOut
case notAvailable
case failed
public var errorDescription: String? {
switch self {
case .cancelledByUser, .cancelledBySystem, .fellBackToPasscode:
// Not failures. See the anti-patterns.
nil
case .notEnrolled:
String(localized: "Set up Face ID in Settings to unlock this way.")
case .lockedOut:
String(localized: "Face ID is locked. Enter your device passcode to re-enable it.")
case .notAvailable:
String(localized: "This device can't use biometric unlock.")
case .failed:
String(localized: "Couldn't verify it's you. Try again.")
}
}
}
3. The implementation
import LocalAuthentication
/// `LAContext` is not `Sendable` and a context must not be reused across
/// authentications, so this type creates one per call and never stores it.
/// That is what makes the actor safe *and* correct at the same time.
public actor BiometricAuthenticator: BiometricAuthenticating {
/// `.deviceOwnerAuthenticationWithBiometrics` — biometry only, no passcode
/// fallback. Use when the whole point is "prove it's this person".
///
/// `.deviceOwnerAuthentication` — biometry, then passcode. Use when the
/// point is "prove it's the device owner", which is almost every case that
/// is not a payment confirmation.
private let policy: LAPolicy
public init(policy: LAPolicy = .deviceOwnerAuthentication) {
self.policy = policy
}
public nonisolated func availability() -> BiometricAvailability {
let context = LAContext()
var error: NSError?
// canEvaluatePolicy must be called before biometryType is meaningful.
// Read in the other order it reports .none on a perfectly good device.
let canEvaluate = context.canEvaluatePolicy(
.deviceOwnerAuthenticationWithBiometrics,
error: &error
)
let biometry: Biometry = switch context.biometryType {
case .faceID: .faceID
case .touchID: .touchID
case .opticID: .opticID
default: .none
}
if canEvaluate { return .available(biometry) }
return switch LAError.Code(rawValue: error?.code ?? -1) {
case .biometryNotEnrolled: .notEnrolled(biometry)
case .biometryLockout: .lockedOut(biometry)
default: .unavailable
}
}
public func authenticate(reason: String) async throws {
let context = LAContext()
// Shown beneath the prompt on Touch ID, and in the passcode sheet.
// It must say what happens, not what the app is: "Unlock your vault",
// never "Authenticate with Face ID". The user can see it is Face ID.
context.localizedFallbackTitle = String(localized: "Use Passcode")
do {
// The throwing async overload. The completion-handler form calls
// back on an arbitrary queue, which is where "UI updated from a
// background thread" crashes come from.
try await context.evaluatePolicy(policy, localizedReason: reason)
} catch let error as LAError {
throw Self.map(error)
}
}
private static func map(_ error: LAError) -> BiometricError {
switch error.code {
case .userCancel: .cancelledByUser
case .systemCancel, .appCancel: .cancelledBySystem
case .userFallback: .fellBackToPasscode
case .biometryNotEnrolled: .notEnrolled
case .biometryLockout: .lockedOut
case .biometryNotAvailable, .passcodeNotSet: .notAvailable
default: .failed
}
}
}
On localizedReason:
it is user-facing, on screen, in the system prompt.
It must be localized, and it must complete the sentence
" is trying to
…"
. "Unlock your saved cards" is right. "Authenticate" is not a sentence.
4. The view model
import Observation
@MainActor
@Observable
public final class VaultLockModel {
public private(set) var isUnlocked = false
public private(set) var isAuthenticating = false
public var errorMessage: String?
/// Drives the button label and the empty state — a screen that says
/// "Unlock with Face ID" on a device with no Face ID is a dead end.
public private(set) var availability: BiometricAvailability = .unavailable
private let authenticator: any BiometricAuthenticating
public init(authenticator: any BiometricAuthenticating) {
self.authenticator = authenticator
}
public func refreshAvailability() {
availability = authenticator.availability()
}
public func unlock() async {
isAuthenticating = true
defer { isAuthenticating = false }
do {
try await authenticator.authenticate(
reason: String(localized: "Unlock your saved cards")
)
isUnlocked = true
errorMessage = nil
} catch let error as BiometricError {
// A cancel is a decision, not a failure. `errorDescription` is nil
// for those cases, so this assignment is the whole policy.
errorMessage = error.errorDescription
} catch {
errorMessage = String(localized: "Couldn't verify it's you. Try again.")
}
}
/// Call from `.onChange(of: scenePhase)`. Leaving a vault unlocked across
/// backgrounding defeats the lock — the next person to pick up the phone is
/// already inside.
public func lock() {
isUnlocked = false
}
}
5. The real boundary: Keychain access control
When the requirement is that data stays unreadable without biometry, the check
belongs in the Keychain, not in an
if
.
import Foundation
import LocalAuthentication
import Security
public enum BiometricKeychain {
/// `.biometryCurrentSet` invalidates the item when a face or fingerprint is
/// added or removed. `.biometryAny` survives enrolment changes — which means
/// someone who can add their own face to an unlocked device inherits access
/// to the secret. For anything worth protecting, use `.biometryCurrentSet`
/// and accept that re-enrolment forces the user to sign in again.
public static func store(_ secret: Data, account: String) throws {
var error: Unmanaged<CFError>?
guard let access = SecAccessControlCreateWithFlags(
nil,
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
.biometryCurrentSet,
&error
) else {
throw error!.takeRetainedValue() as Error
}
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: account,
kSecValueData as String: secret,
kSecAttrAccessControl as String: access
]
SecItemDelete(query as CFDictionary) // no duplicates on re-store
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else { throw KeychainError.status(status) }
}
/// The biometric prompt happens *inside* SecItemCopyMatching. There is no
/// branch to patch: without a successful evaluation the Secure Enclave
/// never releases the bytes.
public static func load(account: String, reason: String) throws -> Data {
let context = LAContext()
context.localizedReason = reason
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: account,
kSecReturnData as String: true,
kSecUseAuthenticationContext as String: context
]
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
guard status == errSecSuccess, let data = item as? Data else {
throw KeychainError.status(status)
}
return data
}
}
public enum KeychainError: LocalizedError {
case status(OSStatus)
public var errorDescription: String? {
switch self {
case .status(errSecUserCanceled): nil // a decision, not a failure
case .status: String(localized: "Couldn't unlock your saved data.")
}
}
}
SecItemCopyMatching
with an access control
blocks
— it does not return
until the user has responded to the biometric prompt. Call it from an actor:
public actor SecureCardStore {
public init() {}
public func loadCards(reason: String) throws -> Data {
try BiometricKeychain.load(account: "cards", reason: reason)
}
}
Never call it inside a
@MainActor
type's body, and never reach for
Task.detached
to escape — that drops isolation, priority, and task-locals to
solve a problem an actor already solves.
6. Info.plist
Face ID requires a purpose string. Without it the app
crashes
the first time
it prompts — not at launch, not in review necessarily, but on a user's device
the first time they tap Unlock.
<key>NSFaceIDUsageDescription</key>
<string>Unlock your saved cards without typing your password.</string>
Touch ID and device passcode need no key. Face ID does, and the crash it causes
is easy to miss because the simulator you tested on was set to Touch ID.
Anti-Patterns
// WRONG — a security theatre boundary.
// The branch is patchable and the data was never encrypted. This protects
// nothing; it only adds a prompt.
if try await context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: r) {
self.decryptedCards = loadCardsFromDisk()
}
// RIGHT — the key never leaves the Secure Enclave without biometry, and the
// blocking Keychain call runs on an actor rather than a detached task (which
// would drop isolation, priority, and task-locals for no benefit).
let secret = try await secureStore.loadCards(reason: r)
// WRONG — one context reused across the app.
// It caches the successful evaluation, so the second screen unlocks with no
// prompt at all. The user believes they re-authenticated. They did not.
final class Auth { static let context = LAContext() }
// RIGHT — a fresh LAContext per authentication.
let context = LAContext()
// WRONG — biometryType read before canEvaluatePolicy.
// It reports .none on a device with working Face ID, so the UI offers a
// password field to someone who has Face ID enrolled.
let type = LAContext().biometryType
// RIGHT — evaluate first; the type is only populated afterwards.
let context = LAContext()
_ = context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error)
let type = context.biometryType
// WRONG — a cancel rendered as a failure.
// The user tapped Cancel. Telling them "Authentication failed" implies
// something broke and trains them to distrust the prompt.
catch { errorMessage = "Authentication failed" }
// RIGHT — cancels and passcode fallbacks are outcomes, not errors.
catch let error as LAError where error.code == .userCancel { return }
// WRONG — .deviceOwnerAuthenticationWithBiometrics as the only path.
// A user with no enrolled biometry, or one who is locked out, can never get in.
// There is no passcode fallback in this policy — that is what it means.
try await context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: r)
// RIGHT — allow the passcode unless the requirement is specifically biometry.
try await context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: r)
// WRONG — .biometryAny on a secret worth protecting.
// The item survives enrolment changes, so adding a new face to an unlocked
// device grants that face access to the existing secret.
SecAccessControlCreateWithFlags(nil, accessible, .biometryAny, &error)
// RIGHT — invalidate when the enrolled set changes.
SecAccessControlCreateWithFlags(nil, accessible, .biometryCurrentSet, &error)
// WRONG — kSecAttrAccessibleWhenUnlocked on a device-bound secret.
// It migrates to a new device through an encrypted backup, which is exactly
// what a device-bound credential must not do.
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked
// RIGHT
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
// WRONG — LAContext stored on a @MainActor @Observable model.
// LAContext is not Sendable; this is a data race the moment evaluation moves
// off the main actor, and a Swift 6 error.
@MainActor @Observable final class LockModel { let context = LAContext() }
// RIGHT — the context lives inside the actor that uses it, created per call.
// WRONG — a completion handler updating UI directly.
// evaluatePolicy's completion runs on an arbitrary queue. This is the classic
// "UIView modified from a background thread" crash.
context.evaluatePolicy(policy, localizedReason: r) { success, _ in
self.isUnlocked = success
}
// RIGHT — the async overload, called from an isolated context.
try await context.evaluatePolicy(policy, localizedReason: r)
// WRONG — a hardcoded, untranslated reason string.
// It is displayed to the user, by the system, in their language everywhere
// except this sentence.
localizedReason: "Authenticate"
// RIGHT — localized, and a complete thought.
localizedReason: String(localized: "Unlock your saved cards")
// WRONG — unlocked state that survives backgrounding.
// The next person to pick up the phone is already past the lock screen.
// RIGHT — relock on scenePhase change.
.onChange(of: scenePhase) { _, phase in if phase != .active { model.lock() } }
// WRONG — biometrics as the only way in.
// Face ID fails with sunglasses, gloves defeat Touch ID, and lockout needs a
// passcode to clear. An app with no alternative path is an app the user is
// locked out of.
// RIGHT — always keep a password or passcode route to the same data.
Testing
Biometrics cannot be evaluated headlessly, so the protocol seam is not
optional — it is the only way any of this is testable.
public struct StubBiometrics: BiometricAuthenticating {
// Note the name: a stored `availability` property would collide with the
// protocol's `availability()` method — a redeclaration error, not a
// shadowing warning.
public var reportedAvailability: BiometricAvailability
public var error: BiometricError?
public init(
reporting availability: BiometricAvailability = .available(.faceID),
error: BiometricError? = nil
) {
self.reportedAvailability = availability
self.error = error
}
public func availability() -> BiometricAvailability { reportedAvailability }
public func authenticate(reason: String) async throws {
if let error { throw error }
}
}
Cover, at minimum: success;
.cancelledByUser
producing
no
error message;
.lockedOut
producing one that mentions the passcode; and
.notEnrolled
rendering a screen that does not offer Face ID.
On the simulator,
Features → Face ID → Enrolled
, then
Matching Face
or
Non-matching Face
. Simulator biometry is not a substitute for a device
pass — enrolment changes and lockout behave differently on real hardware.
Checklist
[ ]
NSFaceIDUsageDescription
present — its absence is a crash, not a warning
[ ] Secrets protected by Keychain access control, not by an
if
[ ]
.biometryCurrentSet
, not
.biometryAny
, for anything sensitive
[ ]
kSecAttrAccessibleWhenUnlockedThisDeviceOnly
for device-bound credentials
[ ] A fresh
LAContext
per authentication
[ ]
canEvaluatePolicy
called before reading
biometryType
[ ]
.deviceOwnerAuthentication
unless biometry-only is a stated requirement
[ ] Cancel and passcode-fallback produce no error UI
[ ] Lockout and not-enrolled produce distinct, actionable messages
[ ] A non-biometric route to the same data exists
[ ] State relocks on backgrounding
[ ]
LAContext
never stored on a
@MainActor @Observable
type
[ ]
localizedReason
is localized and completes "
is trying to …"
[ ] Behind a protocol, with a stub, so previews and tests never prompt
---
# MapKit
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-mapkit.html
← Back to all frameworks & guides
All articles →System Integration · Reference guideMapKitRepository guidance for MapKit. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
Map View in SwiftUI
Annotations
Overlays
MKLocalSearch for Place Search
MKDirections for Routing
MapCamera and MapCameraPosition
Look Around
iOS 18+ Additions
AnyMapContent
MapSelection
MapKit Improvements Summary
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Define map region
↓2
Load annotations or search
↓3
Select a destination
↓4
Present route or place detail
02 / ArchitectureResponsibility boundariesBoundary 1
Location dataBoundary 2
Map contentBoundary 3
Selection stateConnected responsibilities, not a required class hierarchy or an execution trace.
Map View in SwiftUI
import SwiftUI
import MapKit
// Basic map with position binding
struct BasicMapView: View {
@State private var position = MapCameraPosition.region(
MKCoordinateRegion(
center: CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194),
span: MKCoordinateSpan(latitudeDelta: 0.05, longitudeDelta: 0.05)
)
)
var body: some View {
Map(position: $position) {
// Map content goes here
}
.mapStyle(.standard(elevation: .realistic))
.mapControls {
MapUserLocationButton()
MapCompass()
MapScaleView()
MapPitchToggle()
}
}
}
// Map styles
// .standard(elevation: .realistic, pointsOfInterest: .including([.restaurant, .cafe]))
// .imagery(elevation: .realistic)
// .hybrid(elevation: .realistic)
Annotations
struct Place: Identifiable {
let id = UUID()
let name: String
let coordinate: CLLocationCoordinate2D
let category: String
}
struct AnnotatedMapView: View {
let places: [Place] = [
Place(name: "Ferry Building", coordinate: .init(latitude: 37.7956, longitude: -122.3933), category: "landmark"),
Place(name: "Golden Gate Park", coordinate: .init(latitude: 37.7694, longitude: -122.4862), category: "park"),
Place(name: "Chinatown", coordinate: .init(latitude: 37.7941, longitude: -122.4078), category: "neighborhood"),
]
@State private var selectedPlace: Place?
@State private var position = MapCameraPosition.automatic
var body: some View {
Map(position: $position, selection: $selectedPlace) {
ForEach(places) { place in
// Marker — system-styled pin
Marker(place.name, coordinate: place.coordinate)
.tint(colorForCategory(place.category))
.tag(place)
}
}
.onChange(of: selectedPlace) { _, newValue in
if let place = newValue {
print("Selected: \(place.name)")
}
}
}
func colorForCategory(_ category: String) -> Color {
switch category {
case "landmark": return .orange
case "park": return .green
case "neighborhood": return .purple
default: return .red
}
}
}
// Custom annotation with SwiftUI view
struct CustomAnnotationMap: View {
let places: [Place]
var body: some View {
Map {
ForEach(places) { place in
Annotation(place.name, coordinate: place.coordinate) {
VStack(spacing: 0) {
Image(systemName: "mappin.circle.fill")
.font(.title)
.foregroundStyle(.red)
Text(place.name)
.font(.caption2)
.padding(4)
.background(.ultraThinMaterial)
.cornerRadius(4)
}
}
}
}
}
}
Overlays
struct OverlayMapView: View {
let routeCoordinates: [CLLocationCoordinate2D] = [
CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194),
CLLocationCoordinate2D(latitude: 37.7849, longitude: -122.4094),
CLLocationCoordinate2D(latitude: 37.7949, longitude: -122.3994),
]
var body: some View {
Map {
// Circle overlay
MapCircle(center: CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194), radius: 500)
.foregroundStyle(.blue.opacity(0.2))
.stroke(.blue, lineWidth: 2)
// Polyline overlay (route)
MapPolyline(coordinates: routeCoordinates)
.stroke(.blue, lineWidth: 4)
// Polygon overlay (area)
MapPolygon(coordinates: [
CLLocationCoordinate2D(latitude: 37.77, longitude: -122.42),
CLLocationCoordinate2D(latitude: 37.78, longitude: -122.42),
CLLocationCoordinate2D(latitude: 37.78, longitude: -122.41),
CLLocationCoordinate2D(latitude: 37.77, longitude: -122.41),
])
.foregroundStyle(.green.opacity(0.15))
.stroke(.green, lineWidth: 2)
}
}
}
MKLocalSearch for Place Search
@Observable
class PlaceSearchManager {
var searchResults: [MKMapItem] = []
var isSearching = false
func search(query: String, region: MKCoordinateRegion) async {
isSearching = true
defer { isSearching = false }
let request = MKLocalSearch.Request()
request.naturalLanguageQuery = query
request.region = region
request.resultTypes = [.pointOfInterest, .address]
do {
let search = MKLocalSearch(request: request)
let response = try await search.start()
searchResults = response.mapItems
} catch {
searchResults = []
}
}
// Category-based search
func searchNearby(category: MKPointOfInterestCategory, region: MKCoordinateRegion) async {
let request = MKLocalPointsOfInterestRequest(center: region.center, radius: 1000)
request.pointOfInterestFilter = MKPointOfInterestFilter(including: [category])
do {
let search = MKLocalSearch(request: request)
let response = try await search.start()
searchResults = response.mapItems
} catch {
searchResults = []
}
}
}
// Usage in SwiftUI
struct SearchableMapView: View {
@State private var searchManager = PlaceSearchManager()
@State private var searchText = ""
@State private var position = MapCameraPosition.automatic
var body: some View {
Map(position: $position) {
ForEach(searchManager.searchResults, id: \.self) { item in
if let coordinate = item.placemark.coordinate as CLLocationCoordinate2D? {
Marker(item.name ?? "Unknown", coordinate: coordinate)
}
}
}
.searchable(text: $searchText)
.onSubmit(of: .search) {
Task {
await searchManager.search(
query: searchText,
region: MKCoordinateRegion(
center: CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194),
span: MKCoordinateSpan(latitudeDelta: 0.1, longitudeDelta: 0.1)
)
)
}
}
}
}
MKDirections for Routing
@Observable
class DirectionsManager {
var route: MKRoute?
var travelTime: TimeInterval = 0
var distance: CLLocationDistance = 0
func getDirections(
from source: CLLocationCoordinate2D,
to destination: CLLocationCoordinate2D,
transportType: MKDirectionsTransportType = .automobile
) async throws {
let request = MKDirections.Request()
request.source = MKMapItem(placemark: MKPlacemark(coordinate: source))
request.destination = MKMapItem(placemark: MKPlacemark(coordinate: destination))
request.transportType = transportType
request.requestsAlternateRoutes = true
let directions = MKDirections(request: request)
let response = try await directions.calculate()
if let primaryRoute = response.routes.first {
route = primaryRoute
travelTime = primaryRoute.expectedTravelTime
distance = primaryRoute.distance
}
}
func getETA(
from source: CLLocationCoordinate2D,
to destination: CLLocationCoordinate2D
) async throws -> TimeInterval {
let request = MKDirections.Request()
request.source = MKMapItem(placemark: MKPlacemark(coordinate: source))
request.destination = MKMapItem(placemark: MKPlacemark(coordinate: destination))
let directions = MKDirections(request: request)
let response = try await directions.calculateETA()
return response.expectedTravelTime
}
}
// Display route on map
struct RouteMapView: View {
@State private var directionsManager = DirectionsManager()
var body: some View {
Map {
if let route = directionsManager.route {
MapPolyline(route.polyline)
.stroke(.blue, lineWidth: 5)
}
}
.overlay(alignment: .bottom) {
if directionsManager.route != nil {
VStack {
Text("Distance: \(directionsManager.distance / 1000, specifier: "%.1f") km")
Text("ETA: \(Int(directionsManager.travelTime / 60)) min")
}
.padding()
.background(.ultraThinMaterial)
.cornerRadius(12)
.padding()
}
}
}
}
MapCamera and MapCameraPosition
struct CameraControlMap: View {
@State private var position: MapCameraPosition = .automatic
var body: some View {
VStack {
Map(position: $position)
HStack {
Button("SF") {
withAnimation {
position = .region(MKCoordinateRegion(
center: CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194),
span: MKCoordinateSpan(latitudeDelta: 0.05, longitudeDelta: 0.05)
))
}
}
Button("3D View") {
withAnimation {
position = .camera(MapCamera(
centerCoordinate: CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194),
distance: 1000,
heading: 45,
pitch: 60
))
}
}
Button("User Location") {
withAnimation {
position = .userLocation(fallback: .automatic)
}
}
}
.buttonStyle(.bordered)
}
}
}
Look Around
struct LookAroundPreview: View {
let coordinate: CLLocationCoordinate2D
@State private var lookAroundScene: MKLookAroundScene?
var body: some View {
Group {
if let scene = lookAroundScene {
LookAroundPreview(initialScene: scene)
.frame(height: 200)
.cornerRadius(12)
} else {
ContentUnavailableView("No Look Around available", systemImage: "eye.slash")
}
}
.task {
await fetchScene()
}
}
private func fetchScene() async {
let request = MKLookAroundSceneRequest(coordinate: coordinate)
lookAroundScene = try? await request.scene
}
}
iOS 18+ Additions
AnyMapContent
AnyMapContent
provides type-erased map content, enabling dynamic and conditional map building without complex generic constraints. This is useful when composing map content from heterogeneous sources or building map content conditionally at runtime.
import SwiftUI
import MapKit
struct DynamicMapView: View {
let places: [Place]
let routes: [MKRoute]
@State private var showOverlays = true
var body: some View {
Map {
// Use AnyMapContent for conditional content composition
ForEach(places) { place in
if place.category == "landmark" {
AnyMapContent(
Marker(place.name, coordinate: place.coordinate)
.tint(.orange)
)
} else {
AnyMapContent(
Annotation(place.name, coordinate: place.coordinate) {
Image(systemName: "mappin.circle.fill")
.foregroundStyle(.blue)
}
)
}
}
if showOverlays {
ForEach(routes, id: \.self) { route in
AnyMapContent(
MapPolyline(route.polyline)
.stroke(.blue, lineWidth: 4)
)
}
}
}
}
}
// Building map content dynamically from a heterogeneous collection
struct MapContentBuilder {
enum MapItem {
case marker(name: String, coordinate: CLLocationCoordinate2D, tint: Color)
case circle(center: CLLocationCoordinate2D, radius: Double)
case polyline(coordinates: [CLLocationCoordinate2D])
}
@MapContentBuilder
static func content(for items: [MapItem]) -> some MapContent {
ForEach(Array(items.enumerated()), id: \.offset) { _, item in
switch item {
case .marker(let name, let coordinate, let tint):
AnyMapContent(
Marker(name, coordinate: coordinate)
.tint(tint)
)
case .circle(let center, let radius):
AnyMapContent(
MapCircle(center: center, radius: radius)
.foregroundStyle(.blue.opacity(0.2))
.stroke(.blue, lineWidth: 2)
)
case .polyline(let coordinates):
AnyMapContent(
MapPolyline(coordinates: coordinates)
.stroke(.red, lineWidth: 3)
)
}
}
}
}
MapSelection
iOS 18 enhances map selection handling with
MapSelection
, enabling richer interaction with map features including automatic selection of points of interest, physical features (mountains, lakes), and custom annotations.
import SwiftUI
import MapKit
struct SelectableMapView: View {
@State private var selection: MapSelection<MKMapItem>?
var body: some View {
Map(selection: $selection) {
Marker("Apple Park", coordinate: CLLocationCoordinate2D(latitude: 37.3349, longitude: -122.0090))
.tag(MapSelection<MKMapItem>.tag(for: "apple-park"))
}
// Enable automatic selection of built-in map features
.mapFeatureSelectionAccessory(.automatic)
// React to selection changes
.onChange(of: selection) { _, newSelection in
if let selection = newSelection {
handleSelection(selection)
}
}
.sheet(item: $selection) { selectedItem in
// Present details for the selected map feature
MapItemDetailView(selection: selectedItem)
}
}
private func handleSelection(_ selection: MapSelection<MKMapItem>) {
// Handle different selection types
print("Map item selected")
}
}
// Detailed view for selected map features
struct MapItemDetailView: View {
let selection: MapSelection<MKMapItem>
var body: some View {
VStack {
Text("Selected Location")
.font(.headline)
// Display details about the selected feature
}
.padding()
.presentationDetents([.medium])
}
}
MapKit Improvements Summary
Additional iOS 18 MapKit enhancements:
Unified Maps URLs
:
MKMapItem.openMaps(with:launchOptions:)
now generates universal map links that work consistently across iOS, macOS, and the web, making it easier to share locations across platforms.
Improved .mapStyle() options
: Additional customization for map rendering styles including finer control over point-of-interest filtering and label density.
MapFeature selection
: Tap on built-in map features (parks, transit stops, businesses) to get details without custom annotation overlays via
.mapFeatureSelectionAccessory()
.
Performance
: MapKit rendering and tile-loading performance improvements for large numbers of annotations and overlays.
---
# Metal -- GPU Rendering, Compute, and Performance-Critical…
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-metal.html
← Back to all frameworks & guides
All articles →Graphics, 3D, and Games · Reference guideMetal -- GPU Rendering, Compute, and Performance-Critical GraphicsRepository guidance for Metal. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
Overview
1. Choosing Metal
2. Minimal MTKView Renderer
3. Minimal Shader File
4. Vertex Buffers
5. Uniforms and In-Flight Frames
6. Textures with MetalKit
7. Compute Kernels
8. Depth, Stencil, and Render State
9. ARKit Camera Frames and Metal
10. Swift Concurrency Boundaries
11. Debugging and Profiling
12. Common Pitfalls
13. Review Checklist
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Create GPU resources
↓2
Encode rendering or compute work
↓3
Submit command buffers
↓4
Profile and synchronize
02 / ArchitectureResponsibility boundariesBoundary 1
CPU preparationBoundary 2
GPU command pipelineBoundary 3
Output buffersConnected responsibilities, not a required class hierarchy or an execution trace.
Overview
Metal gives Swift and Objective-C apps direct access to Apple GPUs for rendering, compute, image processing, data-parallel work, and custom frame pipelines. Use Metal when high-level frameworks such as RealityKit, SceneKit, SpriteKit, Core Image, or SwiftUI cannot provide the performance, visual result, or GPU scheduling control the product needs.
Reach for Metal deliberately. It is powerful, but it makes the app responsible for pipeline state, memory lifetime, synchronization, shader compilation, frame pacing, and GPU debugging.
1. Choosing Metal
Need
Prefer
Common AR / 3D entities, physics, USDZ
RealityKit
Existing scene graph and .scnassets
SceneKit
Custom post-processing, compute, renderer ownership
Metal
Image filters without custom kernels
Core Image
Charts and UI drawing
SwiftUI / Core Graphics
Use
MetalKit
for app display setup. Apple documents
MTKView
as the Metal-aware view that owns the drawable setup, render-pass descriptor creation, optional depth/stencil textures, and delegate callbacks.
MTKView
is
@MainActor
, so create and configure it on the main actor.
2. Minimal
MTKView
Renderer
import Metal
import MetalKit
@MainActor
final class Renderer: NSObject, MTKViewDelegate {
private let device: any MTLDevice
private let commandQueue: any MTLCommandQueue
private let pipelineState: any MTLRenderPipelineState
init(view: MTKView) throws {
guard let device = MTLCreateSystemDefaultDevice(),
let commandQueue = device.makeCommandQueue() else {
throw RendererError.metalUnavailable
}
self.device = device
self.commandQueue = commandQueue
view.device = device
view.colorPixelFormat = .bgra8Unorm
view.depthStencilPixelFormat = .depth32Float
view.framebufferOnly = true
view.clearColor = MTLClearColor(red: 0.02, green: 0.03, blue: 0.05, alpha: 1)
let library = try device.makeDefaultLibrary(bundle: .main)
guard let vertexFunction = library.makeFunction(name: "vertex_main"),
let fragmentFunction = library.makeFunction(name: "fragment_main") else {
throw RendererError.missingShaderFunction
}
let descriptor = MTLRenderPipelineDescriptor()
descriptor.vertexFunction = vertexFunction
descriptor.fragmentFunction = fragmentFunction
descriptor.colorAttachments[0].pixelFormat = view.colorPixelFormat
descriptor.depthAttachmentPixelFormat = view.depthStencilPixelFormat
pipelineState = try device.makeRenderPipelineState(descriptor: descriptor)
super.init()
view.delegate = self
}
func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {}
func draw(in view: MTKView) {
guard let descriptor = view.currentRenderPassDescriptor,
let drawable = view.currentDrawable,
let commandBuffer = commandQueue.makeCommandBuffer(),
let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: descriptor) else {
return
}
encoder.setRenderPipelineState(pipelineState)
encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 3)
encoder.endEncoding()
commandBuffer.present(drawable)
commandBuffer.commit()
}
}
enum RendererError: Error {
case metalUnavailable
case missingShaderFunction
case bufferAllocationFailed
}
SwiftUI wrapper:
import MetalKit
import SwiftUI
struct MetalView: UIViewRepresentable {
func makeUIView(context: Context) -> MTKView {
let view = MTKView()
context.coordinator.attach(to: view)
return view
}
func updateUIView(_ uiView: MTKView, context: Context) {}
func makeCoordinator() -> Coordinator { Coordinator() }
@MainActor
final class Coordinator {
private var renderer: Renderer?
func attach(to view: MTKView) {
renderer = try? Renderer(view: view)
}
}
}
3. Minimal Shader File
Add a
.metal
file to the app target. Xcode compiles it into
default.metallib
.
#include <metal_stdlib>
using namespace metal;
struct VertexOut {
float4 position [[position]];
float3 color;
};
vertex VertexOut vertex_main(uint vertexID [[vertex_id]]) {
float2 positions[3] = {
float2( 0.0, 0.6),
float2(-0.6, -0.6),
float2( 0.6, -0.6)
};
float3 colors[3] = {
float3(1.0, 0.2, 0.2),
float3(0.2, 1.0, 0.4),
float3(0.2, 0.4, 1.0)
};
VertexOut out;
out.position = float4(positions[vertexID], 0.0, 1.0);
out.color = colors[vertexID];
return out;
}
fragment float4 fragment_main(VertexOut in [[stage_in]]) {
return float4(in.color, 1.0);
}
Keep shader names stable and fail fast if a function is missing. A nil shader function usually means the
.metal
file is not in target membership or the Swift name no longer matches the shader function.
4. Vertex Buffers
struct Vertex {
var position: SIMD3<Float>
var color: SIMD3<Float>
}
let vertices: [Vertex] = [
Vertex(position: [-0.5, -0.5, 0], color: [1, 0, 0]),
Vertex(position: [ 0.5, -0.5, 0], color: [0, 1, 0]),
Vertex(position: [ 0.0, 0.5, 0], color: [0, 0, 1]),
]
guard let vertexBuffer = device.makeBuffer(
bytes: vertices,
length: MemoryLayout<Vertex>.stride * vertices.count,
options: [.storageModeShared]
) else {
throw RendererError.bufferAllocationFailed
}
Prefer immutable buffers for static geometry. Use ring buffers or multiple in-flight buffers for per-frame uniforms so the CPU does not overwrite data the GPU is still reading.
5. Uniforms and In-Flight Frames
struct Uniforms {
var modelViewProjectionMatrix: simd_float4x4
var time: Float
}
private let maxFramesInFlight = 3
private let frameSemaphore = DispatchSemaphore(value: 3)
private var frameIndex = 0
private var uniformBuffers: [MTLBuffer] = []
func beginFrame() {
frameSemaphore.wait()
frameIndex = (frameIndex + 1) % maxFramesInFlight
}
func endFrame(commandBuffer: MTLCommandBuffer) {
commandBuffer.addCompletedHandler { [frameSemaphore] _ in
frameSemaphore.signal()
}
}
Never mutate a shared uniform buffer immediately after encoding it unless you know the GPU has finished using it.
6. Textures with MetalKit
import MetalKit
let loader = MTKTextureLoader(device: device)
let texture = try loader.newTexture(
name: "albedo",
scaleFactor: UIScreen.main.scale,
bundle: .main,
options: [
.textureUsage: MTLTextureUsage.shaderRead.rawValue,
.textureStorageMode: MTLStorageMode.private.rawValue,
.SRGB: true,
]
)
Use private storage for GPU-only textures. Use shared storage only when the CPU must read or write the resource.
7. Compute Kernels
#include <metal_stdlib>
using namespace metal;
kernel void brighten(texture2d<float, access::read> source [[texture(0)]],
texture2d<float, access::write> output [[texture(1)]],
uint2 id [[thread_position_in_grid]]) {
if (id.x >= output.get_width() || id.y >= output.get_height()) {
return;
}
float4 pixel = source.read(id);
output.write(float4(min(pixel.rgb * 1.15, 1.0), pixel.a), id);
}
let width = pipeline.threadExecutionWidth
let height = max(1, pipeline.maxTotalThreadsPerThreadgroup / width)
let threadsPerGroup = MTLSize(width: width, height: height, depth: 1)
let threadsPerGrid = MTLSize(width: output.width, height: output.height, depth: 1)
encoder.setComputePipelineState(pipeline)
encoder.setTexture(source, index: 0)
encoder.setTexture(output, index: 1)
encoder.dispatchThreads(threadsPerGrid, threadsPerThreadgroup: threadsPerGroup)
Dispatch with bounds checks in the shader. Texture dimensions are rarely exact multiples of the threadgroup size.
8. Depth, Stencil, and Render State
let depthDescriptor = MTLDepthStencilDescriptor()
depthDescriptor.depthCompareFunction = .less
depthDescriptor.isDepthWriteEnabled = true
let depthState = device.makeDepthStencilState(descriptor: depthDescriptor)
encoder.setDepthStencilState(depthState)
Pipeline state is expensive to create. Apple recommends creating shared objects such as command queues, pipelines, buffers, and textures during initialization instead of time-critical paths. Build render and compute pipeline states before
draw(in:)
.
9. ARKit Camera Frames and Metal
ARKit gives each
ARFrame
a camera image as a
CVPixelBuffer
. Use
CVMetalTextureCache
to wrap camera planes as Metal textures without copying.
import ARKit
import CoreVideo
import Metal
var textureCache: CVMetalTextureCache?
CVMetalTextureCacheCreate(nil, nil, device, nil, &textureCache)
func makeTexture(from pixelBuffer: CVPixelBuffer,
plane: Int,
pixelFormat: MTLPixelFormat) -> MTLTexture? {
guard let textureCache else { return nil }
let width = CVPixelBufferGetWidthOfPlane(pixelBuffer, plane)
let height = CVPixelBufferGetHeightOfPlane(pixelBuffer, plane)
var cvTexture: CVMetalTexture?
let status = CVMetalTextureCacheCreateTextureFromImage(
nil,
textureCache,
pixelBuffer,
nil,
pixelFormat,
width,
height,
plane,
&cvTexture
)
guard status == kCVReturnSuccess else { return nil }
return cvTexture.flatMap(CVMetalTextureGetTexture)
}
Use RealityKit unless the product needs custom camera compositing, segmentation, reconstruction rendering, or research-grade visualization.
10. Swift Concurrency Boundaries
Metal objects are reference types that often represent GPU resources. Treat the renderer as owning them on one execution context. Because
MTKView
is
@MainActor
, keep view configuration and delegate attachment on the main actor.
@MainActor
final class RenderModel {
private var renderer: Renderer?
func attach(view: MTKView) {
renderer = try? Renderer(view: view)
}
}
For asset preparation, decode or generate CPU-side data off the main actor, then hand immutable bytes to the renderer for buffer creation. Keep command encoding serialized unless you have a clear multi-queue design.
11. Debugging and Profiling
Enable Metal API Validation in debug schemes.
Capture GPU frames in Xcode and inspect render passes, attachments, and pipeline state.
Use Instruments' Metal System Trace for frame pacing and GPU/CPU overlap.
Give command buffers and resources labels so captures are readable.
Watch for pixel format mismatches between
MTKView
, pipeline descriptors, and render pass attachments.
commandBuffer.label = "Main scene command buffer"
pipelineDescriptor.label = "Opaque mesh pipeline"
vertexBuffer.label = "Static mesh vertices"
12. Common Pitfalls
Creating pipeline state during rendering
-- compile it during setup.
Forgetting target membership for .metal files
--
makeDefaultLibrary()
will not contain the shader.
Writing to buffers still in use by the GPU
-- use in-flight buffers or command-buffer completion handlers.
Mismatched pixel formats
-- the pipeline descriptor must match the render pass.
Assuming one threadgroup fits all textures
-- bounds-check compute kernels.
Skipping labels
-- unlabeled GPU captures are slow to debug.
13. Review Checklist
[ ]
MTLCreateSystemDefaultDevice()
failure has a user-facing fallback
[ ]
MTKView
setup and delegate attachment happen on the main actor
[ ] Pipeline states are created outside the draw loop
[ ]
.metal
functions are checked and failures are actionable
[ ] Per-frame data uses in-flight buffers or synchronization
[ ] Textures use private storage unless CPU access is required
[ ] Render pass formats match pipeline descriptors
[ ] Command buffers/resources are labeled in debug builds
[ ] GPU work is profiled before lower-level rewrites
See also:
docs/frameworks/realitykit.md
,
docs/frameworks/scenekit.md
,
docs/frameworks/arkit.md
,
docs/frameworks/accelerate.md
.
---
# CoreML -- Complete Guide for On-Device Machine Learning
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-ml-coreml.html
← Back to all frameworks & guides
All articles →AI and Machine Learning · Reference guideCoreML -- Complete Guide for On-Device Machine LearningRepository guidance for Core ML. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
Overview
1. Loading an MLModel
From the App Bundle (Compiled .mlmodelc)
From a Compiled Model URL
From a Downloaded .mlmodel (Runtime Compilation)
2. Compute Unit Configuration
3. MLMultiArray for Input and Output
Using MLTensor (iOS 18+)
4. Making Predictions
Synchronous Prediction
Async Prediction (iOS 17+)
Batch Prediction
5. VNCoreMLRequest -- Vision + CoreML Pipeline
6. Complete Image Classification Example
7. Text Prediction Example
8. Model Caching and Performance Optimization
9. Converting Models with coremltools (Overview)
10. Performance Tips
Quick Reference
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Load compatible model
↓2
Prepare typed inputs
↓3
Run prediction
↓4
Validate output and latency
02 / ArchitectureResponsibility boundariesBoundary 1
Model assetBoundary 2
Prediction adapterBoundary 3
App decisionConnected responsibilities, not a required class hierarchy or an execution trace.
Overview
CoreML is Apple's framework for running machine learning models on-device with hardware-accelerated inference across CPU, GPU, and the Neural Engine. It supports vision, natural language, audio, and tabular models with a unified API surface. Every code example below compiles and follows production best practices.
1. Loading an MLModel
From the App Bundle (Compiled .mlmodelc)
When you drag a
.mlmodel
file into Xcode, it auto-generates a Swift class. You can also load manually.
import CoreML
// Auto-generated class usage (Xcode compiles the model at build time)
let imageClassifier = try MobileNetV2(configuration: MLModelConfiguration())
// Manual loading from the bundle
let bundleURL = Bundle.main.url(forResource: "MobileNetV2", withExtension: "mlmodelc")!
let model = try MLModel(contentsOf: bundleURL)
From a Compiled Model URL
import CoreML
func loadModel(from compiledURL: URL) async throws -> MLModel {
let configuration = MLModelConfiguration()
configuration.computeUnits = .all
return try MLModel(contentsOf: compiledURL, configuration: configuration)
}
From a Downloaded .mlmodel (Runtime Compilation)
import CoreML
func compileAndLoad(downloadedModelURL: URL) async throws -> MLModel {
// Compile the .mlmodel into .mlmodelc at runtime
let compiledURL = try await MLModel.compileModel(at: downloadedModelURL)
// Move to a permanent location (compiled models are placed in a temp directory)
let permanentURL = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appendingPathComponent("Models")
.appendingPathComponent(compiledURL.lastPathComponent)
try FileManager.default.createDirectory(
at: permanentURL.deletingLastPathComponent(),
withIntermediateDirectories: true
)
if FileManager.default.fileExists(atPath: permanentURL.path) {
try FileManager.default.removeItem(at: permanentURL)
}
try FileManager.default.moveItem(at: compiledURL, to: permanentURL)
let configuration = MLModelConfiguration()
configuration.computeUnits = .all
return try MLModel(contentsOf: permanentURL, configuration: configuration)
}
2. Compute Unit Configuration
Control where inference runs: CPU only, CPU and GPU, CPU and Neural Engine, or all available hardware.
import CoreML
func configuredModel() throws -> MLModel {
let config = MLModelConfiguration()
// Options:
// .cpuOnly -- safest, always available
// .cpuAndGPU -- good for large matrix operations
// .cpuAndNeuralEngine -- best power efficiency for supported models
// .all -- let CoreML decide the optimal hardware (recommended)
config.computeUnits = .all
// Optionally allow low-precision accumulation for speed
config.allowLowPrecisionAccumulationOnGPU = true
return try MLModel(contentsOf: Bundle.main.url(forResource: "MyModel", withExtension: "mlmodelc")!,
configuration: config)
}
3. MLMultiArray for Input and Output
MLMultiArray is the primary numeric tensor type for CoreML model inputs and outputs.
import CoreML
// Create a multi-array with shape [1, 3, 224, 224] (batch, channels, height, width)
let inputArray = try MLMultiArray(shape: [1, 3, 224, 224], dataType: .float32)
// Fill with data
for i in 0..<inputArray.count {
inputArray[i] = NSNumber(value: Float.random(in: 0...1))
}
// Access specific elements using a flat index
let value = inputArray[0].floatValue
// Access with multi-dimensional subscript
let multiIndex = [0, 1, 112, 112] as [NSNumber]
let pixel = inputArray[multiIndex].floatValue
Using MLTensor (iOS 18+)
MLTensor provides a more ergonomic, Accelerate-backed tensor API.
import CoreML
@available(iOS 18.0, *)
func tensorOperations() {
// Create from shape and scalar
let zeros = MLTensor(zeros: [1, 3, 224, 224], scalarType: Float.self)
// Create from an array
let data = MLTensor([1.0, 2.0, 3.0, 4.0] as [Float])
// Reshape
let reshaped = data.reshaped(to: [2, 2])
// Arithmetic
let scaled = data * 2.0
let summed = data + MLTensor([0.5, 0.5, 0.5, 0.5] as [Float])
// Convert to MLMultiArray for model input
let multiArray = zeros.shapedArray(of: Float.self)
}
4. Making Predictions
Synchronous Prediction
import CoreML
func predict(with model: MLModel, input: MLMultiArray) throws -> MLFeatureProvider {
let inputFeature = try MLDictionaryFeatureProvider(
dictionary: ["input_tensor": MLFeatureValue(multiArray: input)]
)
let options = MLPredictionOptions()
options.usesCPUOnly = false // allow GPU and Neural Engine
let output = try model.prediction(from: inputFeature, options: options)
return output
}
Async Prediction (iOS 17+)
import CoreML
@available(iOS 17.0, *)
func asyncPredict(with model: MLModel, input: MLMultiArray) async throws -> MLFeatureProvider {
let inputFeature = try MLDictionaryFeatureProvider(
dictionary: ["input_tensor": MLFeatureValue(multiArray: input)]
)
let options = MLPredictionOptions()
let output = try await model.prediction(from: inputFeature, options: options)
return output
}
Batch Prediction
import CoreML
func batchPredict(with model: MLModel, inputs: [MLDictionaryFeatureProvider]) throws -> [MLFeatureProvider] {
let batchProvider = MLArrayBatchProvider(array: inputs)
let options = MLPredictionOptions()
let batchResults = try model.predictions(from: batchProvider, options: options)
var results: [MLFeatureProvider] = []
for i in 0..<batchResults.count {
results.append(batchResults.features(at: i))
}
return results
}
5. VNCoreMLRequest -- Vision + CoreML Pipeline
Combine Vision preprocessing (resize, crop, normalize) with a CoreML model in a single pipeline.
import CoreML
import Vision
import UIKit
func classifyImage(_ image: UIImage) async throws -> [(String, Float)] {
guard let cgImage = image.cgImage else {
throw NSError(domain: "ImageError", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid image"])
}
let configuration = MLModelConfiguration()
configuration.computeUnits = .all
let mlModel = try MobileNetV2(configuration: configuration).model
let vnModel = try VNCoreMLModel(for: mlModel)
return try await withCheckedThrowingContinuation { continuation in
let request = VNCoreMLRequest(model: vnModel) { request, error in
if let error {
continuation.resume(throwing: error)
return
}
guard let results = request.results as? [VNClassificationObservation] else {
continuation.resume(returning: [])
return
}
let topResults = results.prefix(5).map { ($0.identifier, $0.confidence) }
continuation.resume(returning: topResults)
}
// Configure image preprocessing
request.imageCropAndScaleOption = .centerCrop
let handler = VNImageRequestHandler(cgImage: cgImage, options: [:])
do {
try handler.perform([request])
} catch {
continuation.resume(throwing: error)
}
}
}
6. Complete Image Classification Example
A full SwiftUI view that loads an image and classifies it with CoreML.
import SwiftUI
import CoreML
import Vision
import PhotosUI
@Observable
final class ImageClassifierViewModel {
var classifications: [(label: String, confidence: Float)] = []
var selectedImage: UIImage?
var isProcessing = false
var errorMessage: String?
@MainActor
func classify() async {
guard let image = selectedImage, let cgImage = image.cgImage else { return }
isProcessing = true
errorMessage = nil
do {
let config = MLModelConfiguration()
config.computeUnits = .all
let mlModel = try MobileNetV2(configuration: config).model
let vnModel = try VNCoreMLModel(for: mlModel)
let results: [(String, Float)] = try await withCheckedThrowingContinuation { continuation in
let request = VNCoreMLRequest(model: vnModel) { request, error in
if let error {
continuation.resume(throwing: error)
return
}
let observations = (request.results as? [VNClassificationObservation]) ?? []
let top5 = observations.prefix(5).map { ($0.identifier, $0.confidence) }
continuation.resume(returning: top5)
}
request.imageCropAndScaleOption = .centerCrop
let handler = VNImageRequestHandler(cgImage: cgImage, options: [:])
do {
try handler.perform([request])
} catch {
continuation.resume(throwing: error)
}
}
classifications = results.map { (label: $0.0, confidence: $0.1) }
} catch {
errorMessage = error.localizedDescription
}
isProcessing = false
}
}
struct ImageClassifierView: View {
@State private var viewModel = ImageClassifierViewModel()
@State private var photoItem: PhotosPickerItem?
var body: some View {
NavigationStack {
VStack(spacing: 24) {
if let image = viewModel.selectedImage {
Image(uiImage: image)
.resizable()
.scaledToFit()
.frame(maxHeight: 300)
.clipShape(RoundedRectangle(cornerRadius: 16))
.shadow(color: .black.opacity(0.2), radius: 12, y: 6)
} else {
ContentUnavailableView("Select a Photo",
systemImage: "photo.on.rectangle",
description: Text("Choose an image to classify"))
}
PhotosPicker(selection: $photoItem, matching: .images) {
Label("Choose Photo", systemImage: "photo.badge.plus")
.font(.headline)
.padding()
.frame(maxWidth: .infinity)
.background(.tint, in: RoundedRectangle(cornerRadius: 12))
.foregroundStyle(.white)
}
if viewModel.isProcessing {
ProgressView("Classifying...")
}
if let error = viewModel.errorMessage {
Text(error)
.foregroundStyle(.red)
.font(.caption)
}
if !viewModel.classifications.isEmpty {
VStack(alignment: .leading, spacing: 8) {
Text("Results")
.font(.headline)
ForEach(viewModel.classifications, id: \.label) { item in
HStack {
Text(item.label)
.font(.body)
Spacer()
Text("\(item.confidence * 100, specifier: "%.1f")%")
.font(.body.monospacedDigit())
.foregroundStyle(.secondary)
}
.padding(.vertical, 4)
}
}
.padding()
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
}
Spacer()
}
.padding()
.navigationTitle("Image Classifier")
.onChange(of: photoItem) { _, newItem in
Task {
if let data = try? await newItem?.loadTransferable(type: Data.self),
let uiImage = UIImage(data: data) {
viewModel.selectedImage = uiImage
await viewModel.classify()
}
}
}
}
}
}
7. Text Prediction Example
Using a CoreML model trained on tabular or text data to make predictions.
import CoreML
struct SentimentPredictor {
private let model: MLModel
init() throws {
let config = MLModelConfiguration()
config.computeUnits = .cpuOnly // text models often run best on CPU
self.model = try SentimentClassifier(configuration: config).model
}
func predict(text: String) throws -> (label: String, confidence: Double) {
let input = try MLDictionaryFeatureProvider(
dictionary: ["text": MLFeatureValue(string: text)]
)
let output = try model.prediction(from: input)
let label = output.featureValue(for: "label")?.stringValue ?? "unknown"
let probabilities = output.featureValue(for: "labelProbability")?.dictionaryValue ?? [:]
let confidence = (probabilities[label as NSObject] as? NSNumber)?.doubleValue ?? 0.0
return (label: label, confidence: confidence)
}
func predictBatch(texts: [String]) throws -> [(label: String, confidence: Double)] {
let inputs: [MLDictionaryFeatureProvider] = try texts.map { text in
try MLDictionaryFeatureProvider(dictionary: ["text": MLFeatureValue(string: text)])
}
let batchProvider = MLArrayBatchProvider(array: inputs)
let batchResults = try model.predictions(from: batchProvider)
var results: [(label: String, confidence: Double)] = []
for i in 0..<batchResults.count {
let output = batchResults.features(at: i)
let label = output.featureValue(for: "label")?.stringValue ?? "unknown"
let probs = output.featureValue(for: "labelProbability")?.dictionaryValue ?? [:]
let confidence = (probs[label as NSObject] as? NSNumber)?.doubleValue ?? 0.0
results.append((label: label, confidence: confidence))
}
return results
}
}
8. Model Caching and Performance Optimization
import CoreML
actor ModelCache {
static let shared = ModelCache()
private var cache: [String: MLModel] = [:]
func model(named name: String, computeUnits: MLComputeUnits = .all) throws -> MLModel {
if let cached = cache[name] {
return cached
}
guard let url = Bundle.main.url(forResource: name, withExtension: "mlmodelc") else {
throw ModelCacheError.modelNotFound(name)
}
let config = MLModelConfiguration()
config.computeUnits = computeUnits
let model = try MLModel(contentsOf: url, configuration: config)
cache[name] = model
return model
}
func preload(modelNames: [String]) async throws {
for name in modelNames {
_ = try model(named: name)
}
}
func evict(named name: String) {
cache.removeValue(forKey: name)
}
func evictAll() {
cache.removeAll()
}
}
enum ModelCacheError: LocalizedError {
case modelNotFound(String)
var errorDescription: String? {
switch self {
case .modelNotFound(let name):
return "Model '\(name)' not found in the app bundle."
}
}
}
9. Converting Models with coremltools (Overview)
Use Python's
coremltools
to convert models from PyTorch, TensorFlow, or ONNX into
.mlmodel
format.
# Install: pip install coremltools torch torchvision
import coremltools as ct
import torch
import torchvision
# Load a pretrained PyTorch model
torch_model = torchvision.models.mobilenet_v2(pretrained=True)
torch_model.eval()
# Trace the model with example input
example_input = torch.randn(1, 3, 224, 224)
traced_model = torch.jit.trace(torch_model, example_input)
# Convert to CoreML
coreml_model = ct.convert(
traced_model,
inputs=[ct.ImageType(name="image", shape=(1, 3, 224, 224), scale=1/255.0)],
classifier_config=ct.ClassifierConfig("imagenet_classes.txt"),
compute_units=ct.ComputeUnit.ALL,
minimum_deployment_target=ct.target.iOS17,
)
# Save
coreml_model.save("MobileNetV2.mlpackage")
Key conversion options:
-
compute_precision
-- use
ct.precision.FLOAT16
for smaller models and faster Neural Engine inference.
-
minimum_deployment_target
-- set to the lowest iOS version you support for maximum compatibility.
-
ct.ImageType
-- declares the input as an image so Vision can preprocess it automatically.
-
ct.ClassifierConfig
-- adds classification metadata so results come as
VNClassificationObservation
.
10. Performance Tips
Technique
Benefit
Use .all compute units
Let CoreML pick the fastest hardware
Float16 quantization
2x smaller model, faster on Neural Engine
Batch predictions
Amortize model setup overhead
Cache loaded models
Avoid repeated disk I/O and compilation
Background thread inference
Keep UI responsive
Model warmup
Run a dummy prediction at launch to prime the pipeline
Use MLPackage over MLModel
Modern format with better optimization support
Profile with Instruments
Use the CoreML Instrument to find bottlenecks
import CoreML
// Warmup: run a dummy prediction to prime the model pipeline
func warmup(model: MLModel, inputName: String, shape: [NSNumber]) throws {
let dummyInput = try MLMultiArray(shape: shape, dataType: .float32)
let provider = try MLDictionaryFeatureProvider(
dictionary: [inputName: MLFeatureValue(multiArray: dummyInput)]
)
_ = try model.prediction(from: provider)
}
Quick Reference
Class / Protocol
Purpose
MLModel
Core class for loading and running models
MLModelConfiguration
Configure compute units, precision
MLMultiArray
N-dimensional numeric array for model I/O
MLTensor
Modern tensor API (iOS 18+)
MLFeatureProvider
Protocol for model input/output
MLDictionaryFeatureProvider
Dictionary-based feature provider
MLArrayBatchProvider
Batch multiple inputs for prediction
MLPredictionOptions
Options for prediction (CPU-only flag)
VNCoreMLModel
Bridge a CoreML model into the Vision pipeline
VNCoreMLRequest
Vision request that uses a CoreML model
---
# NaturalLanguage Framework -- Complete Guide for Text…
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-ml-natural-language.html
← Back to all frameworks & guides
All articles →AI and Machine Learning · Reference guideNaturalLanguage Framework -- Complete Guide for Text Processing and NLPRepository guidance for Natural Language. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
Overview
1. Tokenization -- NLTokenizer
Tokenizing with Language Hint
2. Part-of-Speech Tagging -- NLTagger
3. Named Entity Recognition
4. Lemmatization
5. Language Identification -- NLLanguageRecognizer
6. Sentiment Analysis
Sentence-Level Sentiment Analysis
7. Text Embeddings -- NLEmbedding
Word Embeddings
Sentence Embeddings
8. Custom NLModel with Create ML
9. Complete Text Analysis Pipeline
Quick Reference
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Choose language task
↓2
Configure tokenizer or tagger
↓3
Process text units
↓4
Interpret linguistic results
02 / ArchitectureResponsibility boundariesBoundary 1
Text inputBoundary 2
Language analysisBoundary 3
Structured annotationsConnected responsibilities, not a required class hierarchy or an execution trace.
Overview
The NaturalLanguage framework provides on-device natural language processing for tokenization, language identification, named entity recognition, part-of-speech tagging, sentiment analysis, lemmatization, and text embeddings. All processing runs locally with no network dependency. Every code example below compiles and follows production best practices.
1. Tokenization -- NLTokenizer
Split text into words, sentences, or paragraphs with locale-aware boundary detection.
import NaturalLanguage
func tokenize(_ text: String, unit: NLTokenUnit) -> [String] {
let tokenizer = NLTokenizer(unit: unit)
tokenizer.string = text
var tokens: [String] = []
tokenizer.enumerateTokens(in: text.startIndex..<text.endIndex) { range, _ in
tokens.append(String(text[range]))
return true // continue enumeration
}
return tokens
}
// Usage
let sentence = "The quick brown fox jumps over the lazy dog. It was a sunny day."
let words = tokenize(sentence, unit: .word)
// ["The", "quick", "brown", "fox", "jumps", "over", "the", "lazy", "dog", "It", "was", "a", "sunny", "day"]
let sentences = tokenize(sentence, unit: .sentence)
// ["The quick brown fox jumps over the lazy dog. ", "It was a sunny day."]
let paragraphs = tokenize("First paragraph.\n\nSecond paragraph.", unit: .paragraph)
// ["First paragraph.\n\n", "Second paragraph."]
Tokenizing with Language Hint
import NaturalLanguage
func tokenizeWithLanguage(_ text: String, language: NLLanguage) -> [String] {
let tokenizer = NLTokenizer(unit: .word)
tokenizer.string = text
tokenizer.setLanguage(language)
var tokens: [String] = []
tokenizer.enumerateTokens(in: text.startIndex..<text.endIndex) { range, attributes in
let token = String(text[range])
tokens.append(token)
return true
}
return tokens
}
// Japanese text tokenization
let japaneseTokens = tokenizeWithLanguage("東京は日本の首都です", language: .japanese)
// Properly segments into Japanese word boundaries
// Chinese text tokenization
let chineseTokens = tokenizeWithLanguage("北京是中国的首都", language: .simplifiedChinese)
2. Part-of-Speech Tagging -- NLTagger
Identify the grammatical role of each word: noun, verb, adjective, etc.
import NaturalLanguage
struct TaggedWord {
let word: String
let tag: NLTag?
let tagName: String
}
func tagPartsOfSpeech(_ text: String) -> [TaggedWord] {
let tagger = NLTagger(tagSchemes: [.lexicalClass])
tagger.string = text
var results: [TaggedWord] = []
tagger.enumerateTags(
in: text.startIndex..<text.endIndex,
unit: .word,
scheme: .lexicalClass,
options: [.omitWhitespace, .omitPunctuation]
) { tag, range in
let word = String(text[range])
let tagName: String = switch tag {
case .noun: "Noun"
case .verb: "Verb"
case .adjective: "Adjective"
case .adverb: "Adverb"
case .pronoun: "Pronoun"
case .determiner: "Determiner"
case .particle: "Particle"
case .preposition: "Preposition"
case .conjunction: "Conjunction"
case .interjection: "Interjection"
case .number: "Number"
default: tag?.rawValue ?? "Unknown"
}
results.append(TaggedWord(word: word, tag: tag, tagName: tagName))
return true
}
return results
}
// Usage
let tagged = tagPartsOfSpeech("The quick brown fox jumps over the lazy dog")
for item in tagged {
print("\(item.word): \(item.tagName)")
}
// The: Determiner
// quick: Adjective
// brown: Adjective
// fox: Noun
// jumps: Verb
// over: Preposition
// the: Determiner
// lazy: Adjective
// dog: Noun
3. Named Entity Recognition
Identify people, places, organizations, and other named entities.
import NaturalLanguage
struct NamedEntity {
let text: String
let type: String
}
func extractNamedEntities(_ text: String) -> [NamedEntity] {
let tagger = NLTagger(tagSchemes: [.nameType])
tagger.string = text
var entities: [NamedEntity] = []
tagger.enumerateTags(
in: text.startIndex..<text.endIndex,
unit: .word,
scheme: .nameType,
options: [.omitWhitespace, .omitPunctuation, .joinNames]
) { tag, range in
guard let tag else { return true }
let entityText = String(text[range])
let entityType: String = switch tag {
case .personalName: "Person"
case .placeName: "Place"
case .organizationName: "Organization"
default: tag.rawValue
}
entities.append(NamedEntity(text: entityText, type: entityType))
return true
}
return entities
}
// Usage
let entities = extractNamedEntities("Tim Cook announced the new iPhone at Apple Park in Cupertino.")
// [("Tim Cook", "Person"), ("iPhone", "Organization"), ("Apple Park", "Organization"), ("Cupertino", "Place")]
4. Lemmatization
Reduce words to their base (dictionary) form.
import NaturalLanguage
func lemmatize(_ text: String) -> [(word: String, lemma: String)] {
let tagger = NLTagger(tagSchemes: [.lemma])
tagger.string = text
var results: [(word: String, lemma: String)] = []
tagger.enumerateTags(
in: text.startIndex..<text.endIndex,
unit: .word,
scheme: .lemma,
options: [.omitWhitespace, .omitPunctuation]
) { tag, range in
let word = String(text[range])
let lemma = tag?.rawValue ?? word
results.append((word: word, lemma: lemma))
return true
}
return results
}
// Usage
let lemmas = lemmatize("The dogs were running quickly through the forests")
// [("dogs", "dog"), ("were", "be"), ("running", "run"), ("quickly", "quickly"), ("forests", "forest")]
5. Language Identification -- NLLanguageRecognizer
Detect the language of a text string or rank probable languages.
import NaturalLanguage
func identifyLanguage(_ text: String) -> NLLanguage? {
let recognizer = NLLanguageRecognizer()
recognizer.processString(text)
return recognizer.dominantLanguage
}
func rankLanguages(_ text: String, maxResults: Int = 5) -> [(NLLanguage, Double)] {
let recognizer = NLLanguageRecognizer()
recognizer.processString(text)
let hypotheses = recognizer.languageHypotheses(withMaximum: maxResults)
return hypotheses
.sorted { $0.value > $1.value }
.map { ($0.key, $0.value) }
}
// Constrain to expected languages for better accuracy
func identifyLanguageConstrained(_ text: String, candidates: [NLLanguage]) -> NLLanguage? {
let recognizer = NLLanguageRecognizer()
recognizer.languageConstraints = candidates
recognizer.processString(text)
return recognizer.dominantLanguage
}
// Usage
let language = identifyLanguage("Bonjour, comment allez-vous?")
// .french
let ranked = rankLanguages("Das ist ein Test")
// [(.german, 0.98), (.dutch, 0.01), ...]
let constrained = identifyLanguageConstrained(
"Ciao, come stai?",
candidates: [.italian, .spanish, .french]
)
// .italian
6. Sentiment Analysis
Determine the emotional tone of text using the built-in sentiment tagger.
import NaturalLanguage
/// Returns a sentiment score between -1.0 (negative) and 1.0 (positive)
func analyzeSentiment(_ text: String) -> Double {
let tagger = NLTagger(tagSchemes: [.sentimentScore])
tagger.string = text
let (sentimentTag, _) = tagger.tag(
at: text.startIndex,
unit: .paragraph,
scheme: .sentimentScore
)
return Double(sentimentTag?.rawValue ?? "0") ?? 0.0
}
enum Sentiment: String {
case positive, negative, neutral
}
func classifySentiment(_ text: String) -> Sentiment {
let score = analyzeSentiment(text)
if score > 0.1 { return .positive }
if score < -0.1 { return .negative }
return .neutral
}
// Usage
let score1 = analyzeSentiment("I absolutely love this product! It's amazing!")
// ~0.8 (positive)
let score2 = analyzeSentiment("This is terrible. Worst experience ever.")
// ~-0.7 (negative)
let score3 = analyzeSentiment("The meeting is at 3pm in the conference room.")
// ~0.0 (neutral)
let sentiment = classifySentiment("Great job on the presentation!")
// .positive
Sentence-Level Sentiment Analysis
import NaturalLanguage
struct SentenceSentiment {
let sentence: String
let score: Double
let label: Sentiment
}
func analyzeSentimentBySentence(_ text: String) -> [SentenceSentiment] {
// First, split into sentences
let sentenceTokenizer = NLTokenizer(unit: .sentence)
sentenceTokenizer.string = text
var results: [SentenceSentiment] = []
sentenceTokenizer.enumerateTokens(in: text.startIndex..<text.endIndex) { range, _ in
let sentence = String(text[range]).trimmingCharacters(in: .whitespacesAndNewlines)
guard !sentence.isEmpty else { return true }
let score = analyzeSentiment(sentence)
let label: Sentiment
if score > 0.1 { label = .positive }
else if score < -0.1 { label = .negative }
else { label = .neutral }
results.append(SentenceSentiment(sentence: sentence, score: score, label: label))
return true
}
return results
}
7. Text Embeddings -- NLEmbedding
Compute vector representations of words and sentences for semantic similarity.
Word Embeddings
import NaturalLanguage
func wordSimilarity(_ word1: String, _ word2: String, language: NLLanguage = .english) -> Double? {
guard let embedding = NLEmbedding.wordEmbedding(for: language) else { return nil }
// Distance is between 0 (identical) and 2 (opposite)
let distance = embedding.distance(between: word1, and: word2)
// Convert to similarity (1.0 = identical, 0.0 = unrelated)
return 1.0 - (distance / 2.0)
}
func findNearestWords(to word: String, maxResults: Int = 10, language: NLLanguage = .english) -> [(String, Double)] {
guard let embedding = NLEmbedding.wordEmbedding(for: language) else { return [] }
var results: [(String, Double)] = []
embedding.enumerateNeighbors(for: word, maximumCount: maxResults) { neighbor, distance in
let similarity = 1.0 - (distance / 2.0)
results.append((neighbor, similarity))
return true
}
return results
}
func wordVector(_ word: String, language: NLLanguage = .english) -> [Double]? {
guard let embedding = NLEmbedding.wordEmbedding(for: language) else { return nil }
return embedding.vector(for: word)
}
// Usage
let similarity = wordSimilarity("king", "queen")
// ~0.85 (very similar)
let neighbors = findNearestWords(to: "swift", maxResults: 5)
// [("fast", 0.82), ("quick", 0.78), ("rapid", 0.75), ...]
Sentence Embeddings
import NaturalLanguage
@available(iOS 15.0, *)
func sentenceSimilarity(_ sentence1: String, _ sentence2: String, language: NLLanguage = .english) -> Double? {
guard let embedding = NLEmbedding.sentenceEmbedding(for: language) else { return nil }
let distance = embedding.distance(between: sentence1, and: sentence2)
return 1.0 - (distance / 2.0)
}
@available(iOS 15.0, *)
func findSimilarSentences(to query: String, in candidates: [String], language: NLLanguage = .english) -> [(String, Double)] {
guard let embedding = NLEmbedding.sentenceEmbedding(for: language) else { return [] }
return candidates.compactMap { candidate in
let distance = embedding.distance(between: query, and: candidate)
let similarity = 1.0 - (distance / 2.0)
return (candidate, similarity)
}
.sorted { $0.1 > $1.1 }
}
// Usage
let sim = sentenceSimilarity(
"How is the weather today?",
"What's the forecast for today?"
)
// ~0.85 (semantically similar)
let results = findSimilarSentences(
to: "I need help with my account",
in: [
"How do I reset my password?",
"Where is the nearest restaurant?",
"I want to change my profile settings",
"What time does the store close?"
]
)
// Ranked by semantic similarity to the query
8. Custom NLModel with Create ML
Train a custom text classifier and use it with NLTagger.
import NaturalLanguage
import CoreML
// Loading and using a custom NLModel (trained with Create ML)
func loadCustomModel() throws -> NLModel {
let modelURL = Bundle.main.url(forResource: "CustomTextClassifier", withExtension: "mlmodelc")!
return try NLModel(contentsOf: modelURL)
}
// Standalone prediction
func classifyText(_ text: String, model: NLModel) -> String? {
return model.predictedLabel(for: text)
}
// Prediction with confidence scores
func classifyTextWithConfidence(_ text: String, model: NLModel) -> [(String, Double)] {
let hypotheses = model.predictedLabelHypotheses(for: text, maximumCount: 5)
return hypotheses
.sorted { $0.value > $1.value }
.map { ($0.key, $0.value) }
}
// Using a custom model with NLTagger for per-token classification
func tagWithCustomModel(_ text: String, model: NLModel) -> [(String, String?)] {
let tagger = NLTagger(tagSchemes: [.nameType])
tagger.string = text
tagger.setModels([model], forTagScheme: .nameType)
var results: [(String, String?)] = []
tagger.enumerateTags(
in: text.startIndex..<text.endIndex,
unit: .word,
scheme: .nameType,
options: [.omitWhitespace, .omitPunctuation]
) { tag, range in
let word = String(text[range])
results.append((word, tag?.rawValue))
return true
}
return results
}
9. Complete Text Analysis Pipeline
A full SwiftUI view combining multiple NaturalLanguage features.
import SwiftUI
import NaturalLanguage
@Observable
final class TextAnalysisViewModel {
var inputText = ""
var detectedLanguage = ""
var sentimentScore = 0.0
var sentimentLabel = ""
var wordCount = 0
var sentenceCount = 0
var entities: [NamedEntity] = []
var posTagged: [TaggedWord] = []
func analyze() {
guard !inputText.isEmpty else { return }
// Language detection
let recognizer = NLLanguageRecognizer()
recognizer.processString(inputText)
detectedLanguage = recognizer.dominantLanguage?.rawValue ?? "Unknown"
// Sentiment
let sentimentTagger = NLTagger(tagSchemes: [.sentimentScore])
sentimentTagger.string = inputText
let (tag, _) = sentimentTagger.tag(at: inputText.startIndex, unit: .paragraph, scheme: .sentimentScore)
sentimentScore = Double(tag?.rawValue ?? "0") ?? 0.0
if sentimentScore > 0.1 { sentimentLabel = "Positive" }
else if sentimentScore < -0.1 { sentimentLabel = "Negative" }
else { sentimentLabel = "Neutral" }
// Tokenization counts
let wordTokenizer = NLTokenizer(unit: .word)
wordTokenizer.string = inputText
wordCount = 0
wordTokenizer.enumerateTokens(in: inputText.startIndex..<inputText.endIndex) { _, _ in
wordCount += 1
return true
}
let sentenceTokenizer = NLTokenizer(unit: .sentence)
sentenceTokenizer.string = inputText
sentenceCount = 0
sentenceTokenizer.enumerateTokens(in: inputText.startIndex..<inputText.endIndex) { _, _ in
sentenceCount += 1
return true
}
// Named entities
entities = extractNamedEntities(inputText)
// POS tagging (first 20 words)
posTagged = Array(tagPartsOfSpeech(inputText).prefix(20))
}
}
struct TextAnalysisView: View {
@State private var viewModel = TextAnalysisViewModel()
var body: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
TextEditor(text: $viewModel.inputText)
.frame(minHeight: 120)
.padding(8)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
Button("Analyze") {
viewModel.analyze()
}
.buttonStyle(.borderedProminent)
.frame(maxWidth: .infinity)
if !viewModel.detectedLanguage.isEmpty {
GroupBox("Overview") {
LabeledContent("Language", value: viewModel.detectedLanguage)
LabeledContent("Words", value: "\(viewModel.wordCount)")
LabeledContent("Sentences", value: "\(viewModel.sentenceCount)")
}
GroupBox("Sentiment") {
LabeledContent("Score", value: String(format: "%.2f", viewModel.sentimentScore))
LabeledContent("Label", value: viewModel.sentimentLabel)
}
if !viewModel.entities.isEmpty {
GroupBox("Named Entities") {
ForEach(viewModel.entities, id: \.text) { entity in
LabeledContent(entity.text, value: entity.type)
}
}
}
if !viewModel.posTagged.isEmpty {
GroupBox("Parts of Speech") {
LazyVGrid(columns: [
GridItem(.flexible()),
GridItem(.flexible()),
GridItem(.flexible())
], spacing: 8) {
ForEach(viewModel.posTagged, id: \.word) { item in
VStack(spacing: 4) {
Text(item.word)
.font(.body.bold())
Text(item.tagName)
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(8)
.background(.quaternary, in: RoundedRectangle(cornerRadius: 8))
}
}
}
}
}
}
.padding()
}
.navigationTitle("Text Analysis")
}
}
}
Quick Reference
Class
Purpose
NLTokenizer
Split text into words, sentences, or paragraphs
NLTagger
Tag tokens with POS, NER, lemma, sentiment
NLLanguageRecognizer
Detect the language of a string
NLEmbedding
Word and sentence vector embeddings
NLModel
Load and use custom Create ML text models
Tag Scheme
Tags Produced
.lexicalClass
Noun, Verb, Adjective, Adverb, Pronoun, Determiner, etc.
.nameType
PersonalName, PlaceName, OrganizationName
.lemma
Base/dictionary form of each word
.sentimentScore
Floating-point score from -1.0 to 1.0
.language
Per-token language identification
Embedding Type
Available From
Dimensions
Word embedding
iOS 13+
~128-300 dimensions
Sentence embedding
iOS 15+
~512 dimensions
---
# Sound Analysis
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-ml-sound-analysis.html
← Back to all frameworks & guides
All articles →AI and Machine Learning · Reference guideSound AnalysisRepository guidance for Sound Analysis. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
Context
Choosing the analyzer
Audio file analysis
Live audio stream analysis
Custom sound classifiers
Privacy and UX
Verification checklist
Source anchor
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Prepare audio stream
↓2
Configure classification request
↓3
Consume classification results
↓4
Handle stream termination
02 / ArchitectureResponsibility boundariesBoundary 1
Audio sourceBoundary 2
Sound analyzerBoundary 3
Classification consumerConnected responsibilities, not a required class hierarchy or an execution trace.
Context
Load this when an app needs sound classification, audio-event detection, built-in sound labels, custom Core ML sound classifiers, live microphone sound analysis, file-based audio tagging, or sound timestamps.
Use Sound Analysis for non-speech sounds. Use Speech for spoken-word transcription, ShazamKit for music recognition/matching, AVFoundation for capture/playback, and Core ML when the app owns a model that is not routed through
SNClassifySoundRequest
.
Choosing the analyzer
Need
Use
Analyze an existing audio file
SNAudioFileAnalyzer
Analyze microphone or live stream buffers
SNAudioStreamAnalyzer
Use Apple's built-in sound classifier
SNClassifySoundRequest(classifierIdentifier:)
Use a custom sound classifier
SNClassifySoundRequest(mlModel:)
Receive results
SNResultsObserving
Audio file analysis
import Foundation
import SoundAnalysis
final class SoundFileClassifier {
private var observer: SoundResultsObserver?
func classifyFile(at url: URL) throws {
let request = try SNClassifySoundRequest(classifierIdentifier: .version1)
let analyzer = try SNAudioFileAnalyzer(url: url)
let observer = SoundResultsObserver()
self.observer = observer
try analyzer.add(request, withObserver: observer)
analyzer.analyze()
}
}
final class SoundResultsObserver: NSObject, SNResultsObserving {
func request(_ request: SNRequest, didProduce result: SNResult) {
guard let result = result as? SNClassificationResult,
let best = result.classifications.first else {
return
}
let time = result.timeRange.start.seconds
print("Sound at \(time): \(best.identifier), confidence \(best.confidence)")
}
func request(_ request: SNRequest, didFailWithError error: Error) {
print("Sound analysis failed: \(error)")
}
func requestDidComplete(_ request: SNRequest) {
print("Sound analysis complete")
}
}
Important: keep a strong reference to the observer. Sound analyzers do not keep observers alive for you.
Live audio stream analysis
For microphone analysis:
Request microphone permission with clear purpose text.
Configure
AVAudioEngine
.
Create
SNAudioStreamAnalyzer
with the input format.
Add
SNClassifySoundRequest
.
Feed audio buffers from the audio tap.
Stop the engine and remove taps when the feature ends.
Rules:
Keep audio work off the main actor.
Avoid retaining raw audio unless the product explicitly needs it.
Debounce classifications before updating UI.
Treat low-confidence labels as suggestions, not facts.
Custom sound classifiers
Use a custom Core ML sound classifier when built-in labels are not enough.
Rules:
Train with representative audio from the real environment.
Keep train/test splits clean.
Validate noisy rooms, silence, overlapping sounds, and device microphones.
Version the model and labels together.
Keep confidence thresholds product-specific.
Privacy and UX
Explain microphone use before requesting permission.
Show when live listening is active.
Provide a clear stop control.
Avoid background listening unless the product, entitlement, and policy story are explicit.
Do not upload raw audio by default.
Tell users when results are approximate.
Verification checklist
[ ] Microphone permission states are tested.
[ ] File-based and stream-based paths are tested separately.
[ ] Observer lifetime is strong and documented.
[ ] Confidence threshold is defined.
[ ] Silence, noise, and overlapping sounds are tested.
[ ] Audio engine stop/removal is tested.
[ ] Memory and battery are measured for long sessions.
[ ] Privacy copy matches the audio data path.
Source anchor
Use only to verify API signatures and availability:
https://developer.apple.com/documentation/soundanalysis
---
# Speech Framework -- Complete Guide for Speech Recognition…
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-ml-speech.html
← Back to all frameworks & guides
All articles →AI and Machine Learning · Reference guideSpeech Framework -- Complete Guide for Speech Recognition and TranscriptionRepository guidance for Speech. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
Overview
1. Setup and Authorization
2. Creating a Speech Recognizer
3. On-Device vs Server-Based Recognition
4. Transcribing Audio Files -- SFSpeechURLRecognitionRequest
Transcription with Confidence Scores and Alternatives
5. Live Audio Transcription -- SFSpeechAudioBufferRecognitionRequest
6. Complete Live Transcription SwiftUI View
7. Language Selection
8. Monitoring Recognizer Availability
9. iOS 17+ Improvements
Handling Custom Vocabulary
10. Performance Considerations
Restarting for Long Sessions
Quick Reference
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Request required permissions
↓2
Start speech recognition
↓3
Update transcript state
↓4
Handle cancellation and errors
02 / ArchitectureResponsibility boundariesBoundary 1
Audio captureBoundary 2
Recognition sessionBoundary 3
Transcript UIConnected responsibilities, not a required class hierarchy or an execution trace.
Overview
The Speech framework provides on-device and server-assisted speech recognition for converting audio to text. It supports live microphone transcription, audio file transcription, multiple languages, confidence scores, and alternative transcriptions. iOS 17+ introduced improvements for on-device recognition quality and reduced latency. Every code example below compiles and follows production best practices.
1. Setup and Authorization
Speech recognition requires explicit user permission. Add both keys to your Info.plist:
NSSpeechRecognitionUsageDescription
-- why you need speech recognition
NSMicrophoneUsageDescription
-- why you need microphone access (for live audio)
import Speech
func requestSpeechAuthorization() async -> SFSpeechRecognizerAuthorizationStatus {
await withCheckedContinuation { continuation in
SFSpeechRecognizer.requestAuthorization { status in
continuation.resume(returning: status)
}
}
}
func checkAuthorizationStatus() -> Bool {
switch SFSpeechRecognizer.authorizationStatus() {
case .authorized:
return true
case .denied:
print("User denied speech recognition access")
return false
case .restricted:
print("Speech recognition restricted on this device")
return false
case .notDetermined:
print("Speech recognition permission not yet requested")
return false
@unknown default:
return false
}
}
2. Creating a Speech Recognizer
import Speech
// Default locale (user's device locale)
let defaultRecognizer = SFSpeechRecognizer()
// Specific language
let englishRecognizer = SFSpeechRecognizer(locale: Locale(identifier: "en-US"))
let japaneseRecognizer = SFSpeechRecognizer(locale: Locale(identifier: "ja-JP"))
let spanishRecognizer = SFSpeechRecognizer(locale: Locale(identifier: "es-ES"))
// Check availability
func isRecognizerAvailable(for locale: Locale) -> Bool {
guard let recognizer = SFSpeechRecognizer(locale: locale) else { return false }
return recognizer.isAvailable
}
// List all supported locales
func supportedLocales() -> Set<Locale> {
return SFSpeechRecognizer.supportedLocales()
}
3. On-Device vs Server-Based Recognition
import Speech
func configureRecognizer() -> SFSpeechRecognizer? {
guard let recognizer = SFSpeechRecognizer(locale: Locale(identifier: "en-US")) else {
return nil
}
// Check if on-device recognition is available
if recognizer.supportsOnDeviceRecognition {
print("On-device recognition supported -- works offline")
} else {
print("This locale requires server-based recognition")
}
return recognizer
}
// When creating a recognition request, you can require on-device processing
func createOnDeviceRequest() -> SFSpeechAudioBufferRecognitionRequest {
let request = SFSpeechAudioBufferRecognitionRequest()
// Force on-device recognition (fails if not available)
request.requiresOnDeviceRecognition = true
// Additional configuration
request.shouldReportPartialResults = true
request.addsPunctuation = true // iOS 16+
return request
}
4. Transcribing Audio Files -- SFSpeechURLRecognitionRequest
import Speech
func transcribeAudioFile(at url: URL, locale: Locale = Locale(identifier: "en-US")) async throws -> String {
guard let recognizer = SFSpeechRecognizer(locale: locale),
recognizer.isAvailable else {
throw SpeechError.recognizerUnavailable
}
let request = SFSpeechURLRecognitionRequest(url: url)
request.shouldReportPartialResults = false
request.addsPunctuation = true
if recognizer.supportsOnDeviceRecognition {
request.requiresOnDeviceRecognition = true
}
return try await withCheckedThrowingContinuation { continuation in
recognizer.recognitionTask(with: request) { result, error in
if let error {
continuation.resume(throwing: error)
return
}
guard let result, result.isFinal else { return }
continuation.resume(returning: result.bestTranscription.formattedString)
}
}
}
enum SpeechError: LocalizedError {
case recognizerUnavailable
case audioEngineError
case notAuthorized
var errorDescription: String? {
switch self {
case .recognizerUnavailable:
return "Speech recognizer is not available for the selected language."
case .audioEngineError:
return "Audio engine failed to start."
case .notAuthorized:
return "Speech recognition is not authorized."
}
}
}
Transcription with Confidence Scores and Alternatives
import Speech
struct TranscriptionResult {
let text: String
let confidence: Float
let segments: [SegmentDetail]
let alternatives: [String]
}
struct SegmentDetail {
let text: String
let confidence: Float
let timestamp: TimeInterval
let duration: TimeInterval
}
func transcribeWithDetails(at url: URL) async throws -> TranscriptionResult {
guard let recognizer = SFSpeechRecognizer(locale: Locale(identifier: "en-US")),
recognizer.isAvailable else {
throw SpeechError.recognizerUnavailable
}
let request = SFSpeechURLRecognitionRequest(url: url)
request.shouldReportPartialResults = false
request.addsPunctuation = true
return try await withCheckedThrowingContinuation { continuation in
recognizer.recognitionTask(with: request) { result, error in
if let error {
continuation.resume(throwing: error)
return
}
guard let result, result.isFinal else { return }
let bestTranscription = result.bestTranscription
// Extract per-segment details
let segments = bestTranscription.segments.map { segment in
SegmentDetail(
text: segment.substring,
confidence: segment.confidence,
timestamp: segment.timestamp,
duration: segment.duration
)
}
// Overall confidence (average of segment confidences)
let totalConfidence = segments.isEmpty ? 0 :
segments.reduce(Float(0)) { $0 + $1.confidence } / Float(segments.count)
// Alternative transcriptions
let alternatives = result.transcriptions.dropFirst().map { $0.formattedString }
let transcriptionResult = TranscriptionResult(
text: bestTranscription.formattedString,
confidence: totalConfidence,
segments: segments,
alternatives: Array(alternatives)
)
continuation.resume(returning: transcriptionResult)
}
}
}
5. Live Audio Transcription -- SFSpeechAudioBufferRecognitionRequest
Real-time speech-to-text using the device microphone with AVAudioEngine.
import Speech
import AVFoundation
@Observable
@MainActor
final class LiveTranscriptionManager {
var transcribedText = ""
var isRecording = false
var errorMessage: String?
private var audioEngine: AVAudioEngine?
private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest?
private var recognitionTask: SFSpeechRecognitionTask?
private let speechRecognizer: SFSpeechRecognizer?
init(locale: Locale = Locale(identifier: "en-US")) {
self.speechRecognizer = SFSpeechRecognizer(locale: locale)
}
func startRecording() async {
// Check authorization
let authStatus = await requestSpeechAuthorization()
guard authStatus == .authorized else {
errorMessage = "Speech recognition not authorized"
return
}
guard let speechRecognizer, speechRecognizer.isAvailable else {
errorMessage = "Speech recognizer not available"
return
}
// Stop any existing session
stopRecording()
// Configure audio session
let audioSession = AVAudioSession.sharedInstance()
do {
try audioSession.setCategory(.record, mode: .measurement, options: .duckOthers)
try audioSession.setActive(true, options: .notifyOthersOnDeactivation)
} catch {
errorMessage = "Audio session setup failed: \(error.localizedDescription)"
return
}
// Create recognition request
let request = SFSpeechAudioBufferRecognitionRequest()
request.shouldReportPartialResults = true
request.addsPunctuation = true
if speechRecognizer.supportsOnDeviceRecognition {
request.requiresOnDeviceRecognition = true
}
self.recognitionRequest = request
// Create audio engine
let engine = AVAudioEngine()
self.audioEngine = engine
let inputNode = engine.inputNode
let recordingFormat = inputNode.outputFormat(forBus: 0)
// Install a tap on the audio input
inputNode.installTap(onBus: 0, bufferSize: 1024, format: recordingFormat) { buffer, _ in
request.append(buffer)
}
// Start the audio engine
do {
engine.prepare()
try engine.start()
} catch {
errorMessage = "Audio engine failed to start: \(error.localizedDescription)"
return
}
isRecording = true
transcribedText = ""
// Start recognition task
recognitionTask = speechRecognizer.recognitionTask(with: request) { [weak self] result, error in
Task { @MainActor in
guard let self else { return }
if let result {
self.transcribedText = result.bestTranscription.formattedString
}
if let error {
self.errorMessage = error.localizedDescription
self.stopRecording()
}
if result?.isFinal == true {
self.stopRecording()
}
}
}
}
func stopRecording() {
audioEngine?.stop()
audioEngine?.inputNode.removeTap(onBus: 0)
audioEngine = nil
recognitionRequest?.endAudio()
recognitionRequest = nil
recognitionTask?.cancel()
recognitionTask = nil
isRecording = false
}
private func requestSpeechAuthorization() async -> SFSpeechRecognizerAuthorizationStatus {
await withCheckedContinuation { continuation in
SFSpeechRecognizer.requestAuthorization { status in
continuation.resume(returning: status)
}
}
}
}
6. Complete Live Transcription SwiftUI View
import SwiftUI
import Speech
struct LiveTranscriptionView: View {
@State private var manager = LiveTranscriptionManager()
var body: some View {
NavigationStack {
VStack(spacing: 24) {
// Transcribed text display
ScrollView {
Text(manager.transcribedText.isEmpty ? "Tap the microphone to start speaking..." : manager.transcribedText)
.font(.body)
.foregroundStyle(manager.transcribedText.isEmpty ? .secondary : .primary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
}
.frame(maxHeight: .infinity)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16))
// Recording indicator
if manager.isRecording {
HStack(spacing: 8) {
Circle()
.fill(.red)
.frame(width: 10, height: 10)
.opacity(manager.isRecording ? 1 : 0)
.animation(.easeInOut(duration: 0.5).repeatForever(), value: manager.isRecording)
Text("Listening...")
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
// Error display
if let error = manager.errorMessage {
Text(error)
.font(.caption)
.foregroundStyle(.red)
.padding(.horizontal)
}
// Record button
Button {
Task {
if manager.isRecording {
manager.stopRecording()
} else {
await manager.startRecording()
}
}
} label: {
Image(systemName: manager.isRecording ? "stop.circle.fill" : "mic.circle.fill")
.font(.system(size: 64))
.foregroundStyle(manager.isRecording ? .red : .accentColor)
.symbolEffect(.bounce, value: manager.isRecording)
}
.padding(.bottom, 32)
// Copy button
if !manager.transcribedText.isEmpty {
Button {
UIPasteboard.general.string = manager.transcribedText
} label: {
Label("Copy Transcription", systemImage: "doc.on.doc")
}
.buttonStyle(.bordered)
}
}
.padding()
.navigationTitle("Live Transcription")
}
}
}
7. Language Selection
import Speech
import SwiftUI
struct LanguagePickerView: View {
@State private var selectedLocale: Locale = Locale(identifier: "en-US")
@State private var supportedLocales: [Locale] = []
var body: some View {
List {
Section("Select Language") {
ForEach(supportedLocales, id: \.identifier) { locale in
Button {
selectedLocale = locale
} label: {
HStack {
VStack(alignment: .leading) {
Text(locale.localizedString(forIdentifier: locale.identifier) ?? locale.identifier)
.font(.body)
Text(locale.identifier)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
if locale.identifier == selectedLocale.identifier {
Image(systemName: "checkmark")
.foregroundStyle(.accentColor)
}
// Show on-device badge
if let recognizer = SFSpeechRecognizer(locale: locale),
recognizer.supportsOnDeviceRecognition {
Text("On-Device")
.font(.caption2)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(.green.opacity(0.15), in: Capsule())
.foregroundStyle(.green)
}
}
}
.tint(.primary)
}
}
}
.task {
supportedLocales = SFSpeechRecognizer.supportedLocales()
.sorted { ($0.identifier) < ($1.identifier) }
}
}
}
8. Monitoring Recognizer Availability
import Speech
class SpeechRecognizerMonitor: NSObject, SFSpeechRecognizerDelegate {
private let recognizer: SFSpeechRecognizer
var onAvailabilityChanged: ((Bool) -> Void)?
init(locale: Locale = Locale(identifier: "en-US")) {
self.recognizer = SFSpeechRecognizer(locale: locale) ?? SFSpeechRecognizer()!
super.init()
self.recognizer.delegate = self
}
var isAvailable: Bool {
recognizer.isAvailable
}
func speechRecognizer(_ speechRecognizer: SFSpeechRecognizer, availabilityDidChange available: Bool) {
onAvailabilityChanged?(available)
}
}
9. iOS 17+ Improvements
iOS 17 introduced several improvements to speech recognition:
import Speech
@available(iOS 17.0, *)
func modernTranscription(at url: URL) async throws -> String {
guard let recognizer = SFSpeechRecognizer(locale: Locale(identifier: "en-US")),
recognizer.isAvailable else {
throw SpeechError.recognizerUnavailable
}
let request = SFSpeechURLRecognitionRequest(url: url)
// iOS 17+: Improved on-device models with better accuracy
request.requiresOnDeviceRecognition = true
// iOS 16+: Automatic punctuation
request.addsPunctuation = true
// Task-level customization
request.shouldReportPartialResults = false
// iOS 17+: Use the new async recognition API
let result = try await recognizer.recognitionTask(with: request)
return result.bestTranscription.formattedString
}
Handling Custom Vocabulary
import Speech
func transcribeWithCustomVocabulary(at url: URL, customPhrases: [String]) async throws -> String {
guard let recognizer = SFSpeechRecognizer(locale: Locale(identifier: "en-US")),
recognizer.isAvailable else {
throw SpeechError.recognizerUnavailable
}
let request = SFSpeechURLRecognitionRequest(url: url)
request.shouldReportPartialResults = false
request.addsPunctuation = true
// Add domain-specific vocabulary to improve recognition
request.contextualStrings = customPhrases
return try await withCheckedThrowingContinuation { continuation in
recognizer.recognitionTask(with: request) { result, error in
if let error {
continuation.resume(throwing: error)
return
}
guard let result, result.isFinal else { return }
continuation.resume(returning: result.bestTranscription.formattedString)
}
}
}
// Usage
// let text = try await transcribeWithCustomVocabulary(
// at: audioURL,
// customPhrases: ["SwiftUI", "CoreML", "Xcode", "WWDC", "visionOS"]
// )
10. Performance Considerations
Consideration
Recommendation
On-device vs server
Prefer on-device for privacy and offline; server for broader language support
Buffer size
1024 samples is a good default for real-time; increase for batch
Audio format
16kHz mono is optimal for speech recognition
Session duration
Apple limits recognition to ~1 minute per task; restart for longer sessions
Battery impact
On-device recognition uses less battery than server-based
Memory
Audio buffers accumulate; call endAudio() promptly when done
Background
Speech recognition is not available in the background
Rate limiting
Apple throttles server-based requests per device per day
Restarting for Long Sessions
import Speech
import AVFoundation
@Observable
@MainActor
final class ContinuousTranscriptionManager {
var fullTranscript = ""
var isRecording = false
private var audioEngine: AVAudioEngine?
private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest?
private var recognitionTask: SFSpeechRecognitionTask?
private let speechRecognizer = SFSpeechRecognizer(locale: Locale(identifier: "en-US"))
/// Restarts the recognition task to handle Apple's ~1 minute limit
func restartRecognition() async {
guard isRecording else { return }
// End current request
recognitionRequest?.endAudio()
recognitionTask?.cancel()
// Create a new request and task
let request = SFSpeechAudioBufferRecognitionRequest()
request.shouldReportPartialResults = true
request.addsPunctuation = true
if speechRecognizer?.supportsOnDeviceRecognition == true {
request.requiresOnDeviceRecognition = true
}
self.recognitionRequest = request
// Reinstall the audio tap
let inputNode = audioEngine?.inputNode
let format = inputNode?.outputFormat(forBus: 0)
inputNode?.removeTap(onBus: 0)
inputNode?.installTap(onBus: 0, bufferSize: 1024, format: format) { buffer, _ in
request.append(buffer)
}
// Start new recognition task
recognitionTask = speechRecognizer?.recognitionTask(with: request) { [weak self] result, error in
Task { @MainActor in
guard let self else { return }
if let result {
// Append final results to full transcript
if result.isFinal {
self.fullTranscript += result.bestTranscription.formattedString + " "
// Restart for continuous recognition
await self.restartRecognition()
}
}
}
}
}
}
Quick Reference
Class
Purpose
SFSpeechRecognizer
Main class for speech recognition; configure locale and check availability
SFSpeechAudioBufferRecognitionRequest
Recognition request fed with live audio buffers
SFSpeechURLRecognitionRequest
Recognition request for audio files on disk
SFSpeechRecognitionTask
A running recognition operation; cancel or monitor
SFSpeechRecognitionResult
Contains transcriptions, confidence, and finality
SFTranscription
A single transcription with formatted text and segments
SFTranscriptionSegment
Per-word detail: text, confidence, timestamp, duration
Property
Type
Purpose
shouldReportPartialResults
Bool
Emit intermediate results during recognition
requiresOnDeviceRecognition
Bool
Force on-device processing (no network)
addsPunctuation
Bool
Automatic punctuation insertion (iOS 16+)
contextualStrings
[String]
Domain-specific vocabulary hints
supportsOnDeviceRecognition
Bool
Whether the locale supports offline recognition
---
# Translation
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-ml-translation.html
← Back to all frameworks & guides
All articles →AI and Machine Learning · Reference guideTranslationRepository guidance for Translation. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
Context
Choosing the API
System translation UI
Custom translation flow
Language availability
Product and privacy rules
Verification checklist
Source anchor
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Select source and target languages
↓2
Check translation readiness
↓3
Translate scoped text
↓4
Handle unavailable language support
02 / ArchitectureResponsibility boundariesBoundary 1
Source textBoundary 2
Translation sessionBoundary 3
Translated presentationConnected responsibilities, not a required class hierarchy or an execution trace.
Context
Load this when an app needs in-app text translation, system translation UI, custom translation flows, language-pair availability checks, model download handling, or multilingual user-generated content workflows.
Use the Translation framework for text translation. Use Natural Language for text analysis, Speech for speech-to-text, and Foundation Models only when translation is part of a broader generative workflow that still needs review.
Choosing the API
Need
Use
Simple user-facing translation popover
translationPresentation(...)
Replace selected text with a translation
translationPresentation(..., replacementAction:)
Custom UI with one or more translated strings
translationTask(...) and TranslationSession
Batch translation
TranslationSession batch APIs
Check if languages are supported/installed
LanguageAvailability
System translation UI
Use the system presentation when the product needs a familiar, lightweight translation affordance and does not need custom layout.
import SwiftUI
import Translation
struct MessageRow: View {
let text: String
@State private var showingTranslation = false
var body: some View {
Text(text)
.textSelection(.enabled)
.contextMenu {
Button("Translate") {
showingTranslation = true
}
}
.translationPresentation(
isPresented: $showingTranslation,
text: text
)
}
}
Rules:
Attach the presentation to the view that owns the source text.
Do not present translation for empty or already-redacted private text.
Keep the original text available so users can compare.
Custom translation flow
Use a custom session when the app owns the layout, wants batch translation, or needs to store a translated draft.
import SwiftUI
import Translation
struct TranslationDraftView: View {
let sourceText: String
let source: Locale.Language?
let target: Locale.Language?
@State private var translatedText = ""
@State private var errorMessage: String?
var body: some View {
VStack(alignment: .leading) {
Text(sourceText)
Divider()
Text(translatedText.isEmpty ? "Translation unavailable" : translatedText)
}
.translationTask(source: source, target: target) { session in
do {
let response = try await session.translate(sourceText)
translatedText = response.targetText
} catch is CancellationError {
return
} catch {
errorMessage = error.localizedDescription
}
}
}
}
Rules:
Keep translation work tied to view/task lifetime.
Cancel or let SwiftUI cancel translation when inputs change.
Show source and target language when user trust matters.
Do not silently replace legal, medical, financial, or safety-critical text.
Language availability
Check availability before offering a custom translation control.
import Translation
func translationIsSupported(
from source: Locale.Language,
to target: Locale.Language
) async -> Bool {
let availability = LanguageAvailability()
let status = await availability.status(from: source, to: target)
switch status {
case .installed, .supported:
return true
case .unsupported:
return false
@unknown default:
return false
}
}
Rules:
supported
may still require a model download before use.
Treat unsupported language pairs as a normal UI state.
Test source-language auto-detection separately from explicit source language.
Product and privacy rules
Explain when translated text is generated by the system.
Let users inspect the original text.
Do not use translation as a hidden moderation, compliance, or policy decision.
Avoid storing translations unless the product needs persistence.
If stored, mark the translated language and source text version.
Handle mixed-language text, names, code snippets, and domain terms carefully.
Verification checklist
[ ] Source and target languages are explicit or intentionally auto-detected.
[ ]
LanguageAvailability
is checked for custom flows.
[ ] Unsupported language pairs have a clear fallback.
[ ] Model download or permission prompts are not surprising.
[ ] Cancellation is tested when text/language changes.
[ ] Original text remains reachable.
[ ] Localized UI labels fit at accessibility text sizes.
[ ] Privacy copy matches the actual data path.
Source anchor
Use only to verify API signatures and availability:
https://developer.apple.com/documentation/translation
---
# Vision Framework -- Complete Guide for Image Analysis and…
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-ml-vision.html
← Back to all frameworks & guides
All articles →AI and Machine Learning · Reference guideVision Framework -- Complete Guide for Image Analysis and Computer VisionRepository guidance for Vision. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
Overview
1. Performing Vision Requests
2. Text Recognition (OCR) -- VNRecognizeTextRequest
Accurate vs Fast Recognition
Getting Bounding Boxes for Recognized Text
3. Face Detection and Landmarks
Detect Face Rectangles
Detect Face Landmarks (Eyes, Nose, Mouth, etc.)
4. Barcode and QR Code Detection
5. Person Segmentation (Background Removal)
6. Object Tracking in Video
7. Image Classification
8. VisionKit -- DataScannerViewController (iOS 16+)
9. ImageAnalyzer and ImageAnalysisInteraction (iOS 16+)
10. Complete Multi-Request Pipeline
Quick Reference
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Prepare image orientation
↓2
Configure a vision request
↓3
Perform image analysis
↓4
Map observations to UI
02 / ArchitectureResponsibility boundariesBoundary 1
Image inputBoundary 2
Vision requestBoundary 3
ObservationsConnected responsibilities, not a required class hierarchy or an execution trace.
Overview
The Vision framework provides high-performance image analysis for text recognition (OCR), face detection, barcode scanning, object tracking, person segmentation, and image classification. All processing runs on-device using optimized CoreML models managed by the system. Every code example below compiles and follows production best practices.
1. Performing Vision Requests
All Vision requests follow the same pattern: create a request handler, configure requests, and perform them.
import Vision
import UIKit
// From CGImage
func performRequest(on image: UIImage, request: VNRequest) throws {
guard let cgImage = image.cgImage else { return }
let handler = VNImageRequestHandler(cgImage: cgImage, orientation: .up, options: [:])
try handler.perform([request])
}
// From CIImage
func performRequest(on ciImage: CIImage, request: VNRequest) throws {
let handler = VNImageRequestHandler(ciImage: ciImage, options: [:])
try handler.perform([request])
}
// From CVPixelBuffer (camera frames)
func performRequest(on pixelBuffer: CVPixelBuffer, request: VNRequest) throws {
let handler = VNImageRequestHandler(cvPixelBuffer: pixelBuffer, orientation: .right, options: [:])
try handler.perform([request])
}
// From file URL
func performRequest(at url: URL, request: VNRequest) throws {
let handler = VNImageRequestHandler(url: url, options: [:])
try handler.perform([request])
}
2. Text Recognition (OCR) -- VNRecognizeTextRequest
Accurate vs Fast Recognition
import Vision
import UIKit
func recognizeText(in image: UIImage, accurate: Bool = true) async throws -> [String] {
guard let cgImage = image.cgImage else { return [] }
return try await withCheckedThrowingContinuation { continuation in
let request = VNRecognizeTextRequest { request, error in
if let error {
continuation.resume(throwing: error)
return
}
let results = (request.results as? [VNRecognizedTextObservation]) ?? []
let strings = results.compactMap { observation in
observation.topCandidates(1).first?.string
}
continuation.resume(returning: strings)
}
// .accurate -- slower but higher quality, supports language correction
// .fast -- faster but lower accuracy, no language correction
request.recognitionLevel = accurate ? .accurate : .fast
// Supported languages (call supportedRecognitionLanguages() to list all)
request.recognitionLanguages = ["en-US", "fr-FR", "de-DE"]
// Enable automatic language correction
request.usesLanguageCorrection = true
// Minimum text height relative to image height (0.0 to 1.0)
request.minimumTextHeight = 0.01
// Limit to specific character set (useful for numbers/codes)
// request.customWords = ["specific", "domain", "terms"]
let handler = VNImageRequestHandler(cgImage: cgImage, options: [:])
do {
try handler.perform([request])
} catch {
continuation.resume(throwing: error)
}
}
}
Getting Bounding Boxes for Recognized Text
import Vision
import UIKit
struct RecognizedTextBlock {
let text: String
let confidence: Float
let boundingBox: CGRect // normalized coordinates (0,0) at bottom-left
}
func recognizeTextWithLocations(in image: UIImage) async throws -> [RecognizedTextBlock] {
guard let cgImage = image.cgImage else { return [] }
return try await withCheckedThrowingContinuation { continuation in
let request = VNRecognizeTextRequest { request, error in
if let error {
continuation.resume(throwing: error)
return
}
let results = (request.results as? [VNRecognizedTextObservation]) ?? []
let blocks = results.compactMap { observation -> RecognizedTextBlock? in
guard let candidate = observation.topCandidates(1).first else { return nil }
return RecognizedTextBlock(
text: candidate.string,
confidence: candidate.confidence,
boundingBox: observation.boundingBox
)
}
continuation.resume(returning: blocks)
}
request.recognitionLevel = .accurate
request.usesLanguageCorrection = true
let handler = VNImageRequestHandler(cgImage: cgImage, options: [:])
do {
try handler.perform([request])
} catch {
continuation.resume(throwing: error)
}
}
}
3. Face Detection and Landmarks
Detect Face Rectangles
import Vision
import UIKit
func detectFaces(in image: UIImage) async throws -> [VNFaceObservation] {
guard let cgImage = image.cgImage else { return [] }
return try await withCheckedThrowingContinuation { continuation in
let request = VNDetectFaceRectanglesRequest { request, error in
if let error {
continuation.resume(throwing: error)
return
}
let faces = (request.results as? [VNFaceObservation]) ?? []
continuation.resume(returning: faces)
}
let handler = VNImageRequestHandler(cgImage: cgImage, orientation: .up, options: [:])
do {
try handler.perform([request])
} catch {
continuation.resume(throwing: error)
}
}
}
Detect Face Landmarks (Eyes, Nose, Mouth, etc.)
import Vision
import UIKit
struct FaceDetail {
let boundingBox: CGRect
let roll: NSNumber?
let yaw: NSNumber?
let leftEye: [CGPoint]?
let rightEye: [CGPoint]?
let nose: [CGPoint]?
let outerLips: [CGPoint]?
}
func detectFaceLandmarks(in image: UIImage) async throws -> [FaceDetail] {
guard let cgImage = image.cgImage else { return [] }
return try await withCheckedThrowingContinuation { continuation in
let request = VNDetectFaceLandmarksRequest { request, error in
if let error {
continuation.resume(throwing: error)
return
}
let faces = (request.results as? [VNFaceObservation]) ?? []
let details = faces.map { face in
let landmarks = face.landmarks
return FaceDetail(
boundingBox: face.boundingBox,
roll: face.roll,
yaw: face.yaw,
leftEye: landmarks?.leftEye?.normalizedPoints.map { CGPoint(x: $0.x, y: $0.y) },
rightEye: landmarks?.rightEye?.normalizedPoints.map { CGPoint(x: $0.x, y: $0.y) },
nose: landmarks?.nose?.normalizedPoints.map { CGPoint(x: $0.x, y: $0.y) },
outerLips: landmarks?.outerLips?.normalizedPoints.map { CGPoint(x: $0.x, y: $0.y) }
)
}
continuation.resume(returning: details)
}
let handler = VNImageRequestHandler(cgImage: cgImage, orientation: .up, options: [:])
do {
try handler.perform([request])
} catch {
continuation.resume(throwing: error)
}
}
}
4. Barcode and QR Code Detection
import Vision
import UIKit
struct DetectedBarcode {
let payload: String
let symbology: VNBarcodeSymbology
let boundingBox: CGRect
}
func detectBarcodes(in image: UIImage) async throws -> [DetectedBarcode] {
guard let cgImage = image.cgImage else { return [] }
return try await withCheckedThrowingContinuation { continuation in
let request = VNDetectBarcodesRequest { request, error in
if let error {
continuation.resume(throwing: error)
return
}
let results = (request.results as? [VNBarcodeObservation]) ?? []
let barcodes = results.compactMap { observation -> DetectedBarcode? in
guard let payload = observation.payloadStringValue else { return nil }
return DetectedBarcode(
payload: payload,
symbology: observation.symbology,
boundingBox: observation.boundingBox
)
}
continuation.resume(returning: barcodes)
}
// Limit to specific symbologies for better performance
request.symbologies = [
.qr,
.ean13,
.ean8,
.code128,
.code39,
.upce,
.pdf417,
.aztec,
.dataMatrix
]
let handler = VNImageRequestHandler(cgImage: cgImage, options: [:])
do {
try handler.perform([request])
} catch {
continuation.resume(throwing: error)
}
}
}
5. Person Segmentation (Background Removal)
import Vision
import UIKit
import CoreImage
import CoreImage.CIFilterBuiltins
@available(iOS 15.0, *)
func removeBackground(from image: UIImage) async throws -> UIImage? {
guard let cgImage = image.cgImage else { return nil }
return try await withCheckedThrowingContinuation { continuation in
let request = VNGeneratePersonSegmentationRequest { request, error in
if let error {
continuation.resume(throwing: error)
return
}
guard let result = (request.results as? [VNPixelBufferObservation])?.first else {
continuation.resume(returning: nil)
return
}
let maskImage = CIImage(cvPixelBuffer: result.pixelBuffer)
let originalImage = CIImage(cgImage: cgImage)
// Scale mask to match original image size
let scaleX = originalImage.extent.width / maskImage.extent.width
let scaleY = originalImage.extent.height / maskImage.extent.height
let scaledMask = maskImage.transformed(by: CGAffineTransform(scaleX: scaleX, y: scaleY))
// Apply mask using CIBlendWithMask
let filter = CIFilter.blendWithMask()
filter.inputImage = originalImage
filter.backgroundImage = CIImage(color: .clear).cropped(to: originalImage.extent)
filter.maskImage = scaledMask
guard let outputCIImage = filter.outputImage else {
continuation.resume(returning: nil)
return
}
let context = CIContext()
guard let outputCGImage = context.createCGImage(outputCIImage, from: outputCIImage.extent) else {
continuation.resume(returning: nil)
return
}
let result = UIImage(cgImage: outputCGImage)
continuation.resume(returning: result)
}
// Quality levels: .balanced (default), .accurate, .fast
request.qualityLevel = .accurate
let handler = VNImageRequestHandler(cgImage: cgImage, options: [:])
do {
try handler.perform([request])
} catch {
continuation.resume(throwing: error)
}
}
}
6. Object Tracking in Video
import Vision
import AVFoundation
class ObjectTracker {
private var trackingRequest: VNTrackObjectRequest?
private let sequenceHandler = VNSequenceRequestHandler()
/// Start tracking an object defined by an initial bounding box
func startTracking(initialBoundingBox: CGRect) {
let observation = VNDetectedObjectObservation(boundingBox: initialBoundingBox)
trackingRequest = VNTrackObjectRequest(detectedObjectObservation: observation) { [weak self] request, error in
guard let results = request.results as? [VNDetectedObjectObservation],
let trackedObject = results.first else { return }
if trackedObject.confidence < 0.3 {
// Object lost, stop tracking
self?.trackingRequest = nil
return
}
// Update tracking for next frame
self?.trackingRequest = VNTrackObjectRequest(detectedObjectObservation: trackedObject)
}
trackingRequest?.trackingLevel = .accurate
}
/// Process a new video frame
func processFrame(_ pixelBuffer: CVPixelBuffer) throws -> CGRect? {
guard let request = trackingRequest else { return nil }
try sequenceHandler.perform([request], on: pixelBuffer, orientation: .up)
guard let results = request.results as? [VNDetectedObjectObservation],
let tracked = results.first else { return nil }
return tracked.boundingBox
}
}
7. Image Classification
import Vision
import UIKit
func classifyImage(_ image: UIImage) async throws -> [(identifier: String, confidence: Float)] {
guard let cgImage = image.cgImage else { return [] }
return try await withCheckedThrowingContinuation { continuation in
let request = VNClassifyImageRequest { request, error in
if let error {
continuation.resume(throwing: error)
return
}
let results = (request.results as? [VNClassificationObservation]) ?? []
let topResults = results
.filter { $0.confidence > 0.1 }
.prefix(10)
.map { (identifier: $0.identifier, confidence: $0.confidence) }
continuation.resume(returning: topResults)
}
let handler = VNImageRequestHandler(cgImage: cgImage, options: [:])
do {
try handler.perform([request])
} catch {
continuation.resume(throwing: error)
}
}
}
8. VisionKit -- DataScannerViewController (iOS 16+)
Live camera scanning for text and barcodes with a system-provided UI.
import SwiftUI
import VisionKit
@available(iOS 16.0, *)
struct DataScannerView: UIViewControllerRepresentable {
@Binding var scannedText: String
@Binding var scannedBarcode: String
let scanType: DataScannerViewController.RecognizedDataType
func makeUIViewController(context: Context) -> DataScannerViewController {
let scanner = DataScannerViewController(
recognizedDataTypes: [scanType],
qualityLevel: .balanced,
recognizesMultipleItems: false,
isHighFrameRateTrackingEnabled: true,
isHighlightingEnabled: true
)
scanner.delegate = context.coordinator
return scanner
}
func updateUIViewController(_ uiViewController: DataScannerViewController, context: Context) {}
func makeCoordinator() -> Coordinator {
Coordinator(parent: self)
}
class Coordinator: NSObject, DataScannerViewControllerDelegate {
let parent: DataScannerView
init(parent: DataScannerView) {
self.parent = parent
}
func dataScanner(_ dataScanner: DataScannerViewController, didTapOn item: RecognizedItem) {
switch item {
case .text(let text):
parent.scannedText = text.transcript
case .barcode(let barcode):
parent.scannedBarcode = barcode.payloadStringValue ?? ""
@unknown default:
break
}
}
func dataScanner(_ dataScanner: DataScannerViewController, didAdd addedItems: [RecognizedItem], allItems: [RecognizedItem]) {
// Handle newly recognized items
for item in addedItems {
switch item {
case .text(let text):
parent.scannedText = text.transcript
case .barcode(let barcode):
parent.scannedBarcode = barcode.payloadStringValue ?? ""
@unknown default:
break
}
}
}
}
}
@available(iOS 16.0, *)
struct ScannerContainerView: View {
@State private var scannedText = ""
@State private var scannedBarcode = ""
@State private var isShowingScanner = false
var body: some View {
VStack(spacing: 20) {
if DataScannerViewController.isSupported && DataScannerViewController.isAvailable {
Button("Scan Text") {
isShowingScanner = true
}
.buttonStyle(.borderedProminent)
if !scannedText.isEmpty {
Text("Scanned: \(scannedText)")
.padding()
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
}
} else {
ContentUnavailableView("Scanner Not Available",
systemImage: "camera.fill",
description: Text("This device does not support data scanning."))
}
}
.sheet(isPresented: $isShowingScanner) {
DataScannerView(
scannedText: $scannedText,
scannedBarcode: $scannedBarcode,
scanType: .text()
)
.ignoresSafeArea()
}
}
}
9. ImageAnalyzer and ImageAnalysisInteraction (iOS 16+)
Enable Live Text on any image view -- users can select, copy, translate, and interact with text in images.
import SwiftUI
import VisionKit
@available(iOS 16.0, *)
struct LiveTextImageView: UIViewRepresentable {
let image: UIImage
func makeUIView(context: Context) -> UIImageView {
let imageView = UIImageView(image: image)
imageView.contentMode = .scaleAspectFit
imageView.isUserInteractionEnabled = true
let interaction = ImageAnalysisInteraction()
interaction.preferredInteractionTypes = [.textSelection, .dataDetectors]
imageView.addInteraction(interaction)
Task {
let analyzer = ImageAnalyzer()
let configuration = ImageAnalyzer.Configuration([.text, .machineReadableCode])
do {
let analysis = try await analyzer.analyze(image, configuration: configuration)
await MainActor.run {
interaction.analysis = analysis
}
} catch {
print("Image analysis failed: \(error.localizedDescription)")
}
}
return imageView
}
func updateUIView(_ uiView: UIImageView, context: Context) {
uiView.image = image
}
}
@available(iOS 16.0, *)
struct LiveTextDemoView: View {
let sampleImage: UIImage
var body: some View {
NavigationStack {
LiveTextImageView(image: sampleImage)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.navigationTitle("Live Text")
.navigationBarTitleDisplayMode(.inline)
}
}
}
10. Complete Multi-Request Pipeline
Run multiple Vision requests together for maximum efficiency.
import Vision
import UIKit
struct ImageAnalysisResult {
var recognizedText: [String] = []
var faceCount: Int = 0
var barcodes: [String] = []
var classifications: [(String, Float)] = []
}
func analyzeImage(_ image: UIImage) async throws -> ImageAnalysisResult {
guard let cgImage = image.cgImage else {
throw NSError(domain: "Vision", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid image"])
}
return try await withCheckedThrowingContinuation { continuation in
var result = ImageAnalysisResult()
let group = DispatchGroup()
// Text recognition
group.enter()
let textRequest = VNRecognizeTextRequest { request, _ in
defer { group.leave() }
let observations = (request.results as? [VNRecognizedTextObservation]) ?? []
result.recognizedText = observations.compactMap { $0.topCandidates(1).first?.string }
}
textRequest.recognitionLevel = .accurate
// Face detection
group.enter()
let faceRequest = VNDetectFaceRectanglesRequest { request, _ in
defer { group.leave() }
result.faceCount = (request.results as? [VNFaceObservation])?.count ?? 0
}
// Barcode detection
group.enter()
let barcodeRequest = VNDetectBarcodesRequest { request, _ in
defer { group.leave() }
let observations = (request.results as? [VNBarcodeObservation]) ?? []
result.barcodes = observations.compactMap { $0.payloadStringValue }
}
// Image classification
group.enter()
let classifyRequest = VNClassifyImageRequest { request, _ in
defer { group.leave() }
let observations = (request.results as? [VNClassificationObservation]) ?? []
result.classifications = observations
.filter { $0.confidence > 0.1 }
.prefix(5)
.map { ($0.identifier, $0.confidence) }
}
group.notify(queue: .main) {
continuation.resume(returning: result)
}
// Perform all requests together -- Vision optimizes shared preprocessing
let handler = VNImageRequestHandler(cgImage: cgImage, options: [:])
do {
try handler.perform([textRequest, faceRequest, barcodeRequest, classifyRequest])
} catch {
continuation.resume(throwing: error)
}
}
}
Quick Reference
Request
Observation Type
Purpose
VNRecognizeTextRequest
VNRecognizedTextObservation
OCR -- extract text from images
VNDetectFaceRectanglesRequest
VNFaceObservation
Locate faces
VNDetectFaceLandmarksRequest
VNFaceObservation
Eyes, nose, mouth positions
VNDetectBarcodesRequest
VNBarcodeObservation
QR codes, barcodes
VNGeneratePersonSegmentationRequest
VNPixelBufferObservation
Background removal mask
VNTrackObjectRequest
VNDetectedObjectObservation
Track objects across frames
VNClassifyImageRequest
VNClassificationObservation
Scene/object classification
VNCoreMLRequest
varies
Run custom CoreML models
VisionKit Class
Purpose
DataScannerViewController
Live camera text/barcode scanning UI (iOS 16+)
ImageAnalyzer
Analyze images for Live Text content (iOS 16+)
ImageAnalysisInteraction
Add text selection to image views (iOS 16+)
---
# Network.framework
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-network-framework.html
← Back to all frameworks & guides
All articles →Networking and Connectivity · Reference guideNetwork.frameworkRepository guidance for Network Framework. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
1. The three facts that shape every design here
2. Path monitoring
3. A framed TCP connection
4. TLS
5. Listening (local peer-to-peer)
Anti-Patterns
Checklist
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Select transport parameters
↓2
Start connection
↓3
Send and receive framed data
↓4
Handle connection transitions
02 / ArchitectureResponsibility boundariesBoundary 1
Connection stateBoundary 2
Transport channelBoundary 3
Message handlingConnected responsibilities, not a required class hierarchy or an execution trace.
Load this when:
you need a socket rather than an HTTP request — a custom
protocol, a persistent TCP or UDP connection, a local peer-to-peer listener, TLS
you must configure yourself — or when you need to observe connectivity changes
with
NWPathMonitor
.
Do not load this for HTTP.
URLSession
already sits on Network.framework
and gives you caching, cookies, redirects, retries, background transfers, and
ATS compliance for free. Reimplementing HTTP over
NWConnection
is a large
amount of work to arrive somewhere worse. See
docs/frameworks/networking.md
.
Availability:
Network.framework iOS 12+;
NWBrowser
iOS 13+; the types are
not
Sendable
, and their callbacks arrive on a queue you supply.
1. The three facts that shape every design here
1. NWConnection is not Sendable and its handlers run on your queue.
Every state and receive callback lands on the
DispatchQueue
passed to
start(queue:)
. Touching
@MainActor
state from inside one is a data race and
a Swift 6 error. The fix is not to hop with
DispatchQueue.main.async
— it is to
own the connection inside an
actor
and expose an
AsyncStream
outward.
2. TCP is a byte stream, not a message stream.
receive
gives you whatever
bytes have arrived. One
send
of 100 bytes may arrive as four callbacks; three
sends may arrive as one. Any code that treats one receive as one message works
perfectly on localhost and fails on a real network. You must frame — length
prefix, delimiter, or
NWProtocolFramer
.
3. .ready is not "connected forever".
Connections go
.waiting
when the
path is unsatisfied and can return to
.ready
on their own. Tearing down on
.waiting
throws away the recovery the framework is doing for you; treating
.failed
as recoverable leaks a dead connection. They are different states and
need different handling.
2. Path monitoring
The simplest correct thing in the framework, and the most commonly misused.
import Network
public enum Connectivity: Equatable, Sendable {
case satisfied(isExpensive: Bool, isConstrained: Bool)
case unsatisfied
}
/// An actor, because NWPathMonitor's handler fires on a background queue and
/// the value it produces is read from the main actor.
public actor NetworkMonitor {
private let monitor = NWPathMonitor()
private let queue = DispatchQueue(label: "NetworkMonitor")
private var continuations: [UUID: AsyncStream<Connectivity>.Continuation] = [:]
private var current: Connectivity = .unsatisfied
private var isStarted = false
public init() {}
/// A fresh stream per caller. One shared stream means the second observer
/// steals elements from the first.
public func updates() -> AsyncStream<Connectivity> {
let id = UUID()
return AsyncStream { continuation in
continuations[id] = continuation
continuation.yield(current) // current value, not just changes
continuation.onTermination = { [weak self] _ in
Task { await self?.remove(id) }
}
start()
}
}
private func remove(_ id: UUID) {
continuations[id] = nil
}
private func start() {
guard !isStarted else { return }
isStarted = true
monitor.pathUpdateHandler = { [weak self] path in
// Fires on `queue`. Hop back onto the actor rather than touching
// state from here.
let value: Connectivity = path.status == .satisfied
? .satisfied(isExpensive: path.isExpensive, isConstrained: path.isConstrained)
: .unsatisfied
Task { await self?.publish(value) }
}
monitor.start(queue: queue)
}
private func publish(_ value: Connectivity) {
current = value
for continuation in continuations.values {
continuation.yield(value)
}
}
deinit {
monitor.cancel()
}
}
isExpensive and isConstrained are not decoration.
isExpensive
means
cellular or a personal hotspot;
isConstrained
means the user turned on Low
Data Mode and explicitly asked you to use less. Ignoring the second is ignoring a
direct instruction from the user, and App Review does notice.
Never use path status as a precondition.
.satisfied
means a route exists,
not that your server is reachable. Code that refuses to try because the monitor
says unsatisfied fails on captive portals and VPN transitions where the request
would have worked. Make the request; handle the failure.
3. A framed TCP connection
import Foundation
import Network
public enum ConnectionError: LocalizedError {
case failed(NWError)
case closedByPeer
case messageTooLarge(Int)
case malformedFrame
public var errorDescription: String? {
switch self {
case .failed: String(localized: "Lost the connection. Trying again.")
case .closedByPeer: String(localized: "The other side disconnected.")
case .messageTooLarge, .malformedFrame:
String(localized: "Received unreadable data.")
}
}
}
/// Owns the connection. Nothing outside this actor touches NWConnection, which
/// is what makes the non-Sendable type safe to use.
public actor MessageConnection {
private let connection: NWConnection
private let queue = DispatchQueue(label: "MessageConnection")
/// Bytes received but not yet forming a complete frame.
private var buffer = Data()
private var messages: AsyncStream<Data>.Continuation?
private var readyContinuation: CheckedContinuation<Void, any Error>?
private var hasResumedReady = false
/// A ceiling on any single frame. Without it, a hostile or buggy peer
/// sending a 4 GB length prefix makes the app allocate until it is killed.
private let maximumFrameSize: Int
public init(host: String, port: UInt16, useTLS: Bool = true, maximumFrameSize: Int = 4 << 20) {
let parameters: NWParameters = useTLS ? .tls : .tcp
// Disable Nagle for latency-sensitive small messages. Leave it on for
// throughput-oriented traffic — turning it off there costs bandwidth.
if let tcp = parameters.defaultProtocolStack.internetProtocol as? NWProtocolTCP.Options {
tcp.noDelay = true
tcp.connectionTimeout = 10
}
self.connection = NWConnection(
host: NWEndpoint.Host(host),
port: NWEndpoint.Port(rawValue: port)!,
using: parameters
)
self.maximumFrameSize = maximumFrameSize
}
// MARK: Lifecycle
public func connect() async throws {
connection.stateUpdateHandler = { [weak self] state in
Task { await self?.handle(state) }
}
connection.start(queue: queue)
try await withCheckedThrowingContinuation { continuation in
readyContinuation = continuation
}
receiveLoop()
}
private func handle(_ state: NWConnection.State) {
switch state {
case .ready:
resumeReady(with: .success(()))
case .waiting(let error):
// NOT a failure. The path is temporarily unsatisfied and the
// framework is already retrying. Tearing down here throws away the
// recovery it is doing for you.
log("waiting: \(error)")
case .failed(let error):
// Terminal. This one does need a new connection.
resumeReady(with: .failure(ConnectionError.failed(error)))
messages?.finish()
case .cancelled:
messages?.finish()
case .setup, .preparing:
break
@unknown default:
break
}
}
/// A continuation must be resumed exactly once. `.waiting` -> `.ready` ->
/// `.failed` is a normal sequence, so without this guard a flaky network
/// crashes the app with "resumed more than once".
private func resumeReady(with result: Result<Void, any Error>) {
guard !hasResumedReady, let continuation = readyContinuation else { return }
hasResumedReady = true
readyContinuation = nil
continuation.resume(with: result)
}
public func cancel() {
connection.cancel()
messages?.finish()
}
// MARK: Sending
/// Length-prefixed framing: 4-byte big-endian count, then the payload.
public func send(_ payload: Data) async throws {
guard payload.count <= maximumFrameSize else {
throw ConnectionError.messageTooLarge(payload.count)
}
var frame = Data()
withUnsafeBytes(of: UInt32(payload.count).bigEndian) { frame.append(contentsOf: $0) }
frame.append(payload)
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, any Error>) in
connection.send(content: frame, completion: .contentProcessed { error in
if let error {
continuation.resume(throwing: ConnectionError.failed(error))
} else {
continuation.resume()
}
})
}
}
// MARK: Receiving
public func incomingMessages() -> AsyncStream<Data> {
AsyncStream { continuation in
messages = continuation
}
}
private func receiveLoop() {
connection.receive(minimumIncompleteLength: 1, maximumLength: 65_536) {
[weak self] content, _, isComplete, error in
Task { await self?.handleReceive(content, isComplete: isComplete, error: error) }
}
}
private func handleReceive(_ content: Data?, isComplete: Bool, error: NWError?) {
if let error {
log("receive failed: \(error)")
messages?.finish()
return
}
if let content, !content.isEmpty {
buffer.append(content)
drainFrames()
}
if isComplete {
messages?.finish()
return
}
receiveLoop() // re-arm; `receive` delivers once per call
}
/// TCP is a byte stream. One receive is not one message — this is where
/// code that assumes otherwise breaks the moment it leaves localhost.
private func drainFrames() {
while buffer.count >= 4 {
let length = buffer.prefix(4).withUnsafeBytes {
Int($0.load(as: UInt32.self).bigEndian)
}
guard length <= maximumFrameSize else {
log("frame of \(length) exceeds the ceiling; closing")
connection.cancel()
messages?.finish()
return
}
guard buffer.count >= 4 + length else { return } // partial frame; wait
let payload = buffer.subdata(in: 4..<(4 + length))
buffer.removeSubrange(0..<(4 + length))
messages?.yield(payload)
}
}
private func log(_ message: String) {
// OSLog in production — see docs/frameworks/oslog.md.
}
}
4. TLS
NWParameters.tls
gives you the system defaults, which are correct. Only reach
for
sec_protocol_options
when you have a specific requirement — a pinned
certificate, or a private CA.
import CryptoKit
import Network
func pinnedParameters(expectedSPKISHA256: Data) -> NWParameters {
let options = NWProtocolTLS.Options()
sec_protocol_options_set_verify_block(
options.securityProtocolOptions,
{ _, trustRef, complete in
let trust = sec_trust_copy_ref(trustRef).takeRetainedValue()
// Evaluate the chain normally FIRST. Pinning replaces nothing —
// it is an additional constraint on top of a valid chain.
var error: CFError?
guard SecTrustEvaluateWithError(trust, &error) else {
return complete(false)
}
guard let chain = SecTrustCopyCertificateChain(trust) as? [SecCertificate],
let leaf = chain.first,
let publicKey = SecCertificateCopyKey(leaf),
let spki = SecKeyCopyExternalRepresentation(publicKey, nil) as Data?
else {
return complete(false)
}
// Pin the public key, not the certificate: the key survives
// certificate renewal, so routine rotation does not brick the app.
let digest = Data(SHA256.hash(data: spki))
complete(digest == expectedSPKISHA256)
},
DispatchQueue(label: "TLSVerify")
)
return NWParameters(tls: options)
}
Pin at least two keys — the current one and a backup you control.
A single
pin turns a lost private key into an app that cannot reach its own server until
every user installs an update. Ship an expiry date for the pin set, after which
the app falls back to standard validation rather than becoming a brick.
5. Listening (local peer-to-peer)
public actor MessageListener {
private let listener: NWListener
private let queue = DispatchQueue(label: "MessageListener")
private var connections: [ObjectIdentifier: NWConnection] = [:]
public init(port: UInt16, service: String) throws {
let parameters = NWParameters.tls
parameters.includePeerToPeer = true // AWDL, for local discovery
listener = try NWListener(
using: parameters,
on: NWEndpoint.Port(rawValue: port) ?? .any
)
// Bonjour advertisement. The type must also appear in
// NSBonjourServices in Info.plist or discovery silently fails.
listener.service = NWListener.Service(type: service)
}
public func start() {
listener.newConnectionHandler = { [weak self] connection in
Task { await self?.accept(connection) }
}
listener.stateUpdateHandler = { state in
// .failed here is usually a port already in use.
}
listener.start(queue: queue)
}
private func accept(_ connection: NWConnection) {
// Retain it. A connection that goes out of scope is cancelled
// immediately, and the symptom is peers that connect then vanish.
connections[ObjectIdentifier(connection)] = connection
connection.stateUpdateHandler = { [weak self] state in
switch state {
case .cancelled, .failed:
Task { await self?.drop(connection) }
default:
break
}
}
connection.start(queue: queue)
}
private func drop(_ connection: NWConnection) {
connections[ObjectIdentifier(connection)] = nil
}
}
Local networking needs
both
Info.plist keys since iOS 14, or the first
connection attempt fails with no useful diagnostic:
<key>NSLocalNetworkUsageDescription</key>
<string>Find and connect to nearby devices running this app.</string>
<key>NSBonjourServices</key>
<array>
<string>_myapp._tcp</string>
</array>
Anti-Patterns
// WRONG — one receive treated as one message.
// Works on localhost, fails on a real network: TCP is a byte stream, so a
// 100-byte send may arrive as four callbacks and three sends as one.
connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { data, _, _, _ in
let message = try? JSONDecoder().decode(Message.self, from: data!)
}
// RIGHT — buffer, then extract complete frames.
buffer.append(data); drainFrames()
// WRONG — an unbounded length prefix.
// A peer sending 0xFFFFFFFF makes the app allocate 4 GB and get killed.
guard buffer.count >= 4 + length else { return }
// RIGHT — a ceiling, checked before trusting the length.
guard length <= maximumFrameSize else { connection.cancel(); return }
// WRONG — .waiting treated as a failure.
// The path is temporarily unsatisfied and the framework is already retrying.
// Tearing down discards that recovery and usually starts a reconnect storm.
case .waiting: reconnect()
// RIGHT — log it and let the framework recover; act on .failed.
case .waiting(let error): log(error)
case .failed(let error): reconnect()
// WRONG — a continuation resumed from a state handler with no guard.
// .waiting -> .ready -> .failed is a normal sequence on a flaky network, and
// resuming twice is a hard crash.
case .ready: continuation.resume()
case .failed(let e): continuation.resume(throwing: e)
// RIGHT — resume exactly once.
private func resumeReady(with result: Result<Void, any Error>) { guard !hasResumedReady … }
// WRONG — receive called once.
// `receive` delivers a single callback. Without re-arming, exactly one chunk
// ever arrives and the connection appears to hang.
connection.receive(…) { data, _, _, _ in self.handle(data) }
// RIGHT — call receive again at the end of every callback.
// WRONG — NWConnection state mutated from its own handler on a @MainActor type.
// The handler runs on the connection's queue. This is a data race and a Swift 6
// error, and DispatchQueue.main.async is not the fix.
@MainActor final class Client { var isConnected = false
func start() { c.stateUpdateHandler = { s in self.isConnected = (s == .ready) } } }
// RIGHT — the connection lives in an actor; the outside world gets an AsyncStream.
// WRONG — an accepted connection not retained.
// It deallocates at the end of the handler and is cancelled immediately. The
// symptom is peers that connect and instantly vanish.
listener.newConnectionHandler = { connection in connection.start(queue: q) }
// RIGHT — hold it until it is cancelled or fails.
connections[ObjectIdentifier(connection)] = connection
// WRONG — path status as a precondition.
// .satisfied means a route exists, not that your server is reachable. This
// refuses to try on captive portals and VPN transitions where it would work.
guard monitor.currentPath.status == .satisfied else { throw .offline }
// RIGHT — attempt the request; handle the failure.
// WRONG — ignoring isConstrained.
// Low Data Mode is the user explicitly asking you to use less. Prefetching
// through it ignores a direct instruction.
await prefetchEverything()
// RIGHT
if case .satisfied(_, let isConstrained) = connectivity, !isConstrained { await prefetch() }
// WRONG — TLS verification disabled to "make it work in dev".
// This ships. It always ships.
sec_protocol_options_set_verify_block(opts, { _, _, complete in complete(true) }, q)
// RIGHT — a real certificate in dev, or a pin scoped to a DEBUG build with a
// release branch that ignores the flag entirely.
// WRONG — a single pinned certificate.
// Routine certificate renewal bricks every installed copy of the app.
let pinned = [productionCertificateHash]
// RIGHT — pin public keys (which survive renewal), at least two, with an expiry.
let pinned = [currentSPKIHash, backupSPKIHash]
// WRONG — Network.framework for HTTP.
// Reimplements caching, redirects, cookies, retries, and ATS, worse.
let connection = NWConnection(host: "api.example.com", port: 443, using: .tls)
// RIGHT
let (data, response) = try await URLSession.shared.data(for: request)
Checklist
[ ]
URLSession
ruled out for a stated reason before reaching for this
[ ]
NWConnection
owned by an actor; nothing outside it touches the connection
[ ] Messages framed; a receive is never assumed to be one message
[ ] A maximum frame size, checked before allocating
[ ]
receive
re-armed at the end of every callback
[ ]
.waiting
and
.failed
handled differently
[ ] Every continuation resumed exactly once, guarded
[ ] Accepted connections retained until cancelled
[ ] Path status used as a hint, never as a precondition
[ ]
isConstrained
respected — Low Data Mode is an instruction
[ ] TLS verification never disabled, in any build that can ship
[ ] Pins on public keys, at least two, with an expiry fallback
[ ]
NSLocalNetworkUsageDescription
and
NSBonjourServices
present for local discovery
[ ] Connections cancelled on
scenePhase
background — a live socket drains battery
---
# Networking
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-networking.html
← Back to all frameworks & guides
All articles →Networking and Connectivity · Reference guideNetworkingRepository guidance for Networking. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
URLSession Async/Await Patterns
Generic API Client
Authentication — Bearer Token and OAuth
Error Handling and Retry Logic
Multipart Form Data Upload
WebSocket
Network Monitoring
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Build a typed request
↓2
Await URLSession response
↓3
Validate and decode payload
↓4
Map failures to UI state
02 / ArchitectureResponsibility boundariesBoundary 1
View modelBoundary 2
API clientBoundary 3
Remote endpointConnected responsibilities, not a required class hierarchy or an execution trace.
URLSession Async/Await Patterns
// GET request
func fetchUsers() async throws -> [User] {
let url = URL(string: "https://api.example.com/users")!
let (data, response) = try await URLSession.shared.data(from: url)
guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else {
throw APIError.badResponse
}
return try JSONDecoder().decode([User].self, from: data)
}
// POST request
func createUser(_ user: CreateUserRequest) async throws -> User {
var request = URLRequest(url: URL(string: "https://api.example.com/users")!)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONEncoder().encode(user)
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse, http.statusCode == 201 else {
throw APIError.badResponse
}
return try JSONDecoder().decode(User.self, from: data)
}
// Download with progress using AsyncBytes
func downloadWithProgress(from url: URL) async throws -> Data {
let (bytes, response) = try await URLSession.shared.bytes(from: url)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
throw APIError.badResponse
}
let totalBytes = Int(http.expectedContentLength)
var data = Data(capacity: totalBytes)
for try await byte in bytes {
data.append(byte)
let progress = Double(data.count) / Double(totalBytes)
await MainActor.run { self.downloadProgress = progress }
}
return data
}
Generic API Client
enum HTTPMethod: String {
case get = "GET", post = "POST", put = "PUT", patch = "PATCH", delete = "DELETE"
}
enum APIError: LocalizedError {
case badURL
case badResponse
case unauthorized
case notFound
case serverError(statusCode: Int)
case decodingError(Error)
case networkError(Error)
var errorDescription: String? {
switch self {
case .badURL: return "Invalid URL"
case .badResponse: return "Bad server response"
case .unauthorized: return "Authentication required"
case .notFound: return "Resource not found"
case .serverError(let code): return "Server error (\(code))"
case .decodingError(let err): return "Decoding failed: \(err.localizedDescription)"
case .networkError(let err): return "Network error: \(err.localizedDescription)"
}
}
}
actor APIClient {
static let shared = APIClient()
private let baseURL: URL
private let session: URLSession
private let decoder: JSONDecoder
private let encoder: JSONEncoder
private var authToken: String?
init(
baseURL: URL = URL(string: "https://api.example.com")!,
session: URLSession = .shared
) {
self.baseURL = baseURL
self.session = session
self.decoder = JSONDecoder()
self.decoder.keyDecodingStrategy = .convertFromSnakeCase
self.decoder.dateDecodingStrategy = .iso8601
self.encoder = JSONEncoder()
self.encoder.keyEncodingStrategy = .convertToSnakeCase
self.encoder.dateEncodingStrategy = .iso8601
}
func setToken(_ token: String?) {
self.authToken = token
}
func request<T: Decodable>(
_ method: HTTPMethod,
path: String,
body: (any Encodable)? = nil,
queryItems: [URLQueryItem]? = nil
) async throws -> T {
var components = URLComponents(url: baseURL.appendingPathComponent(path), resolvingAgainstBaseURL: true)!
components.queryItems = queryItems
guard let url = components.url else { throw APIError.badURL }
var request = URLRequest(url: url)
request.httpMethod = method.rawValue
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("application/json", forHTTPHeaderField: "Accept")
if let token = authToken {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
if let body {
request.httpBody = try encoder.encode(body)
}
let (data, response): (Data, URLResponse)
do {
(data, response) = try await session.data(for: request)
} catch {
throw APIError.networkError(error)
}
guard let http = response as? HTTPURLResponse else {
throw APIError.badResponse
}
switch http.statusCode {
case 200...299:
do {
return try decoder.decode(T.self, from: data)
} catch {
throw APIError.decodingError(error)
}
case 401:
throw APIError.unauthorized
case 404:
throw APIError.notFound
default:
throw APIError.serverError(statusCode: http.statusCode)
}
}
}
// Usage
let users: [User] = try await APIClient.shared.request(.get, path: "/users")
let newUser: User = try await APIClient.shared.request(.post, path: "/users", body: CreateUserRequest(name: "Alice"))
Authentication — Bearer Token and OAuth
actor AuthManager {
static let shared = AuthManager()
private var accessToken: String?
private var refreshToken: String?
private var tokenExpiry: Date?
private var refreshTask: Task<String, Error>?
func validToken() async throws -> String {
// Return cached token if still valid
if let token = accessToken, let expiry = tokenExpiry, expiry > Date() {
return token
}
// Deduplicate concurrent refresh calls
if let refreshTask {
return try await refreshTask.value
}
let task = Task<String, Error> {
defer { refreshTask = nil }
guard let refresh = refreshToken else { throw APIError.unauthorized }
let response = try await performTokenRefresh(refreshToken: refresh)
self.accessToken = response.accessToken
self.refreshToken = response.refreshToken
self.tokenExpiry = Date().addingTimeInterval(TimeInterval(response.expiresIn))
return response.accessToken
}
self.refreshTask = task
return try await task.value
}
private func performTokenRefresh(refreshToken: String) async throws -> TokenResponse {
var request = URLRequest(url: URL(string: "https://auth.example.com/token")!)
request.httpMethod = "POST"
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
let body = "grant_type=refresh_token&refresh_token=\(refreshToken)&client_id=\(clientId)"
request.httpBody = body.data(using: .utf8)
let (data, _) = try await URLSession.shared.data(for: request)
return try JSONDecoder().decode(TokenResponse.self, from: data)
}
}
struct TokenResponse: Codable {
let accessToken: String
let refreshToken: String
let expiresIn: Int
}
Error Handling and Retry Logic
func fetchWithRetry<T: Decodable>(
_ type: T.Type,
url: URL,
maxRetries: Int = 3,
delay: Duration = .seconds(1)
) async throws -> T {
var lastError: Error?
for attempt in 0..<maxRetries {
do {
let (data, response) = try await URLSession.shared.data(from: url)
guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else {
throw APIError.badResponse
}
return try JSONDecoder().decode(T.self, from: data)
} catch {
lastError = error
// Don't retry client errors (4xx)
if let apiError = error as? APIError, case .unauthorized = apiError { throw error }
if let apiError = error as? APIError, case .notFound = apiError { throw error }
if attempt < maxRetries - 1 {
let backoff = Duration.seconds(pow(2.0, Double(attempt)))
try await Task.sleep(for: backoff)
}
}
}
throw lastError ?? APIError.badResponse
}
Multipart Form Data Upload
func uploadMultipart(
imageData: Data,
filename: String,
fields: [String: String] = [:]
) async throws -> UploadResponse {
let boundary = UUID().uuidString
var request = URLRequest(url: URL(string: "https://api.example.com/upload")!)
request.httpMethod = "POST"
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
var body = Data()
// Text fields
for (key, value) in fields {
body.append("--\(boundary)\r\n".data(using: .utf8)!)
body.append("Content-Disposition: form-data; name=\"\(key)\"\r\n\r\n".data(using: .utf8)!)
body.append("\(value)\r\n".data(using: .utf8)!)
}
// File field
body.append("--\(boundary)\r\n".data(using: .utf8)!)
body.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\r\n".data(using: .utf8)!)
body.append("Content-Type: image/jpeg\r\n\r\n".data(using: .utf8)!)
body.append(imageData)
body.append("\r\n".data(using: .utf8)!)
body.append("--\(boundary)--\r\n".data(using: .utf8)!)
request.httpBody = body
let (data, _) = try await URLSession.shared.data(for: request)
return try JSONDecoder().decode(UploadResponse.self, from: data)
}
WebSocket
actor WebSocketClient {
private var webSocketTask: URLSessionWebSocketTask?
private let url: URL
init(url: URL) {
self.url = url
}
func connect() {
webSocketTask = URLSession.shared.webSocketTask(with: url)
webSocketTask?.resume()
listenForMessages()
}
func send(_ message: String) async throws {
try await webSocketTask?.send(.string(message))
}
func send(_ data: Data) async throws {
try await webSocketTask?.send(.data(data))
}
private func listenForMessages() {
webSocketTask?.receive { [weak self] result in
switch result {
case .success(let message):
switch message {
case .string(let text):
print("Received text: \(text)")
case .data(let data):
print("Received data: \(data.count) bytes")
@unknown default:
break
}
// Continue listening
self?.listenForMessages()
case .failure(let error):
print("WebSocket error: \(error)")
}
}
}
func disconnect() {
webSocketTask?.cancel(with: .normalClosure, reason: nil)
}
}
Network Monitoring
import Network
@Observable
class NetworkMonitor {
static let shared = NetworkMonitor()
var isConnected = true
var connectionType: ConnectionType = .unknown
enum ConnectionType {
case wifi, cellular, ethernet, unknown
}
private let monitor = NWPathMonitor()
private let queue = DispatchQueue(label: "NetworkMonitor")
init() {
monitor.pathUpdateHandler = { [weak self] path in
DispatchQueue.main.async {
self?.isConnected = path.status == .satisfied
self?.connectionType = self?.getConnectionType(path) ?? .unknown
}
}
monitor.start(queue: queue)
}
private func getConnectionType(_ path: NWPath) -> ConnectionType {
if path.usesInterfaceType(.wifi) { return .wifi }
if path.usesInterfaceType(.cellular) { return .cellular }
if path.usesInterfaceType(.wiredEthernet) { return .ethernet }
return .unknown
}
deinit {
monitor.cancel()
}
}
// Usage in SwiftUI
struct ContentView: View {
let networkMonitor = NetworkMonitor.shared
var body: some View {
Group {
if networkMonitor.isConnected {
MainContentView()
} else {
OfflineView()
}
}
}
}
---
# OSLog & MetricKit
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-oslog.html
← Back to all frameworks & guides
All articles →System Integration · Reference guideOSLog & MetricKitRepository guidance for OSLog. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
Logger Struct (iOS 14+)
Log Levels
String Interpolation Privacy
os_signpost for Performance Profiling
Structured Logging Utility
MXMetricManager (MetricKit)
Xcode Instruments Integration
Complete Logging Setup Example
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Define subsystem and category
↓2
Emit privacy-aware events
↓3
Reproduce the behavior
↓4
Inspect filtered logs
02 / ArchitectureResponsibility boundariesBoundary 1
App eventsBoundary 2
Structured loggingBoundary 3
Diagnostic readerConnected responsibilities, not a required class hierarchy or an execution trace.
Logger Struct (iOS 14+)
import OSLog
extension Logger {
/// Bundle identifier as default subsystem
private static let subsystem = Bundle.main.bundleIdentifier ?? "com.yourapp"
/// Loggers organized by category
static let network = Logger(subsystem: subsystem, category: "Network")
static let ui = Logger(subsystem: subsystem, category: "UI")
static let data = Logger(subsystem: subsystem, category: "DataStore")
static let auth = Logger(subsystem: subsystem, category: "Authentication")
static let payment = Logger(subsystem: subsystem, category: "Payment")
static let sync = Logger(subsystem: subsystem, category: "Sync")
}
Log Levels
func demonstrateLogLevels() {
// .debug — Verbose developer info. Not persisted by default. Not collected in production.
Logger.network.debug("Request headers: \(headers)")
// .info — Helpful but not essential. Persisted only during log collect.
Logger.network.info("Starting request to \(endpoint)")
// .notice (default) — Essential for troubleshooting. Persisted up to storage limit.
Logger.auth.notice("User signed in successfully")
// .error — Error conditions. Always persisted.
Logger.data.error("Failed to save context: \(error.localizedDescription)")
// .fault — Bug in the program. Always persisted. Captures calling process info.
Logger.data.fault("Core Data stack not initialized — this should never happen")
}
String Interpolation Privacy
func logWithPrivacy(userId: String, email: String, itemCount: Int) {
// Default: dynamic strings are PRIVATE (redacted in production logs)
Logger.auth.info("User logged in: \(userId)")
// Output in production: "User logged in: <private>"
// Explicitly mark as public when safe
Logger.ui.info("Screen loaded: \(screenName, privacy: .public)")
// Mark as private (default for dynamic values)
Logger.auth.info("Email: \(email, privacy: .private)")
// Hash private data for correlation without revealing values
Logger.auth.info("User hash: \(email, privacy: .private(mask: .hash))")
// Numeric values are PUBLIC by default
Logger.data.info("Items count: \(itemCount)")
// Format specifiers
Logger.payment.info("Amount: \(amount, format: .fixed(precision: 2), privacy: .private)")
// Boolean and numeric types
Logger.network.debug("Cache hit: \(isCached, privacy: .public), size: \(responseSize) bytes")
}
os_signpost for Performance Profiling
import os
// Create a signpost log
let pointsOfInterest = OSLog(subsystem: "com.yourapp", category: .pointsOfInterest)
let networkLog = OSLog(subsystem: "com.yourapp.network", category: "Requests")
// Mark an interval (begin + end)
func fetchData() async throws -> Data {
let signpostID = OSSignpostID(log: networkLog)
os_signpost(.begin, log: networkLog, name: "FetchData", signpostID: signpostID,
"URL: %{public}@", url.absoluteString)
let (data, _) = try await URLSession.shared.data(from: url)
os_signpost(.end, log: networkLog, name: "FetchData", signpostID: signpostID,
"Received %d bytes", data.count)
return data
}
// Mark a single event (point of interest)
func userTappedCheckout() {
os_signpost(.event, log: pointsOfInterest, name: "Checkout", "User tapped checkout button")
}
// Modern signpost API (iOS 15+)
let signposter = OSSignposter(subsystem: "com.yourapp", category: "Performance")
func modernSignpostExample() async throws {
let state = signposter.beginInterval("DataLoad")
let data = try await loadData()
signposter.emitEvent("DataReceived", "\(data.count) bytes")
signposter.endInterval("DataLoad", state)
}
// Automatic interval with withIntervalSignpost
func automaticSignpost() async throws -> [Item] {
try await signposter.withIntervalSignpost("FetchItems") {
try await api.fetchItems()
}
}
Structured Logging Utility
import OSLog
/// Centralized logging with context
enum AppLogger {
private static let logger = Logger(
subsystem: Bundle.main.bundleIdentifier ?? "com.yourapp",
category: "App"
)
static func logNetworkRequest(
method: String,
url: URL,
statusCode: Int?,
duration: TimeInterval,
error: Error? = nil
) {
if let error {
Logger.network.error("""
Network error — \(method, privacy: .public) \(url.absoluteString, privacy: .public) \
status=\(statusCode ?? 0) \
duration=\(duration, format: .fixed(precision: 3))s \
error=\(error.localizedDescription)
""")
} else {
Logger.network.info("""
Network — \(method, privacy: .public) \(url.absoluteString, privacy: .public) \
status=\(statusCode ?? 0) \
duration=\(duration, format: .fixed(precision: 3))s
""")
}
}
static func logUserAction(_ action: String, screen: String, metadata: [String: String] = [:]) {
let metaString = metadata.map { "\($0.key)=\($0.value)" }.joined(separator: ", ")
Logger.ui.notice("Action: \(action, privacy: .public) screen=\(screen, privacy: .public) \(metaString)")
}
static func logAppLifecycle(_ event: String) {
logger.notice("Lifecycle: \(event, privacy: .public)")
}
}
MXMetricManager (MetricKit)
import MetricKit
final class MetricsManager: NSObject, MXMetricManagerSubscriber {
static let shared = MetricsManager()
func startCollecting() {
MXMetricManager.shared.add(self)
}
func stopCollecting() {
MXMetricManager.shared.remove(self)
}
// Called once per day with aggregated metrics
func didReceive(_ payloads: [MXMetricPayload]) {
for payload in payloads {
processMetricPayload(payload)
}
}
// Called when diagnostic reports are available
func didReceive(_ payloads: [MXDiagnosticPayload]) {
for payload in payloads {
processDiagnosticPayload(payload)
}
}
private func processMetricPayload(_ payload: MXMetricPayload) {
// Launch time
if let launchMetrics = payload.applicationLaunchMetrics {
let resumeTime = launchMetrics.histogrammedTimeToFirstDraw
Logger.data.info("Launch histogram: \(resumeTime)")
}
// Responsiveness (hang rate)
if let responsiveness = payload.applicationResponsivenessMetrics {
let hangTime = responsiveness.histogrammedApplicationHangTime
Logger.data.info("Hang time histogram: \(hangTime)")
}
// CPU
if let cpu = payload.cpuMetrics {
let cumulativeCPUTime = cpu.cumulativeCPUTime
Logger.data.info("CPU time: \(cumulativeCPUTime)")
}
// Memory
if let memory = payload.memoryMetrics {
let peakMemory = memory.peakMemoryUsage
Logger.data.info("Peak memory: \(peakMemory)")
}
// Disk I/O
if let disk = payload.diskIOMetrics {
let writes = disk.cumulativeLogicalWrites
Logger.data.info("Disk writes: \(writes)")
}
// Network
if let network = payload.networkTransferMetrics {
let upload = network.cumulativeCellularUpload
let download = network.cumulativeCellularDownload
Logger.data.info("Cellular: up=\(upload), down=\(download)")
}
// Export as JSON for server-side analytics
let jsonData = payload.jsonRepresentation()
sendToAnalyticsServer(jsonData)
}
private func processDiagnosticPayload(_ payload: MXDiagnosticPayload) {
// Crash diagnostics
if let crashes = payload.crashDiagnostics {
for crash in crashes {
Logger.data.fault("Crash: \(crash.jsonRepresentation())")
let callStack = crash.callStackTree
// Analyze call stack for crash root cause
}
}
// Hang diagnostics
if let hangs = payload.hangDiagnostics {
for hang in hangs {
let duration = hang.hangDuration
Logger.data.error("Hang detected: \(duration)s")
}
}
// Disk write diagnostics
if let diskWrites = payload.diskWriteExceptionDiagnostics {
for diagnostic in diskWrites {
let totalWrites = diagnostic.totalWritesCaused
Logger.data.error("Excessive disk writes: \(totalWrites)")
}
}
// CPU exceptions
if let cpuExceptions = payload.cpuExceptionDiagnostics {
for exception in cpuExceptions {
let totalCPU = exception.totalCPUTime
Logger.data.error("CPU exception: \(totalCPU)s")
}
}
}
private func sendToAnalyticsServer(_ data: Data) {
// Upload payload JSON to your analytics backend
}
}
Xcode Instruments Integration
/// Custom OSLog categories map to Instruments categories
/// View in Instruments > Logging > your subsystem
// For Instruments profiling, use os_signpost:
import os
final class InstrumentsHelper {
private static let log = OSLog(subsystem: "com.yourapp", category: "Performance")
/// Wrap any async operation with Instruments-visible signpost
static func measure<T>(
_ name: StaticString,
_ operation: () async throws -> T
) async rethrows -> T {
let id = OSSignpostID(log: log)
os_signpost(.begin, log: log, name: name, signpostID: id)
let result = try await operation()
os_signpost(.end, log: log, name: name, signpostID: id)
return result
}
/// Measure synchronous work
static func measureSync<T>(
_ name: StaticString,
_ operation: () throws -> T
) rethrows -> T {
let id = OSSignpostID(log: log)
os_signpost(.begin, log: log, name: name, signpostID: id)
let result = try operation()
os_signpost(.end, log: log, name: name, signpostID: id)
return result
}
}
// Usage:
// let items = await InstrumentsHelper.measure("LoadItems") {
// try await api.fetchItems()
// }
Complete Logging Setup Example
import SwiftUI
import OSLog
import MetricKit
@main
struct MyApp: App {
@State private var metricsManager = MetricsManager.shared
init() {
MetricsManager.shared.startCollecting()
AppLogger.logAppLifecycle("App launched")
}
var body: some Scene {
WindowGroup {
ContentView()
.onOpenURL { url in
Logger.ui.info("Opened URL: \(url.absoluteString, privacy: .public)")
}
}
}
}
// Network layer with structured logging
final class LoggingHTTPClient {
private let session: URLSession
private let signposter = OSSignposter(subsystem: "com.yourapp", category: "HTTP")
init(session: URLSession = .shared) {
self.session = session
}
func request(_ urlRequest: URLRequest) async throws -> (Data, HTTPURLResponse) {
let method = urlRequest.httpMethod ?? "GET"
let url = urlRequest.url!
Logger.network.info("Starting \(method, privacy: .public) \(url.absoluteString, privacy: .public)")
let state = signposter.beginInterval("HTTP", "\(method) \(url.path())")
let start = CFAbsoluteTimeGetCurrent()
do {
let (data, response) = try await session.data(for: urlRequest)
let httpResponse = response as! HTTPURLResponse
let duration = CFAbsoluteTimeGetCurrent() - start
signposter.endInterval("HTTP", state)
AppLogger.logNetworkRequest(
method: method,
url: url,
statusCode: httpResponse.statusCode,
duration: duration
)
return (data, httpResponse)
} catch {
let duration = CFAbsoluteTimeGetCurrent() - start
signposter.endInterval("HTTP", state)
AppLogger.logNetworkRequest(
method: method,
url: url,
statusCode: nil,
duration: duration,
error: error
)
throw error
}
}
}
---
# PhotosUI & AVKit
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-photosui.html
← Back to all frameworks & guides
All articles →Core UI and Apps · Reference guidePhotosUI & AVKitRepository guidance for PhotosUI, Photos. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
PhotosPicker (Single, Multiple, Filtered by Type)
Transferable Protocol for Photo Loading
PHPickerViewController (UIKit)
AVCaptureSession for Custom Camera
AVCapturePhotoOutput and AVCaptureVideoDataOutput
VideoPlayer in SwiftUI with Custom Controls
Picture-in-Picture (AVPictureInPictureController)
PHPhotoLibrary for Saving Photos and Videos
Complete Photo Picker and Custom Camera Example
Key Considerations
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Present system photo picker
↓2
Receive selected items
↓3
Load transferable content
↓4
Handle cancellation or load failure
02 / ArchitectureResponsibility boundariesBoundary 1
Picker selectionBoundary 2
Content loadingBoundary 3
App media stateConnected responsibilities, not a required class hierarchy or an execution trace.
PhotosUI provides the system photo picker for selecting photos and videos. AVKit and AVFoundation provide video playback, custom camera capture, and Picture-in-Picture support. Together they cover the full media pipeline from capture to display.
PhotosPicker (Single, Multiple, Filtered by Type)
The SwiftUI
PhotosPicker
presents the system photo picker with privacy-preserving access (no permissions prompt required).
import SwiftUI
import PhotosUI
struct SinglePhotoPicker: View {
@State private var selectedItem: PhotosPickerItem?
@State private var selectedImage: Image?
var body: some View {
VStack(spacing: 20) {
if let selectedImage {
selectedImage
.resizable()
.scaledToFit()
.frame(maxHeight: 300)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
// Single photo picker
PhotosPicker(
selection: $selectedItem,
matching: .images, // Only show images
photoLibrary: .shared()
) {
Label("Select Photo", systemImage: "photo.on.rectangle")
}
.buttonStyle(.borderedProminent)
}
.onChange(of: selectedItem) { oldValue, newValue in
Task {
if let data = try? await newValue?.loadTransferable(type: Data.self),
let uiImage = UIImage(data: data) {
selectedImage = Image(uiImage: uiImage)
}
}
}
}
}
// Multiple photo selection with type filtering
struct MultiplePhotoPicker: View {
@State private var selectedItems: [PhotosPickerItem] = []
@State private var selectedImages: [UIImage] = []
var body: some View {
VStack {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 12) {
ForEach(selectedImages, id: \.self) { image in
Image(uiImage: image)
.resizable()
.scaledToFill()
.frame(width: 100, height: 100)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
.padding(.horizontal)
}
// Multiple selection with max count and filter
PhotosPicker(
selection: $selectedItems,
maxSelectionCount: 10,
selectionBehavior: .ordered,
matching: .any(of: [.images, .screenshots, .not(.videos)]),
preferredItemEncoding: .compatible,
photoLibrary: .shared()
) {
Label("Select Photos (max 10)", systemImage: "photo.stack")
}
.buttonStyle(.bordered)
// Filter examples:
// .images — all images
// .videos — all videos
// .livePhotos — Live Photos only
// .screenshots — screenshots only
// .any(of: [.images, .videos]) — images or videos
// .all(of: [.images, .not(.screenshots)]) — images excluding screenshots
}
.onChange(of: selectedItems) { oldValue, newValue in
Task {
selectedImages = []
for item in newValue {
if let data = try? await item.loadTransferable(type: Data.self),
let image = UIImage(data: data) {
selectedImages.append(image)
}
}
}
}
}
}
Transferable Protocol for Photo Loading
Use
Transferable
conformance for type-safe photo loading from
PhotosPickerItem
.
import SwiftUI
import PhotosUI
import CoreTransferable
// Custom Transferable type for loading images
struct PickedImage: Transferable {
let image: Image
let uiImage: UIImage
static var transferRepresentation: some TransferRepresentation {
DataRepresentation(importedContentType: .image) { data in
guard let uiImage = UIImage(data: data) else {
throw PickerError.importFailed
}
return PickedImage(image: Image(uiImage: uiImage), uiImage: uiImage)
}
}
}
// Transferable for video loading
struct PickedVideo: Transferable {
let url: URL
static var transferRepresentation: some TransferRepresentation {
FileRepresentation(contentType: .movie) { video in
SentTransferredFile(video.url)
} importing: { received in
// Copy to a permanent location
let destination = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
.appendingPathExtension("mov")
try FileManager.default.copyItem(at: received.file, to: destination)
return PickedVideo(url: destination)
}
}
}
enum PickerError: LocalizedError {
case importFailed
var errorDescription: String? {
"Failed to import the selected media."
}
}
// Usage with Transferable
struct TransferablePickerView: View {
@State private var selectedItem: PhotosPickerItem?
@State private var pickedImage: PickedImage?
@State private var isLoading = false
var body: some View {
VStack(spacing: 20) {
if isLoading {
ProgressView("Loading...")
} else if let pickedImage {
pickedImage.image
.resizable()
.scaledToFit()
.frame(maxHeight: 300)
}
PhotosPicker(selection: $selectedItem, matching: .images) {
Label("Choose Photo", systemImage: "photo")
}
}
.onChange(of: selectedItem) { _, newValue in
Task {
isLoading = true
pickedImage = try? await newValue?.loadTransferable(type: PickedImage.self)
isLoading = false
}
}
}
}
PHPickerViewController (UIKit)
For UIKit codebases, use
PHPickerViewController
which provides the same privacy-preserving picker.
import UIKit
import PhotosUI
class PhotoPickerViewController: UIViewController, PHPickerViewControllerDelegate {
private var selectedImages: [UIImage] = []
func presentPicker() {
var config = PHPickerConfiguration(photoLibrary: .shared())
config.selectionLimit = 5 // 0 = unlimited
config.filter = .images // .videos, .livePhotos, .any(of:)
config.preferredAssetRepresentationMode = .current
config.selection = .ordered // Maintain selection order
let picker = PHPickerViewController(configuration: config)
picker.delegate = self
present(picker, animated: true)
}
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
dismiss(animated: true)
selectedImages = []
for result in results {
let provider = result.itemProvider
if provider.canLoadObject(ofClass: UIImage.self) {
provider.loadObject(ofClass: UIImage.self) { [weak self] image, error in
if let image = image as? UIImage {
DispatchQueue.main.async {
self?.selectedImages.append(image)
self?.updateUI()
}
}
}
}
// Load video
if provider.hasItemConformingToTypeIdentifier("public.movie") {
provider.loadFileRepresentation(forTypeIdentifier: "public.movie") { url, error in
guard let url else { return }
// Copy video to permanent location before the temporary file is deleted
let destination = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString + ".mov")
try? FileManager.default.copyItem(at: url, to: destination)
}
}
}
}
private func updateUI() {
// Update your collection view or image views
}
}
AVCaptureSession for Custom Camera
Build a custom camera interface using
AVCaptureSession
,
AVCaptureDeviceInput
, and preview layers.
import AVFoundation
import UIKit
class CameraManager: NSObject {
let session = AVCaptureSession()
private let photoOutput = AVCapturePhotoOutput()
private let videoOutput = AVCaptureVideoDataOutput()
private var currentDevice: AVCaptureDevice?
enum CameraPosition {
case front, back
}
func configure(position: CameraPosition = .back) throws {
session.beginConfiguration()
defer { session.commitConfiguration() }
session.sessionPreset = .photo
// Remove existing inputs
session.inputs.forEach { session.removeInput($0) }
// Select camera device
let devicePosition: AVCaptureDevice.Position = position == .back ? .back : .front
guard let device = AVCaptureDevice.default(
.builtInWideAngleCamera,
for: .video,
position: devicePosition
) else {
throw CameraError.deviceNotAvailable
}
currentDevice = device
// Add input
let input = try AVCaptureDeviceInput(device: device)
guard session.canAddInput(input) else {
throw CameraError.cannotAddInput
}
session.addInput(input)
// Add photo output
guard session.canAddOutput(photoOutput) else {
throw CameraError.cannotAddOutput
}
session.addOutput(photoOutput)
photoOutput.isHighResolutionCaptureEnabled = true
photoOutput.maxPhotoQualityPrioritization = .quality
}
func start() {
guard !session.isRunning else { return }
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
self?.session.startRunning()
}
}
func stop() {
guard session.isRunning else { return }
session.stopRunning()
}
func switchCamera() throws {
let newPosition: CameraPosition = currentDevice?.position == .back ? .front : .back
try configure(position: newPosition)
}
}
enum CameraError: LocalizedError {
case deviceNotAvailable
case cannotAddInput
case cannotAddOutput
case permissionDenied
var errorDescription: String? {
switch self {
case .deviceNotAvailable: return "Camera device not available."
case .cannotAddInput: return "Cannot add camera input."
case .cannotAddOutput: return "Cannot add camera output."
case .permissionDenied: return "Camera access denied."
}
}
}
AVCapturePhotoOutput and AVCaptureVideoDataOutput
Capture photos and process live video frames from the camera session.
import AVFoundation
import UIKit
// Photo capture delegate
class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegate {
private let completion: (Result<UIImage, Error>) -> Void
init(completion: @escaping (Result<UIImage, Error>) -> Void) {
self.completion = completion
}
func photoOutput(
_ output: AVCapturePhotoOutput,
didFinishProcessingPhoto photo: AVCapturePhoto,
error: Error?
) {
if let error {
completion(.failure(error))
return
}
guard let data = photo.fileDataRepresentation(),
let image = UIImage(data: data) else {
completion(.failure(CameraError.cannotAddOutput))
return
}
completion(.success(image))
}
}
// Extend CameraManager with photo capture
extension CameraManager {
private static var captureDelegate: PhotoCaptureDelegate?
func capturePhoto(completion: @escaping (Result<UIImage, Error>) -> Void) {
let settings = AVCapturePhotoSettings()
settings.flashMode = .auto
settings.isHighResolutionPhotoEnabled = true
// Configure format
if let previewPixelType = settings.availablePreviewPhotoPixelFormatTypes.first {
settings.previewPhotoFormat = [
kCVPixelBufferPixelFormatTypeKey as String: previewPixelType
]
}
let delegate = PhotoCaptureDelegate(completion: completion)
Self.captureDelegate = delegate // Retain the delegate
photoOutput.capturePhoto(with: settings, delegate: delegate)
}
}
// Video frame processing (for real-time analysis, filters, etc.)
class VideoFrameProcessor: NSObject, AVCaptureVideoDataOutputSampleBufferDelegate {
let processingQueue = DispatchQueue(label: "com.app.videoProcessing")
var onFrameCaptured: ((CVPixelBuffer, CMTime) -> Void)?
func captureOutput(
_ output: AVCaptureOutput,
didOutput sampleBuffer: CMSampleBuffer,
from connection: AVCaptureConnection
) {
guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return }
let timestamp = CMSampleBufferGetPresentationTimeStamp(sampleBuffer)
onFrameCaptured?(pixelBuffer, timestamp)
}
func captureOutput(
_ output: AVCaptureOutput,
didDrop sampleBuffer: CMSampleBuffer,
from connection: AVCaptureConnection
) {
// Frame was dropped due to processing backpressure
print("Frame dropped")
}
}
// Adding video output to camera manager
extension CameraManager {
func addVideoOutput(processor: VideoFrameProcessor) {
let videoOutput = AVCaptureVideoDataOutput()
videoOutput.setSampleBufferDelegate(processor, queue: processor.processingQueue)
videoOutput.alwaysDiscardsLateVideoFrames = true
if session.canAddOutput(videoOutput) {
session.addOutput(videoOutput)
}
}
}
VideoPlayer in SwiftUI with Custom Controls
Use
AVKit.VideoPlayer
for standard playback or build custom controls with
AVPlayer
.
import SwiftUI
import AVKit
// Simple video player with built-in controls
struct SimpleVideoPlayerView: View {
@State private var player = AVPlayer(url: URL(string: "https://example.com/video.mp4")!)
var body: some View {
VideoPlayer(player: player) {
// Optional overlay content
VStack {
Spacer()
HStack {
Spacer()
Text("Live")
.font(.caption.bold())
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(.red)
.foregroundStyle(.white)
.clipShape(Capsule())
.padding()
}
}
}
.frame(height: 300)
.clipShape(RoundedRectangle(cornerRadius: 12))
.onDisappear {
player.pause()
}
}
}
// Custom video player with custom controls
struct CustomVideoPlayerView: View {
let url: URL
@State private var player: AVPlayer?
@State private var isPlaying = false
@State private var currentTime: Double = 0
@State private var duration: Double = 0
@State private var showControls = true
var body: some View {
ZStack {
// Video layer
VideoPlayerLayer(player: player)
.onTapGesture {
withAnimation(.easeInOut(duration: 0.2)) {
showControls.toggle()
}
}
// Custom controls overlay
if showControls {
controlsOverlay
.transition(.opacity)
}
}
.frame(height: 300)
.clipShape(RoundedRectangle(cornerRadius: 12))
.onAppear {
setupPlayer()
}
.onDisappear {
player?.pause()
}
}
private var controlsOverlay: some View {
ZStack {
// Semi-transparent background
Color.black.opacity(0.4)
VStack {
Spacer()
// Play/Pause button
Button {
togglePlayback()
} label: {
Image(systemName: isPlaying ? "pause.circle.fill" : "play.circle.fill")
.font(.system(size: 50))
.foregroundStyle(.white)
}
Spacer()
// Progress bar
HStack(spacing: 12) {
Text(formatTime(currentTime))
.font(.caption.monospacedDigit())
.foregroundStyle(.white)
Slider(value: $currentTime, in: 0...max(duration, 1)) { editing in
if !editing {
player?.seek(to: CMTime(seconds: currentTime, preferredTimescale: 600))
}
}
.tint(.white)
Text(formatTime(duration))
.font(.caption.monospacedDigit())
.foregroundStyle(.white)
}
.padding(.horizontal)
.padding(.bottom, 16)
}
}
}
private func setupPlayer() {
player = AVPlayer(url: url)
// Observe time
player?.addPeriodicTimeObserver(
forInterval: CMTime(seconds: 0.5, preferredTimescale: 600),
queue: .main
) { time in
currentTime = time.seconds
}
// Get duration
Task {
if let durationCM = try? await player?.currentItem?.asset.load(.duration) {
duration = durationCM.seconds
}
}
}
private func togglePlayback() {
if isPlaying {
player?.pause()
} else {
player?.play()
}
isPlaying.toggle()
}
private func formatTime(_ seconds: Double) -> String {
let mins = Int(seconds) / 60
let secs = Int(seconds) % 60
return String(format: "%d:%02d", mins, secs)
}
}
// UIViewRepresentable for AVPlayerLayer
struct VideoPlayerLayer: UIViewRepresentable {
let player: AVPlayer?
func makeUIView(context: Context) -> PlayerUIView {
PlayerUIView(player: player)
}
func updateUIView(_ uiView: PlayerUIView, context: Context) {
uiView.playerLayer.player = player
}
}
class PlayerUIView: UIView {
var playerLayer: AVPlayerLayer
init(player: AVPlayer?) {
playerLayer = AVPlayerLayer(player: player)
super.init(frame: .zero)
playerLayer.videoGravity = .resizeAspectFill
layer.addSublayer(playerLayer)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
playerLayer.frame = bounds
}
}
Picture-in-Picture (AVPictureInPictureController)
Enable Picture-in-Picture for video playback so users can continue watching in a floating window.
import AVKit
import SwiftUI
class PiPManager: NSObject, AVPictureInPictureControllerDelegate {
private var pipController: AVPictureInPictureController?
var onPiPStatusChanged: ((Bool) -> Void)?
func setup(with playerLayer: AVPlayerLayer) {
guard AVPictureInPictureController.isPictureInPictureSupported() else {
print("PiP is not supported on this device")
return
}
pipController = AVPictureInPictureController(playerLayer: playerLayer)
pipController?.delegate = self
pipController?.canStartPictureInPictureAutomaticallyFromInline = true
}
func togglePiP() {
guard let pipController else { return }
if pipController.isPictureInPictureActive {
pipController.stopPictureInPicture()
} else {
pipController.startPictureInPicture()
}
}
var isPiPActive: Bool {
pipController?.isPictureInPictureActive ?? false
}
var isPiPPossible: Bool {
pipController?.isPictureInPicturePossible ?? false
}
// MARK: - AVPictureInPictureControllerDelegate
func pictureInPictureControllerWillStartPictureInPicture(
_ pictureInPictureController: AVPictureInPictureController
) {
onPiPStatusChanged?(true)
}
func pictureInPictureControllerDidStopPictureInPicture(
_ pictureInPictureController: AVPictureInPictureController
) {
onPiPStatusChanged?(false)
}
func pictureInPictureController(
_ pictureInPictureController: AVPictureInPictureController,
failedToStartPictureInPictureWithError error: Error
) {
print("PiP failed to start: \(error.localizedDescription)")
}
func pictureInPictureController(
_ pictureInPictureController: AVPictureInPictureController,
restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void
) {
// Restore your UI when PiP stops
completionHandler(true)
}
}
// Configure audio session for PiP background audio
import AVFAudio
func configureAudioSession() {
do {
let session = AVAudioSession.sharedInstance()
try session.setCategory(.playback, mode: .moviePlayback)
try session.setActive(true)
} catch {
print("Audio session configuration failed: \(error)")
}
}
PHPhotoLibrary for Saving Photos and Videos
Save captured photos and videos to the user's photo library with proper permissions handling.
import Photos
import UIKit
class PhotoLibraryManager {
// Check and request authorization
static func requestAuthorization() async -> PHAuthorizationStatus {
let status = PHPhotoLibrary.authorizationStatus(for: .addOnly)
switch status {
case .notDetermined:
return await PHPhotoLibrary.requestAuthorization(for: .addOnly)
default:
return status
}
}
// Save a UIImage to the photo library
static func saveImage(_ image: UIImage) async throws {
let status = await requestAuthorization()
guard status == .authorized || status == .limited else {
throw PhotoLibraryError.permissionDenied
}
try await PHPhotoLibrary.shared().performChanges {
let request = PHAssetCreationRequest.forAsset()
guard let imageData = image.jpegData(compressionQuality: 0.9) else {
return
}
request.addResource(with: .photo, data: imageData, options: nil)
request.creationDate = Date()
}
}
// Save a video file to the photo library
static func saveVideo(at url: URL) async throws {
let status = await requestAuthorization()
guard status == .authorized || status == .limited else {
throw PhotoLibraryError.permissionDenied
}
try await PHPhotoLibrary.shared().performChanges {
PHAssetChangeRequest.creationRequestForAssetFromVideo(atFileURL: url)
}
}
// Save to a specific album (create if needed)
static func saveImage(_ image: UIImage, toAlbum albumName: String) async throws {
let status = await requestAuthorization()
guard status == .authorized else {
throw PhotoLibraryError.permissionDenied
}
// Find or create the album
let album = try await findOrCreateAlbum(named: albumName)
try await PHPhotoLibrary.shared().performChanges {
let assetRequest = PHAssetCreationRequest.forAsset()
guard let imageData = image.jpegData(compressionQuality: 0.9) else { return }
assetRequest.addResource(with: .photo, data: imageData, options: nil)
guard let placeholder = assetRequest.placeholderForCreatedAsset,
let albumChangeRequest = PHAssetCollectionChangeRequest(for: album) else {
return
}
albumChangeRequest.addAssets([placeholder] as NSFastEnumeration)
}
}
private static func findOrCreateAlbum(named name: String) async throws -> PHAssetCollection {
// Search for existing album
let fetchOptions = PHFetchOptions()
fetchOptions.predicate = NSPredicate(format: "title = %@", name)
let collections = PHAssetCollection.fetchAssetCollections(
with: .album,
subtype: .any,
options: fetchOptions
)
if let existing = collections.firstObject {
return existing
}
// Create new album
var placeholder: PHObjectPlaceholder?
try await PHPhotoLibrary.shared().performChanges {
let request = PHAssetCollectionChangeRequest.creationRequestForAssetCollection(withTitle: name)
placeholder = request.placeholderForCreatedAssetCollection
}
guard let placeholder,
let album = PHAssetCollection.fetchAssetCollections(
withLocalIdentifiers: [placeholder.localIdentifier],
options: nil
).firstObject else {
throw PhotoLibraryError.albumCreationFailed
}
return album
}
}
enum PhotoLibraryError: LocalizedError {
case permissionDenied
case albumCreationFailed
var errorDescription: String? {
switch self {
case .permissionDenied:
return "Photo library access denied. Enable it in Settings."
case .albumCreationFailed:
return "Failed to create photo album."
}
}
}
Complete Photo Picker and Custom Camera Example
A full-featured media capture app with both a photo picker and custom camera interface.
import SwiftUI
import PhotosUI
import AVFoundation
// MARK: - Main View
struct MediaCaptureView: View {
@State private var selectedTab = 0
@State private var capturedImages: [UIImage] = []
var body: some View {
NavigationStack {
VStack(spacing: 0) {
// Photo grid
ScrollView {
if capturedImages.isEmpty {
ContentUnavailableView(
"No Photos Yet",
systemImage: "photo.on.rectangle.angled",
description: Text("Take a photo or pick from your library.")
)
} else {
LazyVGrid(columns: [
GridItem(.flexible(), spacing: 2),
GridItem(.flexible(), spacing: 2),
GridItem(.flexible(), spacing: 2)
], spacing: 2) {
ForEach(capturedImages.indices, id: \.self) { index in
Image(uiImage: capturedImages[index])
.resizable()
.scaledToFill()
.frame(minHeight: 120)
.clipped()
}
}
}
}
// Bottom toolbar
MediaToolbar(
onPhotoPicked: { images in
capturedImages.append(contentsOf: images)
},
onPhotoTaken: { image in
capturedImages.append(image)
}
)
}
.navigationTitle("Photos")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
if !capturedImages.isEmpty {
Button("Save All") {
saveAllPhotos()
}
}
}
}
}
}
private func saveAllPhotos() {
Task {
for image in capturedImages {
try? await PhotoLibraryManager.saveImage(image, toAlbum: "My App")
}
}
}
}
// MARK: - Media Toolbar
struct MediaToolbar: View {
let onPhotoPicked: ([UIImage]) -> Void
let onPhotoTaken: (UIImage) -> Void
@State private var selectedItems: [PhotosPickerItem] = []
@State private var showCamera = false
var body: some View {
HStack(spacing: 40) {
// Photo picker button
PhotosPicker(
selection: $selectedItems,
maxSelectionCount: 20,
matching: .images
) {
VStack(spacing: 4) {
Image(systemName: "photo.on.rectangle")
.font(.title2)
Text("Library")
.font(.caption)
}
}
// Camera button
Button {
showCamera = true
} label: {
VStack(spacing: 4) {
Image(systemName: "camera.fill")
.font(.title2)
Text("Camera")
.font(.caption)
}
}
}
.padding()
.frame(maxWidth: .infinity)
.background(.ultraThinMaterial)
.onChange(of: selectedItems) { _, newValue in
Task {
var images: [UIImage] = []
for item in newValue {
if let data = try? await item.loadTransferable(type: Data.self),
let image = UIImage(data: data) {
images.append(image)
}
}
onPhotoPicked(images)
selectedItems = []
}
}
.fullScreenCover(isPresented: $showCamera) {
CameraView(onCapture: onPhotoTaken)
}
}
}
// MARK: - Camera View
struct CameraView: View {
let onCapture: (UIImage) -> Void
@Environment(\.dismiss) private var dismiss
@State private var cameraManager = SwiftUICameraManager()
@State private var lastCapturedImage: UIImage?
@State private var flashEnabled = false
@State private var isFrontCamera = false
var body: some View {
ZStack {
// Camera preview
CameraPreviewView(session: cameraManager.session)
.ignoresSafeArea()
VStack {
// Top bar
HStack {
Button("Cancel") {
dismiss()
}
.foregroundStyle(.white)
Spacer()
Button {
flashEnabled.toggle()
} label: {
Image(systemName: flashEnabled ? "bolt.fill" : "bolt.slash")
.foregroundStyle(.white)
.font(.title3)
}
}
.padding()
Spacer()
// Bottom controls
HStack(spacing: 60) {
// Last captured thumbnail
if let lastImage = lastCapturedImage {
Image(uiImage: lastImage)
.resizable()
.scaledToFill()
.frame(width: 50, height: 50)
.clipShape(RoundedRectangle(cornerRadius: 8))
} else {
Color.clear.frame(width: 50, height: 50)
}
// Capture button
Button {
capturePhoto()
} label: {
Circle()
.fill(.white)
.frame(width: 70, height: 70)
.overlay(
Circle()
.stroke(.white, lineWidth: 3)
.frame(width: 80, height: 80)
)
}
// Switch camera
Button {
switchCamera()
} label: {
Image(systemName: "camera.rotate")
.font(.title2)
.foregroundStyle(.white)
.frame(width: 50, height: 50)
}
}
.padding(.bottom, 40)
}
}
.onAppear {
Task {
await cameraManager.checkPermissionAndStart()
}
}
.onDisappear {
cameraManager.stop()
}
}
private func capturePhoto() {
cameraManager.capturePhoto { result in
if case .success(let image) = result {
lastCapturedImage = image
onCapture(image)
}
}
}
private func switchCamera() {
isFrontCamera.toggle()
try? cameraManager.switchCamera()
}
}
// MARK: - Camera Preview (UIViewRepresentable)
struct CameraPreviewView: UIViewRepresentable {
let session: AVCaptureSession
func makeUIView(context: Context) -> CameraPreviewUIView {
CameraPreviewUIView(session: session)
}
func updateUIView(_ uiView: CameraPreviewUIView, context: Context) {}
}
class CameraPreviewUIView: UIView {
private var previewLayer: AVCaptureVideoPreviewLayer
init(session: AVCaptureSession) {
previewLayer = AVCaptureVideoPreviewLayer(session: session)
super.init(frame: .zero)
previewLayer.videoGravity = .resizeAspectFill
layer.addSublayer(previewLayer)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
previewLayer.frame = bounds
}
}
// MARK: - SwiftUI Camera Manager
@Observable
class SwiftUICameraManager {
let session = AVCaptureSession()
private let photoOutput = AVCapturePhotoOutput()
private var captureDelegate: PhotoCaptureHandler?
func checkPermissionAndStart() async {
switch AVCaptureDevice.authorizationStatus(for: .video) {
case .authorized:
setupAndStart()
case .notDetermined:
let granted = await AVCaptureDevice.requestAccess(for: .video)
if granted { setupAndStart() }
default:
break
}
}
private func setupAndStart() {
session.beginConfiguration()
session.sessionPreset = .photo
guard let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back),
let input = try? AVCaptureDeviceInput(device: device),
session.canAddInput(input) else {
session.commitConfiguration()
return
}
session.addInput(input)
if session.canAddOutput(photoOutput) {
session.addOutput(photoOutput)
}
session.commitConfiguration()
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
self?.session.startRunning()
}
}
func stop() {
session.stopRunning()
}
func switchCamera() throws {
session.beginConfiguration()
defer { session.commitConfiguration() }
guard let currentInput = session.inputs.first as? AVCaptureDeviceInput else { return }
session.removeInput(currentInput)
let newPosition: AVCaptureDevice.Position = currentInput.device.position == .back ? .front : .back
guard let newDevice = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: newPosition),
let newInput = try? AVCaptureDeviceInput(device: newDevice),
session.canAddInput(newInput) else {
// Re-add old input if switch fails
if session.canAddInput(currentInput) { session.addInput(currentInput) }
return
}
session.addInput(newInput)
}
func capturePhoto(completion: @escaping (Result<UIImage, Error>) -> Void) {
let settings = AVCapturePhotoSettings()
let handler = PhotoCaptureHandler(completion: completion)
captureDelegate = handler
photoOutput.capturePhoto(with: settings, delegate: handler)
}
}
class PhotoCaptureHandler: NSObject, AVCapturePhotoCaptureDelegate {
private let completion: (Result<UIImage, Error>) -> Void
init(completion: @escaping (Result<UIImage, Error>) -> Void) {
self.completion = completion
}
func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) {
if let error {
DispatchQueue.main.async { self.completion(.failure(error)) }
return
}
guard let data = photo.fileDataRepresentation(),
let image = UIImage(data: data) else {
DispatchQueue.main.async { self.completion(.failure(CameraError.cannotAddOutput)) }
return
}
DispatchQueue.main.async { self.completion(.success(image)) }
}
}
Key Considerations
PhotosPicker
: No permissions prompt required. The system mediates access. Available iOS 16+.
Camera permissions
: Add
NSCameraUsageDescription
to Info.plist. Always check authorization before accessing
AVCaptureSession
.
Photo library permissions
: Add
NSPhotoLibraryAddUsageDescription
for save-only access. Add
NSPhotoLibraryUsageDescription
for full read/write access.
Background audio
: Set the audio session category to
.playback
and enable the "Audio, AirPlay, and Picture in Picture" background mode for PiP.
Memory
: Large photos can consume significant memory. Use
CGImageSource
for progressive loading of very large images.
Thread safety
:
AVCaptureSession
configuration must happen on a single thread. Never call
startRunning()
on the main thread.
Transferable
: Use
Transferable
with
PhotosPickerItem
for type-safe, modern photo loading (iOS 16+).
Video
: For video playback, prefer
AVPlayer
with
VideoPlayer
in SwiftUI. For recording, use
AVCaptureMovieFileOutput
.
---
# RealityKit -- Complete Guide for 3D Rendering, AR, and…
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-realitykit.html
← Back to all frameworks & guides
All articles →Graphics, 3D, and Games · Reference guideRealityKit -- Complete Guide for 3D Rendering, AR, and Spatial AppsRepository guidance for RealityKit. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
Overview
1. Core Concepts: Entity Component System
2. SwiftUI: RealityView (iOS 18 / visionOS 1+)
Loading a USDZ asset
Reality Composer Pro scenes (visionOS)
3. UIKit: ARView for iOS AR
4. Materials and Lighting
PBR (PhysicallyBasedMaterial)
Unlit / shaded surface materials
Custom shader graphs
5. Animation
Built-in skeletal animations
Transform animation (move/scale/rotate)
6. Physics
7. Input and Gestures (visionOS)
8. Audio
9. Lighting (visionOS / macOS)
10. Networking and Multipeer Sync
11. ECS: Custom Components and Systems
Component
System
12. Object Capture (iOS 17+)
13. Performance Tips
14. Common Pitfalls
15. Platform Differences
16. 3D Content in an Ordinary iOS Screen (non-AR)
The two lines that opt out
A light rig, because non-AR scenes have no environment
Framing on a phone: you have about 27 degrees, not 55
Orbit controls silently override your camera
Text in 3D: MeshResource.generateText does not exist on iOS
Scene is ambiguous when both frameworks are imported
Checklist for non-AR 3D on iOS
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Load or create entities
↓2
Attach components
↓3
Add entities to a scene
↓4
Update behavior and interactions
02 / ArchitectureResponsibility boundariesBoundary 1
Entity hierarchyBoundary 2
Components and systemsBoundary 3
Rendered sceneConnected responsibilities, not a required class hierarchy or an execution trace.
Overview
RealityKit is Apple's modern 3D engine and the default renderer for AR on iOS/iPadOS and for spatial content on visionOS. It uses a Swift-first
Entity Component System (ECS)
, ships with PBR materials, physics, audio, networking sync, and tightly integrates with USDZ and Reality Composer Pro. Pair it with
ARKit
for sensor data on iOS, or with
RealityView
/
ImmersiveSpace
on visionOS.
Available on
iOS 13+
,
macOS 10.15+
,
tvOS 16+
, and
visionOS 1.0+
. SwiftUI integration via
RealityView
requires iOS 18+ / visionOS 1+ / macOS 15+ for the unified API; older code uses
ARView
(UIKit) wrapped in
UIViewRepresentable
.
1. Core Concepts: Entity Component System
import RealityKit
// Entity: a node in the scene graph
let cube = ModelEntity(
mesh: .generateBox(size: 0.2),
materials: [SimpleMaterial(color: .systemBlue, isMetallic: false)]
)
// Components: data attached to entities
cube.components.set(InputTargetComponent())
cube.components.set(CollisionComponent(shapes: [.generateBox(size: [0.2, 0.2, 0.2])]))
cube.components.set(HoverEffectComponent()) // visionOS hover
// Anchor: where in the world the subtree lives
let anchor = AnchorEntity(world: .zero)
anchor.addChild(cube)
The full hierarchy:
Scene
->
AnchorEntity
->
Entity
(with components and children).
2. SwiftUI:
RealityView
(iOS 18 / visionOS 1+)
import SwiftUI
import RealityKit
struct GalaxyView: View {
var body: some View {
RealityView { content in
// Initial setup -- runs once
let sphere = ModelEntity(
mesh: .generateSphere(radius: 0.1),
materials: [SimpleMaterial(color: .orange, roughness: 0.3, isMetallic: true)]
)
sphere.position = [0, 1.5, -1]
content.add(sphere)
} update: { content in
// Re-runs whenever bound state changes
}
}
}
Loading a USDZ asset
RealityView { content in
if let model = try? await Entity(named: "Toy_robot", in: .main) {
model.position = [0, 0, -1]
content.add(model)
}
}
Reality Composer Pro scenes (visionOS)
RealityView { content in
if let scene = try? await Entity(named: "GalaxyScene", in: realityKitContentBundle) {
content.add(scene)
}
}
3. UIKit:
ARView
for iOS AR
import RealityKit
import ARKit
let arView = ARView(frame: .zero, cameraMode: .ar, automaticallyConfigureSession: true)
// Place an entity 1 meter in front of the camera
let anchor = AnchorEntity(plane: .horizontal, classification: .floor, minimumBounds: [0.5, 0.5])
let modelEntity = try ModelEntity.load(named: "Robot")
anchor.addChild(modelEntity)
arView.scene.addAnchor(anchor)
ARView
automatically composites people occlusion, depth, and lighting estimation when the session opts in -- see
docs/frameworks/arkit.md
.
4. Materials and Lighting
PBR (PhysicallyBasedMaterial)
var material = PhysicallyBasedMaterial()
material.baseColor = .init(tint: .systemTeal)
material.metallic = 0.9
material.roughness = 0.2
material.emissiveColor = .init(color: .blue)
material.emissiveIntensity = 0.5
// Texture maps
material.baseColor = .init(texture: .init(try .load(named: "albedo")))
material.normal = .init(texture: .init(try .load(named: "normal")))
let entity = ModelEntity(mesh: .generateSphere(radius: 0.1), materials: [material])
Unlit / shaded surface materials
let unlit = UnlitMaterial(color: .white)
Custom shader graphs
Author shaders visually in
Reality Composer Pro
, then bind parameters at runtime:
guard var material = entity.model?.materials.first as? ShaderGraphMaterial else { return }
try material.setParameter(name: "Tint", value: .color(.systemPink))
entity.model?.materials = [material]
5. Animation
Built-in skeletal animations
let robot = try ModelEntity.load(named: "Robot")
if let animation = robot.availableAnimations.first {
robot.playAnimation(animation.repeat(duration: .infinity), transitionDuration: 0.5)
}
Transform animation (move/scale/rotate)
let move = FromToByAnimation<Transform>(
name: "slide",
from: .init(translation: [0, 0, 0]),
to: .init(translation: [0.5, 0, 0]),
duration: 1.0,
bindTarget: .transform
)
let resource = try AnimationResource.generate(with: move)
entity.playAnimation(resource)
6. Physics
// 1. Generate collision shapes from the mesh
entity.generateCollisionShapes(recursive: true)
// 2. Add a physics body
entity.components.set(
PhysicsBodyComponent(
massProperties: .init(mass: 1.0),
material: .generate(friction: 0.4, restitution: 0.2),
mode: .dynamic
)
)
// 3. Apply forces / impulses
entity.applyLinearImpulse([0, 2, 0], relativeTo: nil)
PhysicsBodyComponent.mode
:
.dynamic
,
.kinematic
, or
.static
.
7. Input and Gestures (visionOS)
RealityView { content in
let cube = ModelEntity(mesh: .generateBox(size: 0.2),
materials: [SimpleMaterial(color: .green, isMetallic: false)])
cube.components.set(InputTargetComponent())
cube.components.set(CollisionComponent(shapes: [.generateBox(size: [0.2, 0.2, 0.2])]))
content.add(cube)
}
.gesture(
DragGesture()
.targetedToAnyEntity()
.onChanged { value in
value.entity.position = value.convert(value.location3D, from: .local, to: value.entity.parent!)
}
)
For iOS, use
arView.installGestures([.translation, .rotation, .scale], for: entity)
.
8. Audio
let resource = try AudioFileResource.load(named: "spaceship.wav",
configuration: .init(shouldLoop: true))
let controller = entity.prepareAudio(resource)
controller.gain = -6
controller.play()
Spatial audio is automatic when the entity has a position; head-tracked rendering happens for free on visionOS / AirPods Pro.
9. Lighting (visionOS / macOS)
let light = DirectionalLight()
light.light.intensity = 5000
light.light.color = .white
light.shadow = .init(maximumDistance: 10, depthBias: 0.001)
light.orientation = simd_quatf(angle: -.pi / 4, axis: [1, 0, 0])
content.add(light)
For image-based lighting:
let env = try await EnvironmentResource(named: "studio_small")
content.environment.lighting.resource = env
iOS AR uses real-world lighting estimation automatically -- do not add manual lights unless you want extra fill.
10. Networking and Multipeer Sync
import MultipeerConnectivity
let session = MCSession(peer: MCPeerID(displayName: UIDevice.current.name))
arView.scene.synchronizationService = try? MultipeerConnectivityService(session: session)
// Tag entities you want replicated
entity.synchronization?.ownershipTransferMode = .autoAccept
All entities with a
SynchronizationComponent
(added by default to AnchorEntity) are kept in sync across peers.
11. ECS: Custom Components and Systems
Component
struct SpinComponent: Component {
var radiansPerSecond: Float = .pi
}
System
final class SpinSystem: System {
static let query = EntityQuery(where: .has(SpinComponent.self))
init(scene: Scene) {}
func update(context: SceneUpdateContext) {
for entity in context.scene.performQuery(Self.query) {
guard let spin = entity.components[SpinComponent.self] else { continue }
entity.transform.rotation *= simd_quatf(
angle: spin.radiansPerSecond * Float(context.deltaTime),
axis: [0, 1, 0]
)
}
}
}
// Register once at app start
SpinComponent.registerComponent()
SpinSystem.registerSystem()
Now any entity with
SpinComponent()
rotates automatically -- no per-entity update logic.
12. Object Capture (iOS 17+)
import RealityKit
let session = ObjectCaptureSession()
session.start(imagesDirectory: capturesURL,
configuration: .init(isOverCaptureEnabled: true))
// Later, generate the model
let photogrammetry = try PhotogrammetrySession(input: capturesURL)
try photogrammetry.process(requests: [
.modelFile(url: outputURL, detail: .reduced)
])
Requires LiDAR for guided capture; works on Macs for cloud-quality processing.
13. Performance Tips
Reuse mesh and material resources
--
MeshResource.generateBox(...)
is cheap, but loading a USDZ multiple times is not. Load once, clone with
entity.clone(recursive: true)
.
Disable shadows on high-poly entities
when frame-rate dips --
model.components[ShadowComponent.self] = nil
.
Cap physics body count
-- mark static geometry as
.static
; only player/interactable items as
.dynamic
.
Use LowLevelMesh (iOS 18+)
for procedural geometry instead of regenerating
MeshResource
every frame.
Profile with the RealityKit Trace template in Instruments
-- shows GPU time per pass and ECS update breakdown.
Bake lighting into Reality Composer Pro scenes
rather than adding runtime lights when possible.
14. Common Pitfalls
Forgetting generateCollisionShapes(recursive:)
-- raycasts and gestures silently miss the entity.
Adding entities outside an AnchorEntity
-- nothing renders. Always parent under an anchor or
content
.
Using SimpleMaterial for production
-- it lacks PBR. Switch to
PhysicallyBasedMaterial
.
Modifying entity.transform from a background thread
-- RealityKit is
main-thread only
. Schedule updates inside
RealityView.update
or a
System
.
Skipping registerComponent() / registerSystem()
-- custom ECS code silently does nothing if not registered.
Loading USDZ synchronously
-- use
try await Entity(named:)
to avoid stalling the render loop.
Confusing iOS RealityKit with visionOS RealityKit
--
ImmersiveSpace
, hand tracking, and eye tracking are visionOS-only.
RealityView and its content closure are not
: they exist on iOS 18+ and macOS 15+, where the content type is
RealityViewCameraContent
. See the platform table immediately below, and section 16 for building non-AR 3D on iOS.
15. Platform Differences
Capability
iOS
macOS
visionOS
RealityView (SwiftUI)
iOS 18+
macOS 15+
visionOS 1+
ARView (UIKit/AppKit)
All
All
--
AR session
ARKit
--
ARKit-for-visionOS
Hand/eye input
--
--
Yes
Reality Composer Pro scenes
Yes
Yes
Yes
Object capture
iOS 17+
macOS 12+
--
16. 3D Content in an Ordinary iOS Screen (non-AR)
Load this when
you want 3D in a normal app screen — a product viewer, a
card carousel, a data sculpture — with no camera and no world tracking.
This is the most common 3D request on iOS and the easiest one to get wrong,
because
RealityView on iOS defaults to world tracking
. Follow the AR
sections above for a non-AR screen and you get a camera-permission prompt and a
live camera feed behind your content — on a screen that was never meant to see
through the phone.
The two lines that opt out
import RealityKit
import SwiftUI
struct GalleryView: View {
var body: some View {
RealityView { content in
// 1. Opt out of world tracking. Without this the view starts an AR
// session, prompts for camera access, and composites your scene
// over the camera feed.
content.camera = .virtual
// 2. Having opted out, you own the camera. There is no default one,
// so without this the scene renders from the origin.
let camera = PerspectiveCamera()
camera.camera.fieldOfViewInDegrees = 55
camera.look(at: .zero, from: [0, 0.35, 1.6], relativeTo: nil)
content.add(camera)
content.add(makeLightRig())
content.add(makeScene())
}
}
}
content.camera
and
PerspectiveCamera
require
iOS 18+
. On iOS 17 the
equivalent is
ARView(frame:cameraMode:automaticallyConfigureSession:)
with
cameraMode: .nonAR
, wrapped in a
UIViewRepresentable
:
struct LegacyRealityView: UIViewRepresentable {
func makeUIView(context: Context) -> ARView {
// .nonAR is the iOS 17 equivalent of `content.camera = .virtual`.
let view = ARView(frame: .zero, cameraMode: .nonAR, automaticallyConfigureSession: false)
view.environment.background = .color(.clear)
return view
}
func updateUIView(_ view: ARView, context: Context) {}
}
A light rig, because non-AR scenes have no environment
An AR scene inherits real-world lighting estimation. A virtual one inherits
nothing, so
PhysicallyBasedMaterial
renders black until you light it. The
minimum that reads well:
private func makeLightRig() -> Entity {
let rig = Entity()
let key = DirectionalLight()
key.light.intensity = 2_500
key.light.color = .white
key.look(at: .zero, from: [1.2, 1.8, 1.4], relativeTo: nil)
rig.addChild(key)
let fill = DirectionalLight()
fill.light.intensity = 800
fill.look(at: .zero, from: [-1.5, 0.4, 1.0], relativeTo: nil)
rig.addChild(fill)
return rig
}
Framing on a phone: you have about 27 degrees, not 55
PerspectiveCamera.camera.fieldOfViewInDegrees
is the
vertical
field.
Horizontal is derived from the aspect ratio, and a portrait iPhone is roughly
0.46 wide-to-tall:
horizontal = 2 · atan( tan(vertical / 2) · aspect )
= 2 · atan( tan(55° / 2) · 0.46 )
≈ 27°
Twenty-seven degrees is narrow.
The natural first idea — a ring of cards
around the camera — puts all but one of them outside the frustum: you see one
card face and the backs of its neighbours. There is no warning; the content is
simply not there.
The rule that follows:
on iPhone, spend depth and height, not width.
A
vertical stack receding into the distance reads well in portrait; a horizontal
carousel does not, unless the items are close to the camera and few.
Layout
Portrait phone
Landscape / iPad
Vertical stack, receding in Z
Works
Works
Depth-sorted deck toward camera
Works
Works
Horizontal ring or carousel
Falls out of frame
Works
Orbit controls silently override your camera
.realityViewCameraControls(.orbit)
is the quickest way to make a scene
inspectable. Combined with
content.cameraTarget
, it
auto-frames the
target's bounds and discards the PerspectiveCamera transform you just set.
Two failures follow, both easy to hit:
// WRONG — the target is assigned before its contents exist.
// Entities built in an async task have not been added yet, so the target has
// no bounds. The camera frames nothing and parks at the origin, inside the
// scene, looking at the backs of the cards.
content.cameraTarget = gallery
Task { await populate(gallery) }
// RIGHT — populate first, then hand it over.
await populate(gallery)
content.cameraTarget = gallery
// WRONG — auto-framing with no floor on scene size.
// With one small entity the bounds are tiny, so the camera zooms in until that
// single card fills the screen.
content.cameraTarget = gallery
// RIGHT — give the target a minimum extent so small scenes frame sanely.
let bounds = gallery.visualBounds(relativeTo: nil)
if bounds.boundingRadius < 0.25 {
gallery.addChild(makeInvisibleFramingBox(radius: 0.25))
}
content.cameraTarget = gallery
Text in 3D:
MeshResource.generateText
does not exist on iOS
It is macOS and visionOS only — it is not in the iOS SDK at all, so the fix is
not an availability guard, it is a different approach. Render a SwiftUI view to
an image and use it as a texture:
@MainActor
func makeLabel(_ text: String) throws -> ModelEntity {
let renderer = ImageRenderer(content:
Text(text)
.font(.system(.title2, design: .rounded).weight(.semibold))
.foregroundStyle(.white)
.padding(24)
)
renderer.scale = 3
guard let cgImage = renderer.cgImage else { throw LabelError.renderFailed }
let texture = try TextureResource(image: cgImage, options: .init(semantic: .color))
var material = UnlitMaterial()
material.color = .init(texture: .init(texture))
material.blending = .transparent(opacity: 1)
let plane = MeshResource.generatePlane(width: 0.4, height: 0.1)
return ModelEntity(mesh: plane, materials: [material])
}
UnlitMaterial
, not
PhysicallyBasedMaterial
: rendered text should not pick up
scene lighting, or it dims as the camera moves and stops looking like text.
Scene
is ambiguous when both frameworks are imported
RealityKit and SwiftUI each declare a
Scene
. The
@main
App file is where
they collide, because that is where components and systems get registered:
// WRONG — `some Scene` no longer resolves.
import RealityKit
import SwiftUI
@main
struct GalleryApp: App {
var body: some Scene { WindowGroup { GalleryView() } } // ambiguous
}
// RIGHT — qualify it, or keep RealityKit out of this file entirely.
var body: some SwiftUI.Scene { WindowGroup { GalleryView() } }
Preferably the second: register components from the view that uses them, and
the
@main
file never imports RealityKit.
Checklist for non-AR 3D on iOS
[ ]
content.camera = .virtual
set — no camera prompt, no live feed
[ ] A
PerspectiveCamera
added, since opting out removes the default
[ ] A light rig added; PBR materials render black without one
[ ] Layout spends depth and height, not width, on portrait phones
[ ]
cameraTarget
assigned only after the target has contents
[ ] Small scenes given a minimum framing extent
[ ] No
MeshResource.generateText
— texture route for labels
[ ]
SwiftUI.Scene
qualified, or RealityKit kept out of the
@main
file
[ ] iOS 17 fallback via
ARView(cameraMode: .nonAR)
if the target allows it
See also:
docs/frameworks/arkit.md
,
docs/platforms/visionos.md
.
---
# SceneKit -- Legacy 3D Scenes, Animation, and AR Rendering
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-scenekit.html
← Back to all frameworks & guides
All articles →Graphics, 3D, and Games · Reference guideSceneKit -- Legacy 3D Scenes, Animation, and AR RenderingRepository guidance for SceneKit. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
Overview
1. Choosing SceneKit vs RealityKit vs Metal
2. SwiftUI SceneView
3. UIKit SCNView
4. Loading Assets
5. Nodes, Transforms, and Animation
6. Materials and Lighting
7. Hit Testing and Selection
8. Physics
9. ARKit with ARSCNView
10. Custom Rendering and Metal Interop
11. Performance Checklist
12. Common Pitfalls
13. Migration Notes to RealityKit
14. Review Checklist
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Create scene content
↓2
Configure camera and lighting
↓3
Animate nodes
↓4
Profile scene performance
02 / ArchitectureResponsibility boundariesBoundary 1
Scene graphBoundary 2
Animation and physicsBoundary 3
Scene rendererConnected responsibilities, not a required class hierarchy or an execution trace.
Overview
SceneKit is Apple's high-level scene graph framework for 3D content. It describes a scene as an
SCNScene
containing an
SCNNode
hierarchy, with geometry, cameras, lights, materials, animations, physics, and positional audio attached to nodes. SceneKit renders through
SCNView
, SwiftUI's
SceneView
,
SCNLayer
, or
SCNRenderer
.
Apple describes SceneKit as
soft-deprecated
: existing apps continue to work, but new capabilities and API investment are expected in RealityKit instead. Use SceneKit when maintaining an existing app, when a project relies on
.scnassets
, shader modifiers,
SCNTechnique
, or
ARSCNView
, or when migration risk is higher than the value of moving immediately.
On visionOS, SceneKit content belongs in 2D views or textures. Build immersive spatial experiences with RealityKit and
RealityView
.
1. Choosing SceneKit vs RealityKit vs Metal
Need
Prefer
New AR, spatial, USDZ, physics, SharePlay sync
RealityKit
Existing .scnassets, SCNNode graphs, shader modifiers
SceneKit
Custom render passes, compute, low-level GPU ownership
Metal
AR camera tracking with legacy SceneKit rendering
ARKit + ARSCNView
Use RealityKit for new features unless the app already has substantial SceneKit investment.
2. SwiftUI
SceneView
import SwiftUI
import SceneKit
struct ModelPreview: View {
private let scene: SCNScene = {
let scene = SCNScene()
let node = SCNNode(geometry: SCNSphere(radius: 0.35))
node.geometry?.firstMaterial?.diffuse.contents = UIColor.systemTeal
node.geometry?.firstMaterial?.roughness.contents = 0.45
scene.rootNode.addChildNode(node)
let camera = SCNNode()
camera.camera = SCNCamera()
camera.position = SCNVector3(0, 0, 2.2)
scene.rootNode.addChildNode(camera)
let light = SCNNode()
light.light = SCNLight()
light.light?.type = .omni
light.position = SCNVector3(0, 1.2, 1.5)
scene.rootNode.addChildNode(light)
scene.background.contents = UIColor.systemBackground
return scene
}()
var body: some View {
SceneView(
scene: scene,
options: [.allowsCameraControl, .autoenablesDefaultLighting]
)
.ignoresSafeArea()
}
}
SceneView
is convenient for previews and simple viewers. For advanced control, wrap
SCNView
so you can own renderer delegates, hit testing, camera configuration, and lifecycle.
3. UIKit
SCNView
import SceneKit
import UIKit
final class SceneViewController: UIViewController {
private let sceneView = SCNView()
private let scene = SCNScene()
override func viewDidLoad() {
super.viewDidLoad()
view.addSubview(sceneView)
sceneView.frame = view.bounds
sceneView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
sceneView.scene = scene
sceneView.allowsCameraControl = true
sceneView.autoenablesDefaultLighting = true
sceneView.backgroundColor = .systemBackground
addCamera()
addContent()
}
private func addCamera() {
let cameraNode = SCNNode()
cameraNode.camera = SCNCamera()
cameraNode.position = SCNVector3(0, 1, 4)
scene.rootNode.addChildNode(cameraNode)
sceneView.pointOfView = cameraNode
}
private func addContent() {
let box = SCNNode(geometry: SCNBox(width: 1, height: 1, length: 1, chamferRadius: 0.06))
box.geometry?.firstMaterial?.diffuse.contents = UIColor.systemBlue
scene.rootNode.addChildNode(box)
let spin = SCNAction.repeatForever(.rotateBy(x: 0, y: .pi * 2, z: 0, duration: 4))
box.runAction(spin)
}
}
4. Loading Assets
import SceneKit
enum SceneAssetError: Error {
case missingAsset(String)
case missingNode(String)
}
func loadNode(named nodeName: String, from sceneName: String) throws -> SCNNode {
guard let scene = SCNScene(named: sceneName) else {
throw SceneAssetError.missingAsset(sceneName)
}
guard let node = scene.rootNode.childNode(withName: nodeName, recursively: true) else {
throw SceneAssetError.missingNode(nodeName)
}
return node.clone()
}
Store SceneKit assets in
.scnassets
. Keep asset names stable and isolate loading behind a small factory so the rest of the app does not know file names.
If an asset arrives as USDZ or needs Reality Composer Pro authoring, route new work through RealityKit instead of adding more SceneKit-only surface area.
5. Nodes, Transforms, and Animation
let ship = SCNNode()
ship.position = SCNVector3(0, 0, -2)
ship.eulerAngles = SCNVector3(0, Float.pi / 4, 0)
ship.scale = SCNVector3(0.5, 0.5, 0.5)
let moveUp = SCNAction.moveBy(x: 0, y: 0.4, z: 0, duration: 0.8)
moveUp.timingMode = .easeInEaseOut
let bob = SCNAction.sequence([moveUp, moveUp.reversed()])
ship.runAction(.repeatForever(bob), forKey: "idle-bob")
Prefer named action keys so you can stop or replace animations deterministically.
6. Materials and Lighting
let material = SCNMaterial()
material.lightingModel = .physicallyBased
material.diffuse.contents = UIColor.systemOrange
material.metalness.contents = 0.8
material.roughness.contents = 0.25
let sphere = SCNNode(geometry: SCNSphere(radius: 0.25))
sphere.geometry?.materials = [material]
let keyLight = SCNNode()
keyLight.light = SCNLight()
keyLight.light?.type = .area
keyLight.light?.intensity = 700
keyLight.position = SCNVector3(0, 2, 2)
Use physically based materials for modern assets. Avoid mixing many lighting models in one scene unless you are matching existing art.
7. Hit Testing and Selection
@objc
func handleTap(_ recognizer: UITapGestureRecognizer) {
let point = recognizer.location(in: sceneView)
let results = sceneView.hitTest(point, options: [
.searchMode: SCNHitTestSearchMode.closest.rawValue,
.boundingBoxOnly: false,
])
guard let hit = results.first else { return }
hit.node.geometry?.firstMaterial?.emission.contents = UIColor.systemYellow
}
For SwiftUI wrappers, keep gesture handling in a coordinator and send domain-level events back through closures.
8. Physics
let floor = SCNNode(geometry: SCNFloor())
floor.physicsBody = SCNPhysicsBody.static()
let ball = SCNNode(geometry: SCNSphere(radius: 0.2))
ball.position = SCNVector3(0, 2, 0)
ball.physicsBody = SCNPhysicsBody(type: .dynamic, shape: nil)
ball.physicsBody?.mass = 0.4
ball.physicsBody?.restitution = 0.7
scene.rootNode.addChildNode(floor)
scene.rootNode.addChildNode(ball)
Physics bodies are tied to node geometry and transforms. If you change geometry after creating the physics body, recreate the body or provide an explicit
SCNPhysicsShape
.
9. ARKit with
ARSCNView
import ARKit
import SceneKit
final class ARSceneController: UIViewController, ARSCNViewDelegate {
private let sceneView = ARSCNView()
override func viewDidLoad() {
super.viewDidLoad()
sceneView.delegate = self
sceneView.scene = SCNScene()
view = sceneView
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
guard ARWorldTrackingConfiguration.isSupported else { return }
let configuration = ARWorldTrackingConfiguration()
configuration.planeDetection = [.horizontal, .vertical]
configuration.environmentTexturing = .automatic
sceneView.session.run(configuration)
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
sceneView.session.pause()
}
func renderer(_ renderer: SCNSceneRenderer, didAdd node: SCNNode, for anchor: ARAnchor) {
guard let planeAnchor = anchor as? ARPlaneAnchor else { return }
let plane = SCNPlane(width: CGFloat(planeAnchor.extent.x),
height: CGFloat(planeAnchor.extent.z))
plane.firstMaterial?.diffuse.contents = UIColor.systemBlue.withAlphaComponent(0.2)
let planeNode = SCNNode(geometry: plane)
planeNode.eulerAngles.x = -.pi / 2
planeNode.position = SCNVector3(planeAnchor.center.x, 0, planeAnchor.center.z)
node.addChildNode(planeNode)
}
}
New AR features should usually use RealityKit's
ARView
; keep
ARSCNView
for legacy SceneKit renderers that already depend on
SCNNode
or
SCNMaterial
.
10. Custom Rendering and Metal Interop
Use SceneKit customization in this order:
SCNMaterial
and built-in physically based lighting.
SCNShadable
shader modifiers for localized material changes.
SCNTechnique
for multipass post-processing.
SCNRenderer
inside an existing Metal pipeline.
A full Metal renderer when SceneKit's frame graph is the constraint.
let renderer = SCNRenderer(device: metalDevice, options: nil)
renderer.scene = scene
// In your Metal render loop:
renderer.render(atTime: currentTime,
viewport: viewport,
commandBuffer: commandBuffer,
passDescriptor: renderPassDescriptor)
SceneKit can coexist with Metal, but once you need deterministic command-buffer ownership, resource heaps, tile shaders, or compute-heavy passes, move that work to Metal directly.
11. Performance Checklist
Keep draw calls low by merging static geometry where practical.
Reuse
SCNMaterial
instances for repeated objects.
Prefer lower-poly preview assets on mobile and load high-detail models only when needed.
Pause rendering when offscreen:
sceneView.isPlaying = false
.
Avoid continuously mutating large node hierarchies from SwiftUI state updates.
Profile with Instruments and Xcode's GPU tools before rewriting rendering code.
12. Common Pitfalls
Treating soft-deprecated as removed
-- existing SceneKit apps still run, but long-lived new 3D work should start in RealityKit.
Using SceneKit for new immersive visionOS apps
-- use RealityKit and
RealityView
instead.
Forgetting camera and microphone purpose strings in AR
-- AR sessions need
NSCameraUsageDescription
; audio capture also needs microphone text.
Creating physics bodies before final geometry setup
-- collision shapes can become stale.
Blocking the main thread while loading assets
-- load heavy assets before presentation or behind a loading state.
Letting SwiftUI recreate scenes on every body pass
-- store scene state in a model or wrapper.
13. Migration Notes to RealityKit
SceneKit
RealityKit
SCNNode
Entity
SCNGeometry
MeshResource / ModelComponent
SCNMaterial
SimpleMaterial, PhysicallyBasedMaterial, ShaderGraphMaterial
SCNAction
AnimationResource, systems, timeline animation
ARSCNView
ARView
.scnassets
USDZ / Reality Composer Pro packages
Migrate feature by feature. Start with leaf scenes or previews before replacing AR session ownership.
14. Review Checklist
[ ] New SceneKit work justified against RealityKit
[ ]
.scnassets
loading isolated behind factories
[ ] Scene lifecycle pauses when view disappears
[ ] AR usage includes camera purpose strings and device support checks
[ ] Materials reuse textures and avoid duplicate large allocations
[ ] Physics shapes match final geometry
[ ] SwiftUI wrappers keep SceneKit state out of
body
[ ] Migration path documented for soft-deprecated SceneKit surfaces
See also:
docs/frameworks/realitykit.md
,
docs/frameworks/arkit.md
,
docs/frameworks/metal.md
.
---
# Contacts Framework
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-services-contacts.html
← Back to all frameworks & guides
All articles →System Integration · Reference guideContacts FrameworkRepository guidance for Contacts. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
CNContactStore Setup and Authorization
CNContactFetchRequest with Key Descriptors
Searching Contacts
Creating and Updating Contacts
Contact Images and Thumbnails
CNContactPickerViewController
Complete Contact Picker Example
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Explain contact access
↓2
Request authorization
↓3
Fetch needed contact fields
↓4
Handle restricted access
02 / ArchitectureResponsibility boundariesBoundary 1
AuthorizationBoundary 2
Contact storeBoundary 3
Contact presentationConnected responsibilities, not a required class hierarchy or an execution trace.
CNContactStore Setup and Authorization
Add to
Info.plist
:
-
NSContactsUsageDescription
— required for contact access
import Contacts
@Observable
final class ContactManager {
var contacts: [CNContact] = []
var authorizationStatus: CNAuthorizationStatus = .notDetermined
var error: Error?
private let store = CNContactStore()
/// Check current authorization status
func checkAuthorization() {
authorizationStatus = CNContactStore.authorizationStatus(for: .contacts)
}
/// Request access to contacts
func requestAccess() async -> Bool {
do {
let granted = try await store.requestAccess(for: .contacts)
authorizationStatus = granted ? .authorized : .denied
return granted
} catch {
self.error = error
authorizationStatus = .denied
return false
}
}
}
CNContactFetchRequest with Key Descriptors
Key descriptors specify which contact properties to fetch. Only request what you need for performance.
extension ContactManager {
/// Standard keys for displaying a contact list
static let listKeys: [CNKeyDescriptor] = [
CNContactIdentifierKey as CNKeyDescriptor,
CNContactGivenNameKey as CNKeyDescriptor,
CNContactFamilyNameKey as CNKeyDescriptor,
CNContactOrganizationNameKey as CNKeyDescriptor,
CNContactPhoneNumbersKey as CNKeyDescriptor,
CNContactEmailAddressesKey as CNKeyDescriptor,
CNContactImageDataAvailableKey as CNKeyDescriptor,
CNContactThumbnailImageDataKey as CNKeyDescriptor,
CNContactFormatter.descriptorForRequiredKeys(for: .fullName)
]
/// Extended keys for a contact detail view
static let detailKeys: [CNKeyDescriptor] = listKeys + [
CNContactImageDataKey as CNKeyDescriptor,
CNContactPostalAddressesKey as CNKeyDescriptor,
CNContactBirthdayKey as CNKeyDescriptor,
CNContactUrlAddressesKey as CNKeyDescriptor,
CNContactSocialProfilesKey as CNKeyDescriptor,
CNContactNoteKey as CNKeyDescriptor,
CNContactJobTitleKey as CNKeyDescriptor,
CNContactDepartmentNameKey as CNKeyDescriptor,
]
/// Fetch all contacts
func fetchAllContacts() throws {
var results: [CNContact] = []
let request = CNContactFetchRequest(keysToFetch: Self.listKeys)
request.sortOrder = .userDefault
try store.enumerateContacts(with: request) { contact, _ in
results.append(contact)
}
contacts = results
}
}
Searching Contacts
extension ContactManager {
/// Search by name
func searchByName(_ name: String) throws -> [CNContact] {
let predicate = CNContact.predicateForContacts(matchingName: name)
return try store.unifiedContacts(
matching: predicate,
keysToFetch: Self.listKeys
)
}
/// Search by email
func searchByEmail(_ email: String) throws -> [CNContact] {
let predicate = CNContact.predicateForContacts(matchingEmailAddress: email)
return try store.unifiedContacts(
matching: predicate,
keysToFetch: Self.listKeys
)
}
/// Search by phone number
func searchByPhone(_ phoneNumber: String) throws -> [CNContact] {
let phoneValue = CNPhoneNumber(stringValue: phoneNumber)
let predicate = CNContact.predicateForContacts(matching: phoneValue)
return try store.unifiedContacts(
matching: predicate,
keysToFetch: Self.listKeys
)
}
/// Fetch a single contact by identifier
func fetchContact(identifier: String) throws -> CNContact {
let predicate = CNContact.predicateForContacts(withIdentifiers: [identifier])
guard let contact = try store.unifiedContacts(
matching: predicate,
keysToFetch: Self.detailKeys
).first else {
throw ContactError.notFound
}
return contact
}
/// Fetch contacts in a specific group
func fetchContacts(inGroup groupIdentifier: String) throws -> [CNContact] {
let predicate = CNContact.predicateForContactsInGroup(withIdentifier: groupIdentifier)
return try store.unifiedContacts(
matching: predicate,
keysToFetch: Self.listKeys
)
}
/// Fetch contacts in a specific container (iCloud, Google, etc.)
func fetchContacts(inContainer containerIdentifier: String) throws -> [CNContact] {
let predicate = CNContact.predicateForContactsInContainer(withIdentifier: containerIdentifier)
return try store.unifiedContacts(
matching: predicate,
keysToFetch: Self.listKeys
)
}
}
enum ContactError: LocalizedError {
case notFound, notAuthorized, saveFailed
var errorDescription: String? {
switch self {
case .notFound: "Contact not found."
case .notAuthorized: "Contacts access not authorized."
case .saveFailed: "Failed to save contact."
}
}
}
Creating and Updating Contacts
extension ContactManager {
/// Create a new contact
func createContact(
givenName: String,
familyName: String,
phoneNumbers: [(label: String, number: String)] = [],
emailAddresses: [(label: String, email: String)] = [],
organization: String? = nil,
jobTitle: String? = nil,
birthday: DateComponents? = nil,
imageData: Data? = nil
) throws -> CNContact {
let contact = CNMutableContact()
contact.givenName = givenName
contact.familyName = familyName
contact.phoneNumbers = phoneNumbers.map {
CNLabeledValue(
label: $0.label,
value: CNPhoneNumber(stringValue: $0.number)
)
}
contact.emailAddresses = emailAddresses.map {
CNLabeledValue(label: $0.label, value: $0.email as NSString)
}
if let organization { contact.organizationName = organization }
if let jobTitle { contact.jobTitle = jobTitle }
if let birthday { contact.birthday = birthday }
if let imageData { contact.imageData = imageData }
let saveRequest = CNSaveRequest()
saveRequest.add(contact, toContainerWithIdentifier: nil) // Default container
try store.execute(saveRequest)
return contact
}
/// Update an existing contact
func updateContact(_ contact: CNContact, updates: (CNMutableContact) -> Void) throws {
guard let mutable = contact.mutableCopy() as? CNMutableContact else { return }
updates(mutable)
let saveRequest = CNSaveRequest()
saveRequest.update(mutable)
try store.execute(saveRequest)
}
/// Delete a contact
func deleteContact(_ contact: CNContact) throws {
guard let mutable = contact.mutableCopy() as? CNMutableContact else { return }
let saveRequest = CNSaveRequest()
saveRequest.delete(mutable)
try store.execute(saveRequest)
}
/// Add a phone number to an existing contact
func addPhoneNumber(to contact: CNContact, label: String, number: String) throws {
try updateContact(contact) { mutable in
let newPhone = CNLabeledValue(
label: label,
value: CNPhoneNumber(stringValue: number)
)
mutable.phoneNumbers.append(newPhone)
}
}
}
Contact Images and Thumbnails
import SwiftUI
extension ContactManager {
/// Get a SwiftUI Image from contact thumbnail data
static func contactImage(for contact: CNContact) -> Image? {
if let data = contact.thumbnailImageData,
let uiImage = UIImage(data: data) {
return Image(uiImage: uiImage)
}
return nil
}
/// Get full-resolution contact image
static func contactFullImage(for contact: CNContact) -> Image? {
if let data = contact.imageData,
let uiImage = UIImage(data: data) {
return Image(uiImage: uiImage)
}
return nil
}
}
struct ContactAvatarView: View {
let contact: CNContact
var body: some View {
Group {
if let image = ContactManager.contactImage(for: contact) {
image
.resizable()
.scaledToFill()
} else {
Text(initials)
.font(.headline)
.foregroundStyle(.white)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.accentColor.gradient)
}
}
.frame(width: 44, height: 44)
.clipShape(Circle())
}
private var initials: String {
let first = contact.givenName.prefix(1)
let last = contact.familyName.prefix(1)
return "\(first)\(last)"
}
}
CNContactPickerViewController
import SwiftUI
import ContactsUI
struct ContactPickerView: UIViewControllerRepresentable {
@Environment(\.dismiss) private var dismiss
var onSelect: (CNContact) -> Void
func makeUIViewController(context: Context) -> CNContactPickerViewController {
let picker = CNContactPickerViewController()
picker.delegate = context.coordinator
// Optional: filter contacts
picker.predicateForEnablingContact = NSPredicate(
format: "phoneNumbers.@count > 0"
)
// Optional: show only specific properties
picker.displayedPropertyKeys = [
CNContactGivenNameKey,
CNContactFamilyNameKey,
CNContactPhoneNumbersKey
]
return picker
}
func updateUIViewController(_ uiViewController: CNContactPickerViewController, context: Context) {}
func makeCoordinator() -> Coordinator {
Coordinator(onSelect: onSelect, dismiss: dismiss)
}
final class Coordinator: NSObject, CNContactPickerDelegate {
let onSelect: (CNContact) -> Void
let dismiss: DismissAction
init(onSelect: @escaping (CNContact) -> Void, dismiss: DismissAction) {
self.onSelect = onSelect
self.dismiss = dismiss
}
func contactPicker(_ picker: CNContactPickerViewController, didSelect contact: CNContact) {
onSelect(contact)
}
func contactPickerDidCancel(_ picker: CNContactPickerViewController) {
dismiss()
}
}
}
Complete Contact Picker Example
import SwiftUI
import Contacts
struct ContactListView: View {
@State private var manager = ContactManager()
@State private var searchText = ""
@State private var showPicker = false
@State private var selectedContact: CNContact?
var filteredContacts: [CNContact] {
guard !searchText.isEmpty else { return manager.contacts }
return manager.contacts.filter { contact in
let fullName = CNContactFormatter.string(from: contact, style: .fullName) ?? ""
return fullName.localizedCaseInsensitiveContains(searchText)
}
}
var body: some View {
NavigationStack {
List(filteredContacts, id: \.identifier) { contact in
HStack(spacing: 12) {
ContactAvatarView(contact: contact)
VStack(alignment: .leading, spacing: 2) {
Text(CNContactFormatter.string(from: contact, style: .fullName) ?? "No Name")
.font(.body.weight(.medium))
if let phone = contact.phoneNumbers.first {
Text(phone.value.stringValue)
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
}
.contentShape(Rectangle())
.onTapGesture { selectedContact = contact }
}
.searchable(text: $searchText, prompt: "Search contacts")
.navigationTitle("Contacts")
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button("Pick", systemImage: "person.crop.circle.badge.plus") {
showPicker = true
}
}
}
.sheet(isPresented: $showPicker) {
ContactPickerView { contact in
selectedContact = contact
}
}
.sheet(item: $selectedContact) { contact in
ContactDetailSheet(contact: contact)
}
.task {
let granted = await manager.requestAccess()
if granted {
try? manager.fetchAllContacts()
}
}
}
}
}
extension CNContact: @retroactive Identifiable {
public var id: String { identifier }
}
struct ContactDetailSheet: View {
let contact: CNContact
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
List {
Section {
HStack {
Spacer()
VStack(spacing: 8) {
ContactAvatarView(contact: contact)
.scaleEffect(2)
.padding(22)
Text(CNContactFormatter.string(from: contact, style: .fullName) ?? "")
.font(.title2.bold())
if !contact.organizationName.isEmpty {
Text(contact.organizationName)
.foregroundStyle(.secondary)
}
}
Spacer()
}
.listRowBackground(Color.clear)
}
if !contact.phoneNumbers.isEmpty {
Section("Phone") {
ForEach(contact.phoneNumbers, id: \.identifier) { phone in
LabeledContent(
CNLabeledValue<NSString>.localizedString(forLabel: phone.label ?? ""),
value: phone.value.stringValue
)
}
}
}
if !contact.emailAddresses.isEmpty {
Section("Email") {
ForEach(contact.emailAddresses, id: \.identifier) { email in
LabeledContent(
CNLabeledValue<NSString>.localizedString(forLabel: email.label ?? ""),
value: email.value as String
)
}
}
}
}
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") { dismiss() }
}
}
}
}
}
---
# EventKit
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-services-eventkit.html
← Back to all frameworks & guides
All articles →System Integration · Reference guideEventKitRepository guidance for EventKit. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
EKEventStore Setup and Authorization
Reading Calendars and Events
Creating, Editing, and Deleting Events
Recurrence Rules
Reminders (EKReminder)
EKEventEditViewController (UIKit / SwiftUI Bridge)
Complete Calendar Integration Example
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Choose calendar or reminder task
↓2
Request scoped access
↓3
Read or save an item
↓4
Handle permission changes
02 / ArchitectureResponsibility boundariesBoundary 1
Event storeBoundary 2
Calendar or reminder dataBoundary 3
Editing UIConnected responsibilities, not a required class hierarchy or an execution trace.
EKEventStore Setup and Authorization
Add to
Info.plist
:
-
NSCalendarsUsageDescription
— required for calendar access
-
NSCalendarsFullAccessUsageDescription
— iOS 17+ full access
-
NSCalendarsWriteOnlyAccessUsageDescription
— iOS 17+ write-only access
-
NSRemindersUsageDescription
— required for reminders access
-
NSRemindersFullAccessUsageDescription
— iOS 17+ reminders
import EventKit
@Observable
final class CalendarManager {
var calendars: [EKCalendar] = []
var events: [EKEvent] = []
var authorizationStatus: EKAuthorizationStatus = .notDetermined
var error: Error?
private let store = EKEventStore()
/// Request calendar access (iOS 17+)
func requestAccess() async {
do {
if #available(iOS 17, *) {
let granted = try await store.requestFullAccessToEvents()
authorizationStatus = granted ? .fullAccess : .denied
} else {
let granted = try await store.requestAccess(to: .event)
authorizationStatus = granted ? .authorized : .denied
}
} catch {
self.error = error
authorizationStatus = .denied
}
}
/// Request reminders access (iOS 17+)
func requestRemindersAccess() async throws -> Bool {
if #available(iOS 17, *) {
return try await store.requestFullAccessToReminders()
} else {
return try await store.requestAccess(to: .reminder)
}
}
}
Reading Calendars and Events
extension CalendarManager {
func loadCalendars() {
calendars = store.calendars(for: .event)
}
/// Fetch events within a date range
func fetchEvents(from startDate: Date, to endDate: Date, calendars: [EKCalendar]? = nil) {
let predicate = store.predicateForEvents(
withStart: startDate,
end: endDate,
calendars: calendars // nil = all calendars
)
events = store.events(matching: predicate)
.sorted { $0.startDate < $1.startDate }
}
/// Fetch events for today
func fetchTodayEvents() {
let now = Date()
let startOfDay = Calendar.current.startOfDay(for: now)
let endOfDay = Calendar.current.date(byAdding: .day, value: 1, to: startOfDay)!
fetchEvents(from: startOfDay, to: endOfDay)
}
/// Fetch events for the next N days
func fetchUpcomingEvents(days: Int = 7) {
let now = Date()
let future = Calendar.current.date(byAdding: .day, value: days, to: now)!
fetchEvents(from: now, to: future)
}
/// Get a single event by identifier
func event(withIdentifier id: String) -> EKEvent? {
store.event(withIdentifier: id)
}
}
Creating, Editing, and Deleting Events
extension CalendarManager {
/// Create a new event
func createEvent(
title: String,
startDate: Date,
endDate: Date,
calendar: EKCalendar? = nil,
location: String? = nil,
notes: String? = nil,
url: URL? = nil,
alarms: [TimeInterval] = [-600] // 10 minutes before
) throws -> EKEvent {
let event = EKEvent(eventStore: store)
event.title = title
event.startDate = startDate
event.endDate = endDate
event.calendar = calendar ?? store.defaultCalendarForNewEvents
event.location = location
event.notes = notes
event.url = url
// Add alarms
for offset in alarms {
event.addAlarm(EKAlarm(relativeOffset: offset))
}
try store.save(event, span: .thisEvent)
return event
}
/// Edit an existing event
func updateEvent(_ event: EKEvent, span: EKSpan = .thisEvent) throws {
try store.save(event, span: span)
}
/// Delete an event
func deleteEvent(_ event: EKEvent, span: EKSpan = .thisEvent) throws {
try store.remove(event, span: span)
}
}
Recurrence Rules
extension CalendarManager {
/// Create a daily recurring event
func createDailyEvent(title: String, startDate: Date, endDate: Date) throws -> EKEvent {
let rule = EKRecurrenceRule(
recurrenceWith: .daily,
interval: 1, // Every 1 day
end: EKRecurrenceEnd(occurrenceCount: 30) // 30 occurrences
)
return try createRecurringEvent(title: title, start: startDate, end: endDate, rule: rule)
}
/// Create a weekly recurring event (e.g., every Monday and Wednesday)
func createWeeklyEvent(title: String, startDate: Date, endDate: Date) throws -> EKEvent {
let daysOfWeek = [
EKRecurrenceDayOfWeek(.monday),
EKRecurrenceDayOfWeek(.wednesday)
]
let rule = EKRecurrenceRule(
recurrenceWith: .weekly,
interval: 1,
daysOfTheWeek: daysOfWeek,
daysOfTheMonth: nil,
monthsOfTheYear: nil,
weeksOfTheYear: nil,
daysOfTheYear: nil,
setPositions: nil,
end: EKRecurrenceEnd(end: Calendar.current.date(byAdding: .month, value: 6, to: startDate)!)
)
return try createRecurringEvent(title: title, start: startDate, end: endDate, rule: rule)
}
/// Create a monthly recurring event (e.g., 15th of each month)
func createMonthlyEvent(title: String, startDate: Date, endDate: Date) throws -> EKEvent {
let rule = EKRecurrenceRule(
recurrenceWith: .monthly,
interval: 1,
daysOfTheWeek: nil,
daysOfTheMonth: [15 as NSNumber],
monthsOfTheYear: nil,
weeksOfTheYear: nil,
daysOfTheYear: nil,
setPositions: nil,
end: nil // Repeats forever
)
return try createRecurringEvent(title: title, start: startDate, end: endDate, rule: rule)
}
private func createRecurringEvent(
title: String, start: Date, end: Date, rule: EKRecurrenceRule
) throws -> EKEvent {
let event = EKEvent(eventStore: store)
event.title = title
event.startDate = start
event.endDate = end
event.calendar = store.defaultCalendarForNewEvents
event.addRecurrenceRule(rule)
try store.save(event, span: .futureEvents)
return event
}
}
Reminders (EKReminder)
extension CalendarManager {
func fetchReminders(in calendars: [EKCalendar]? = nil) async -> [EKReminder] {
let predicate = store.predicateForReminders(in: calendars)
return await withCheckedContinuation { continuation in
store.fetchReminders(matching: predicate) { reminders in
continuation.resume(returning: reminders ?? [])
}
}
}
func fetchIncompleteReminders(
from start: Date? = nil,
to end: Date? = nil
) async -> [EKReminder] {
let predicate = store.predicateForIncompleteReminders(
withDueDateStarting: start,
ending: end,
calendars: nil
)
return await withCheckedContinuation { continuation in
store.fetchReminders(matching: predicate) { reminders in
continuation.resume(returning: reminders ?? [])
}
}
}
func createReminder(
title: String,
dueDate: DateComponents? = nil,
priority: Int = 0,
notes: String? = nil,
list: EKCalendar? = nil
) throws -> EKReminder {
let reminder = EKReminder(eventStore: store)
reminder.title = title
reminder.dueDateComponents = dueDate
reminder.priority = priority // 0 = none, 1 = high, 5 = medium, 9 = low
reminder.notes = notes
reminder.calendar = list ?? store.defaultCalendarForNewReminders()
try store.save(reminder, commit: true)
return reminder
}
func completeReminder(_ reminder: EKReminder) throws {
reminder.isCompleted = true
reminder.completionDate = Date()
try store.save(reminder, commit: true)
}
}
EKEventEditViewController (UIKit / SwiftUI Bridge)
import SwiftUI
import EventKitUI
struct EventEditView: UIViewControllerRepresentable {
@Environment(\.dismiss) private var dismiss
let store: EKEventStore
var event: EKEvent?
func makeUIViewController(context: Context) -> EKEventEditViewController {
let controller = EKEventEditViewController()
controller.eventStore = store
controller.event = event ?? EKEvent(eventStore: store)
controller.editViewDelegate = context.coordinator
return controller
}
func updateUIViewController(_ uiViewController: EKEventEditViewController, context: Context) {}
func makeCoordinator() -> Coordinator {
Coordinator(dismiss: dismiss)
}
final class Coordinator: NSObject, EKEventEditViewDelegate {
let dismiss: DismissAction
init(dismiss: DismissAction) {
self.dismiss = dismiss
}
func eventEditViewController(
_ controller: EKEventEditViewController,
didCompleteWith action: EKEventEditViewAction
) {
dismiss()
}
}
}
Complete Calendar Integration Example
import SwiftUI
import EventKit
struct CalendarIntegrationView: View {
@State private var manager = CalendarManager()
@State private var showingAddEvent = false
@State private var newTitle = ""
@State private var newStartDate = Date()
@State private var newEndDate = Date().addingTimeInterval(3600)
var body: some View {
NavigationStack {
Group {
if manager.authorizationStatus == .notDetermined {
ContentUnavailableView(
"Calendar Access Required",
systemImage: "calendar",
description: Text("Grant access to view and manage events.")
)
} else if manager.events.isEmpty {
ContentUnavailableView("No Events", systemImage: "calendar.badge.exclamationmark")
} else {
eventList
}
}
.navigationTitle("Calendar")
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button("Add", systemImage: "plus") {
showingAddEvent = true
}
}
}
.sheet(isPresented: $showingAddEvent) {
addEventSheet
}
.task {
await manager.requestAccess()
manager.loadCalendars()
manager.fetchUpcomingEvents(days: 14)
}
}
}
private var eventList: some View {
List {
ForEach(manager.events, id: \.eventIdentifier) { event in
VStack(alignment: .leading, spacing: 4) {
HStack {
Circle()
.fill(Color(cgColor: event.calendar.cgColor))
.frame(width: 10, height: 10)
Text(event.title)
.font(.headline)
}
Text(event.startDate, format: .dateTime)
.font(.subheadline)
.foregroundStyle(.secondary)
if let location = event.location, !location.isEmpty {
Label(location, systemImage: "mappin")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
.onDelete { indexSet in
for index in indexSet {
try? manager.deleteEvent(manager.events[index])
}
manager.fetchUpcomingEvents(days: 14)
}
}
}
private var addEventSheet: some View {
NavigationStack {
Form {
TextField("Event Title", text: $newTitle)
DatePicker("Start", selection: $newStartDate)
DatePicker("End", selection: $newEndDate)
}
.navigationTitle("New Event")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { showingAddEvent = false }
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") {
try? manager.createEvent(
title: newTitle,
startDate: newStartDate,
endDate: newEndDate
)
manager.fetchUpcomingEvents(days: 14)
showingAddEvent = false
}
.disabled(newTitle.isEmpty)
}
}
}
}
}
---
# PassKit & FinanceKit
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-services-passkit.html
← Back to all frameworks & guides
All articles →Commerce and Wallet · Reference guidePassKit & FinanceKitRepository guidance for PassKit. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
PKPaymentRequest Setup
Checking Apple Pay Availability
SwiftUI PayWithApplePayButton (iOS 16+)
UIKit PKPaymentAuthorizationViewController
Payment Processing Flow
Wallet Passes (PKPass & PKPassLibrary)
FinanceKit: Apple Card/Cash Transactions (iOS 17+)
Complete Apple Pay Checkout Example
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Check payment capability
↓2
Present payment request
↓3
Send token to payment service
↓4
Complete authorization result
02 / ArchitectureResponsibility boundariesBoundary 1
Checkout UIBoundary 2
Payment authorizationBoundary 3
Payment processorConnected responsibilities, not a required class hierarchy or an execution trace.
PKPaymentRequest Setup
Configure Apple Pay with merchant details and supported networks.
import PassKit
func makePaymentRequest(amount: Decimal) -> PKPaymentRequest {
let request = PKPaymentRequest()
request.merchantIdentifier = "merchant.com.yourapp.pay"
request.supportedNetworks = [.visa, .masterCard, .amex, .discover]
request.merchantCapabilities = [.capability3DS, .capabilityDebit, .capabilityCredit]
request.countryCode = "US"
request.currencyCode = "USD"
let item = PKPaymentSummaryItem(
label: "My App Purchase",
amount: NSDecimalNumber(decimal: amount)
)
let total = PKPaymentSummaryItem(
label: "Your Company",
amount: NSDecimalNumber(decimal: amount),
type: .final
)
request.paymentSummaryItems = [item, total]
// Optional: require shipping
request.requiredShippingContactFields = [.postalAddress, .emailAddress]
request.requiredBillingContactFields = [.postalAddress]
// Shipping methods
let standard = PKShippingMethod(label: "Standard", amount: NSDecimalNumber(value: 0))
standard.identifier = "standard"
standard.detail = "Arrives in 5-7 days"
let express = PKShippingMethod(label: "Express", amount: NSDecimalNumber(value: 9.99))
express.identifier = "express"
express.detail = "Arrives in 1-2 days"
request.shippingMethods = [standard, express]
return request
}
Checking Apple Pay Availability
func canMakePayments() -> Bool {
PKPaymentAuthorizationViewController.canMakePayments(
usingNetworks: [.visa, .masterCard, .amex, .discover],
capabilities: [.capability3DS]
)
}
SwiftUI PayWithApplePayButton (iOS 16+)
import SwiftUI
import PassKit
struct CheckoutView: View {
@State private var paymentStatus: PaymentStatus = .idle
var body: some View {
VStack(spacing: 24) {
OrderSummaryView()
PayWithApplePayButton(.checkout, action: handlePayment)
.frame(height: 50)
.payWithApplePayButtonStyle(.black)
.padding(.horizontal)
}
}
func handlePayment() {
let request = makePaymentRequest(amount: 49.99)
let controller = PKPaymentAuthorizationController(paymentRequest: request)
controller.delegate = PaymentDelegate.shared
controller.present()
}
}
UIKit PKPaymentAuthorizationViewController
import UIKit
import PassKit
final class PaymentViewController: UIViewController, PKPaymentAuthorizationViewControllerDelegate {
func startPayment(amount: Decimal) {
guard PKPaymentAuthorizationViewController.canMakePayments() else {
showSetupApplePay()
return
}
let request = makePaymentRequest(amount: amount)
guard let vc = PKPaymentAuthorizationViewController(paymentRequest: request) else { return }
vc.delegate = self
present(vc, animated: true)
}
func paymentAuthorizationViewController(
_ controller: PKPaymentAuthorizationViewController,
didAuthorizePayment payment: PKPayment,
handler completion: @escaping (PKPaymentAuthorizationResult) -> Void
) {
// Send payment.token.paymentData to your server
Task {
do {
try await PaymentService.processPayment(token: payment.token)
completion(PKPaymentAuthorizationResult(status: .success, errors: nil))
} catch {
let pkError = PKPaymentRequest.paymentShippingAddressUnserviceableError(
withLocalizedDescription: "Payment failed. Please try again."
)
completion(PKPaymentAuthorizationResult(status: .failure, errors: [pkError]))
}
}
}
func paymentAuthorizationViewControllerDidFinish(
_ controller: PKPaymentAuthorizationViewController
) {
controller.dismiss(animated: true)
}
private func showSetupApplePay() {
let setup = PKPassLibrary().openPaymentSetup()
if !setup {
// Fallback: show manual card entry
}
}
}
Payment Processing Flow
enum PaymentService {
static func processPayment(token: PKPaymentToken) async throws {
// 1. Extract payment data
let paymentData = token.paymentData // Encrypted by Apple
let method = token.paymentMethod
let network = method.network?.rawValue ?? "unknown"
let type = method.type // .debit, .credit, .prepaid, .store
// 2. Send encrypted token to your payment processor
var request = URLRequest(url: URL(string: "https://api.yourserver.com/pay")!)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONEncoder().encode([
"paymentData": paymentData.base64EncodedString(),
"network": network,
"transactionId": token.transactionIdentifier
])
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
throw PaymentError.serverDeclined
}
}
}
enum PaymentError: LocalizedError {
case serverDeclined, networkUnavailable
var errorDescription: String? {
switch self {
case .serverDeclined: "Payment was declined. Please try another card."
case .networkUnavailable: "No network connection. Please try again."
}
}
}
Wallet Passes (PKPass & PKPassLibrary)
import PassKit
final class WalletPassManager {
private let library = PKPassLibrary()
/// Add a pass from downloaded .pkpass data
func addPass(data: Data) async throws {
guard PKPassLibrary.isPassLibraryAvailable() else {
throw WalletError.notAvailable
}
let pass = try PKPass(data: data)
if library.containsPass(pass) {
// Pass already in wallet — replace
library.replacePass(with: pass)
} else {
// Show the add-pass UI
guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let root = scene.keyWindow?.rootViewController else { return }
let addController = PKAddPassesViewController(pass: pass)
root.present(addController!, animated: true)
}
}
/// List all passes of a specific type
func passes(ofType type: PKPassType = .barcode) -> [PKPass] {
library.passes(of: type)
}
/// Remove a pass
func removePass(_ pass: PKPass) {
library.removePass(pass)
}
/// Download a .pkpass file from server
func downloadPass(from url: URL) async throws -> Data {
var request = URLRequest(url: url)
request.setValue("application/vnd.apple.pkpass", forHTTPHeaderField: "Accept")
let (data, _) = try await URLSession.shared.data(for: request)
return data
}
}
enum WalletError: LocalizedError {
case notAvailable
var errorDescription: String? { "Wallet is not available on this device." }
}
FinanceKit: Apple Card/Cash Transactions (iOS 17+)
import FinanceKit
@available(iOS 17, *)
final class FinanceManager {
func requestAuthorization() async -> FinanceKit.AuthorizationStatus {
await FinanceStore.shared.requestAuthorization()
}
func fetchTransactions() async throws -> [FinanceKit.Transaction] {
let store = FinanceStore.shared
let status = await store.requestAuthorization()
guard status == .authorized else {
throw FinanceError.notAuthorized
}
// Query recent Apple Card transactions
let now = Date()
let thirtyDaysAgo = Calendar.current.date(byAdding: .day, value: -30, to: now)!
let query = TransactionQuery(
startDate: thirtyDaysAgo,
endDate: now
)
let transactions = try await store.transactions(query: query)
return transactions
}
func fetchAccountBalances() async throws -> [FinanceKit.AccountBalance] {
let store = FinanceStore.shared
return try await store.accountBalances()
}
}
enum FinanceError: LocalizedError {
case notAuthorized
var errorDescription: String? { "Finance data access not authorized." }
}
Complete Apple Pay Checkout Example
import SwiftUI
import PassKit
enum PaymentStatus {
case idle, processing, success, failed(String)
}
@Observable
final class CheckoutViewModel {
var items: [CartItem] = []
var paymentStatus: PaymentStatus = .idle
var total: Decimal {
items.reduce(0) { $0 + $1.price * Decimal($1.quantity) }
}
var canPay: Bool {
PKPaymentAuthorizationController.canMakePayments(
usingNetworks: [.visa, .masterCard, .amex],
capabilities: [.capability3DS]
)
}
func checkout() async {
paymentStatus = .processing
let request = PKPaymentRequest()
request.merchantIdentifier = "merchant.com.yourapp.pay"
request.supportedNetworks = [.visa, .masterCard, .amex]
request.merchantCapabilities = [.capability3DS]
request.countryCode = "US"
request.currencyCode = "USD"
request.paymentSummaryItems = items.map {
PKPaymentSummaryItem(
label: $0.name,
amount: NSDecimalNumber(decimal: $0.price * Decimal($0.quantity))
)
} + [
PKPaymentSummaryItem(label: "Your Store", amount: NSDecimalNumber(decimal: total))
]
let controller = PKPaymentAuthorizationController(paymentRequest: request)
// Present and handle via delegate pattern
controller.present()
}
}
struct CartItem: Identifiable {
let id = UUID()
let name: String
let price: Decimal
var quantity: Int
}
struct ApplePayCheckoutView: View {
@State private var viewModel = CheckoutViewModel()
var body: some View {
NavigationStack {
List {
ForEach(viewModel.items) { item in
HStack {
Text(item.name)
Spacer()
Text("\(item.quantity)x")
Text(item.price, format: .currency(code: "USD"))
}
}
Section {
HStack {
Text("Total").fontWeight(.bold)
Spacer()
Text(viewModel.total, format: .currency(code: "USD"))
.fontWeight(.bold)
}
}
}
.navigationTitle("Checkout")
.safeAreaInset(edge: .bottom) {
if viewModel.canPay {
PayWithApplePayButton(.checkout) {
Task { await viewModel.checkout() }
}
.frame(height: 50)
.padding()
}
}
.overlay {
if case .processing = viewModel.paymentStatus {
ProgressView("Processing...")
.padding()
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
}
}
}
}
}
---
# WeatherKit
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-services-weatherkit.html
← Back to all frameworks & guides
All articles →System Integration · Reference guideWeatherKitRepository guidance for WeatherKit. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
Setup Requirements
WeatherService Basics
Current Weather
Hourly Forecast
Daily Forecast
Weather Alerts
Minute-by-Minute Precipitation
Apple Attribution Requirements (Mandatory)
Caching Strategy
Complete Weather App Example
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Choose location and forecast
↓2
Request weather data
↓3
Cache appropriate results
↓4
Show attribution and fallback
02 / ArchitectureResponsibility boundariesBoundary 1
Location queryBoundary 2
Weather serviceBoundary 3
Forecast and attributionConnected responsibilities, not a required class hierarchy or an execution trace.
Setup Requirements
WeatherKit requires an
Apple Developer Program membership
and capability registration.
Enable
WeatherKit
capability in Xcode (Signing & Capabilities)
Register the WeatherKit service in App Store Connect > Identifiers > App Services
Add
import WeatherKit
to your source files
Rate limits: 500,000 calls/month included free. Additional calls require a paid tier.
WeatherService Basics
import WeatherKit
import CoreLocation
@Observable
final class WeatherManager {
var currentWeather: CurrentWeather?
var hourlyForecast: [HourWeather] = []
var dailyForecast: [DayWeather] = []
var alerts: [WeatherAlert] = []
var minuteForecast: [MinuteWeather]?
var attribution: WeatherAttribution?
var error: Error?
private let service = WeatherService.shared
/// Fetch all weather data for a location
func fetchWeather(for location: CLLocation) async {
do {
let weather = try await service.weather(for: location)
currentWeather = weather.currentWeather
hourlyForecast = Array(weather.hourlyForecast.prefix(24))
dailyForecast = Array(weather.dailyForecast.prefix(10))
alerts = weather.weatherAlerts ?? []
minuteForecast = weather.minuteForecast.map { Array($0) }
// Always fetch attribution (required by Apple)
attribution = try await service.attribution
} catch {
self.error = error
}
}
}
Current Weather
func displayCurrentWeather(_ current: CurrentWeather) {
let temp = current.temperature // Measurement<UnitTemperature>
let apparentTemp = current.apparentTemperature
let condition = current.condition // .clear, .cloudy, .rain, etc.
let symbolName = current.symbolName // SF Symbol name
let humidity = current.humidity // 0.0 ... 1.0
let windSpeed = current.wind.speed // Measurement<UnitSpeed>
let windDirection = current.wind.compassDirection // .north, .northEast, etc.
let uvIndex = current.uvIndex.value // Int
let pressure = current.pressure // Measurement<UnitPressure>
let visibility = current.visibility // Measurement<UnitLength>
let dewPoint = current.dewPoint
// Format for display
let formatter = MeasurementFormatter()
formatter.unitOptions = .providedUnit
let tempString = temp.formatted(.measurement(width: .abbreviated))
// e.g., "72°F" or "22°C" based on locale
}
Hourly Forecast
func processHourlyForecast(_ hours: [HourWeather]) {
for hour in hours {
let date = hour.date
let temp = hour.temperature
let condition = hour.condition
let symbol = hour.symbolName
let precipChance = hour.precipitationChance // 0.0 ... 1.0
let precipAmount = hour.precipitationAmount // Measurement<UnitLength>
let humidity = hour.humidity
let windSpeed = hour.wind.speed
let cloudCover = hour.cloudCover // 0.0 ... 1.0
}
}
Daily Forecast
func processDailyForecast(_ days: [DayWeather]) {
for day in days {
let date = day.date
let highTemp = day.highTemperature
let lowTemp = day.lowTemperature
let condition = day.condition
let symbol = day.symbolName
let precipChance = day.precipitationChance
let sunrise = day.sun.sunrise // Date?
let sunset = day.sun.sunset // Date?
let moonPhase = day.moon.phase // .new, .full, .firstQuarter, etc.
let moonrise = day.moon.moonrise
let uvIndexMax = day.uvIndex.value
let windMax = day.wind.speed
}
}
Weather Alerts
func processAlerts(_ alerts: [WeatherAlert]) {
for alert in alerts {
let summary = alert.summary // Human-readable summary
let severity = alert.severity // .minor, .moderate, .severe, .extreme
let source = alert.source // Issuing authority
let region = alert.region // Affected region name
let detailsURL = alert.detailsURL // URL for full details
switch alert.severity {
case .extreme:
// Show prominent red banner
break
case .severe:
// Show orange warning
break
case .moderate:
// Show yellow advisory
break
case .minor:
// Show informational notice
break
default:
break
}
}
}
Minute-by-Minute Precipitation
/// Available only in select countries (US, UK, Ireland, etc.)
func processMinuteForecast(_ minutes: [MinuteWeather]?) {
guard let minutes else {
// Minute forecast not available for this location
return
}
for minute in minutes {
let date = minute.date
let precipChance = minute.precipitationChance
let precipIntensity = minute.precipitationIntensity // Measurement<UnitSpeed>
}
// Summarize: will it rain in the next hour?
let precipitationExpected = minutes.contains { $0.precipitationChance > 0.5 }
let firstRainMinute = minutes.first { $0.precipitationChance > 0.5 }
}
Apple Attribution Requirements (Mandatory)
Apple
requires
you to display the Apple Weather attribution in your app.
import SwiftUI
import WeatherKit
struct WeatherAttributionView: View {
let attribution: WeatherAttribution?
var body: some View {
if let attribution {
VStack(spacing: 4) {
// Required: Apple Weather logo
AsyncImage(url: attribution.combinedMarkDarkURL) { image in
image.resizable().scaledToFit()
} placeholder: {
EmptyView()
}
.frame(height: 14)
// Required: link to legal attribution page
Link("Weather Data Sources", destination: attribution.legalPageURL)
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
}
Caching Strategy
actor WeatherCache {
static let shared = WeatherCache()
private var cache: [String: CachedWeather] = [:]
private let maxAge: TimeInterval = 900 // 15 minutes
struct CachedWeather {
let weather: Weather
let timestamp: Date
}
func weather(for key: String) -> Weather? {
guard let cached = cache[key],
Date().timeIntervalSince(cached.timestamp) < maxAge else {
cache[key] = nil
return nil
}
return cached.weather
}
func store(_ weather: Weather, for key: String) {
cache[key] = CachedWeather(weather: weather, timestamp: Date())
}
/// Create a cache key from coordinates (rounded to reduce duplicate calls)
static func key(lat: Double, lon: Double) -> String {
let roundedLat = (lat * 100).rounded() / 100
let roundedLon = (lon * 100).rounded() / 100
return "\(roundedLat),\(roundedLon)"
}
}
Complete Weather App Example
import SwiftUI
import WeatherKit
import CoreLocation
@Observable
final class WeatherViewModel {
var currentWeather: CurrentWeather?
var hourly: [HourWeather] = []
var daily: [DayWeather] = []
var attribution: WeatherAttribution?
var isLoading = false
var errorMessage: String?
private let service = WeatherService.shared
func load(latitude: Double, longitude: Double) async {
isLoading = true
defer { isLoading = false }
let location = CLLocation(latitude: latitude, longitude: longitude)
let cacheKey = WeatherCache.key(lat: latitude, lon: longitude)
// Check cache first
if let cached = await WeatherCache.shared.weather(for: cacheKey) {
applyWeather(cached)
return
}
do {
let weather = try await service.weather(for: location)
await WeatherCache.shared.store(weather, for: cacheKey)
applyWeather(weather)
attribution = try await service.attribution
} catch {
errorMessage = error.localizedDescription
}
}
private func applyWeather(_ weather: Weather) {
currentWeather = weather.currentWeather
hourly = Array(weather.hourlyForecast.prefix(24))
daily = Array(weather.dailyForecast.prefix(10))
}
}
struct WeatherAppView: View {
@State private var viewModel = WeatherViewModel()
var body: some View {
ScrollView {
VStack(spacing: 20) {
// Current conditions
if let current = viewModel.currentWeather {
CurrentWeatherCard(weather: current)
}
// Hourly
if !viewModel.hourly.isEmpty {
HourlyForecastRow(hours: viewModel.hourly)
}
// Daily
if !viewModel.daily.isEmpty {
DailyForecastList(days: viewModel.daily)
}
// Attribution (required)
WeatherAttributionView(attribution: viewModel.attribution)
.padding(.top, 8)
}
.padding()
}
.overlay {
if viewModel.isLoading {
ProgressView()
}
}
.task {
await viewModel.load(latitude: 37.7749, longitude: -122.4194)
}
}
}
struct CurrentWeatherCard: View {
let weather: CurrentWeather
var body: some View {
VStack(spacing: 8) {
Image(systemName: weather.symbolName)
.font(.system(size: 60))
.symbolRenderingMode(.multicolor)
Text(weather.temperature.formatted(.measurement(width: .abbreviated)))
.font(.system(size: 56, weight: .thin, design: .rounded))
Text(weather.condition.description)
.font(.title3)
.foregroundStyle(.secondary)
HStack(spacing: 24) {
Label(
weather.wind.speed.formatted(.measurement(width: .abbreviated)),
systemImage: "wind"
)
Label(
"\(Int(weather.humidity * 100))%",
systemImage: "humidity"
)
Label(
"UV \(weather.uvIndex.value)",
systemImage: "sun.max"
)
}
.font(.subheadline)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
.padding()
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16))
}
}
struct HourlyForecastRow: View {
let hours: [HourWeather]
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 16) {
ForEach(hours, id: \.date) { hour in
VStack(spacing: 6) {
Text(hour.date, format: .dateTime.hour())
.font(.caption)
Image(systemName: hour.symbolName)
.symbolRenderingMode(.multicolor)
Text(hour.temperature.formatted(.measurement(width: .abbreviated)))
.font(.callout.bold())
}
}
}
.padding(.horizontal)
}
}
}
struct DailyForecastList: View {
let days: [DayWeather]
var body: some View {
VStack(spacing: 12) {
ForEach(days, id: \.date) { day in
HStack {
Text(day.date, format: .dateTime.weekday(.abbreviated))
.frame(width: 40, alignment: .leading)
Image(systemName: day.symbolName)
.symbolRenderingMode(.multicolor)
.frame(width: 30)
Spacer()
Text(day.lowTemperature.formatted(.measurement(width: .abbreviated)))
.foregroundStyle(.secondary)
Text(day.highTemperature.formatted(.measurement(width: .abbreviated)))
}
.font(.callout)
}
}
.padding()
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16))
}
}
---
# StoreKit 2
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-storekit.html
← Back to all frameworks & guides
All articles →Commerce and Wallet · Reference guideStoreKit 2Repository guidance for StoreKit. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
Product and Product.SubscriptionInfo
Loading Products
Subscription Info
Purchase Flow (Product.purchase())
Transaction Verification and Listener
Subscription Management and Status
Offer Codes and Promotional Offers
StoreKit Configuration File for Testing
Xcode Configuration Steps
Configuration Options
Synced Configuration (Xcode 14+)
Testing in Sandbox Environment
Complete Purchase Flow Example
iOS 18+ & Marketplace Additions
PaymentMethodBinding
AdAttributionKit
External Purchases / Alternative Marketplaces (EU DMA)
MarketplaceKit
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Load products
↓2
Begin purchase
↓3
Verify transaction
↓4
Deliver entitlement and finish
02 / ArchitectureResponsibility boundariesBoundary 1
Storefront UIBoundary 2
Verified transactionsBoundary 3
Entitlement stateConnected responsibilities, not a required class hierarchy or an execution trace.
StoreKit 2 is Apple's modern Swift-native framework for in-app purchases and subscriptions. It replaces the original StoreKit with async/await APIs, automatic transaction verification, and a cleaner purchase flow.
Product and Product.SubscriptionInfo
Loading Products
import StoreKit
class StoreManager: ObservableObject {
@Published var products: [Product] = []
// Define product identifiers matching App Store Connect
let productIDs: Set<String> = [
"com.app.premium.monthly",
"com.app.premium.yearly",
"com.app.gems.100",
"com.app.unlock.feature"
]
func loadProducts() async {
do {
let storeProducts = try await Product.products(for: productIDs)
// Sort by type and price
products = storeProducts.sorted { $0.price < $1.price }
for product in products {
print("ID: \(product.id)")
print("Display name: \(product.displayName)")
print("Description: \(product.description)")
print("Price: \(product.displayPrice)")
print("Type: \(product.type)") // .consumable, .nonConsumable, .autoRenewable, .nonRenewable
}
} catch {
print("Failed to load products: \(error)")
}
}
}
Subscription Info
func checkSubscriptionInfo(for product: Product) async {
guard let subscription = product.subscription else {
print("Not a subscription product")
return
}
// Subscription properties
print("Period: \(subscription.subscriptionPeriod)") // e.g., 1 month
print("Group ID: \(subscription.subscriptionGroupID)")
print("Is eligible for intro offer: \(subscription.isEligibleForIntroOffer)")
// Check subscription status
if let statuses = try? await subscription.status {
for status in statuses {
switch status.state {
case .subscribed:
print("Active subscription")
case .expired:
print("Subscription expired")
case .revoked:
print("Subscription revoked")
case .inGracePeriod:
print("In grace period")
case .inBillingRetryPeriod:
print("Billing retry")
default:
print("Unknown state")
}
// Access renewal info
if let renewalInfo = try? status.renewalInfo.payloadValue {
print("Will renew: \(renewalInfo.willAutoRenew)")
print("Auto-renew product: \(renewalInfo.autoRenewPreference ?? "none")")
print("Expiration reason: \(String(describing: renewalInfo.expirationReason))")
}
}
}
}
Purchase Flow (Product.purchase())
class PurchaseManager: ObservableObject {
@Published var purchasedProductIDs: Set<String> = []
func purchase(_ product: Product) async throws -> Transaction? {
// Optional: set purchase options
let result = try await product.purchase(options: [
.appAccountToken(UUID()) // Associate purchase with user account
])
switch result {
case .success(let verification):
// Verify the transaction
let transaction = try checkVerified(verification)
// Deliver content
await deliverContent(for: transaction)
// CRITICAL: Always finish the transaction
await transaction.finish()
return transaction
case .userCancelled:
print("User cancelled the purchase")
return nil
case .pending:
// Transaction requires approval (e.g., Ask to Buy)
print("Purchase pending approval")
return nil
@unknown default:
return nil
}
}
func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
switch result {
case .unverified(_, let error):
throw StoreError.verificationFailed(error)
case .verified(let value):
return value
}
}
func deliverContent(for transaction: Transaction) async {
purchasedProductIDs.insert(transaction.productID)
// Update your app's state, unlock features, add consumables, etc.
}
}
enum StoreError: Error {
case verificationFailed(VerificationResult<Transaction>.VerificationError)
case purchaseFailed
}
Transaction Verification and Listener
Start the listener as early as possible (typically in your App init) to handle transactions completed outside of the purchase flow such as renewals, Ask to Buy approvals, and refunds.
@main
struct MyApp: App {
@StateObject private var storeManager = StoreManager()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(storeManager)
.task {
await storeManager.listenForTransactions()
await storeManager.checkEntitlements()
}
}
}
}
extension StoreManager {
func listenForTransactions() async {
// Iterate through any transactions that are not yet finished
for await result in Transaction.updates {
do {
let transaction = try checkVerified(result)
await deliverContent(for: transaction)
await transaction.finish()
} catch {
print("Transaction verification failed: \(error)")
}
}
}
// Check current entitlements on launch
func checkEntitlements() async {
for await result in Transaction.currentEntitlements {
do {
let transaction = try checkVerified(result)
purchasedProductIDs.insert(transaction.productID)
} catch {
print("Entitlement verification failed: \(error)")
}
}
}
// Get the latest transaction for a specific product
func latestTransaction(for productID: String) async -> Transaction? {
guard let result = await Transaction.latest(for: productID) else {
return nil
}
return try? checkVerified(result)
}
func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
switch result {
case .unverified(_, let error):
throw error
case .verified(let value):
return value
}
}
}
Subscription Management and Status
class SubscriptionManager: ObservableObject {
@Published var subscriptionGroupStatus: Product.SubscriptionInfo.Status?
func updateSubscriptionStatus(products: [Product]) async {
guard let groupID = products.first?.subscription?.subscriptionGroupID else { return }
do {
let statuses = try await Product.SubscriptionInfo.status(for: groupID)
for status in statuses {
guard case .verified(let renewalInfo) = status.renewalInfo,
case .verified(let transaction) = status.transaction else {
continue
}
switch status.state {
case .subscribed:
print("Subscribed to: \(transaction.productID)")
print("Expires: \(transaction.expirationDate?.formatted() ?? "never")")
subscriptionGroupStatus = status
case .expired:
if renewalInfo.gracePeriodExpirationDate != nil {
print("In grace period")
}
case .revoked:
print("Revoked on: \(transaction.revocationDate?.formatted() ?? "")")
case .inBillingRetryPeriod:
print("Billing retry - still provide access")
subscriptionGroupStatus = status
case .inGracePeriod:
print("Grace period - still provide access")
subscriptionGroupStatus = status
default:
break
}
}
} catch {
print("Failed to check status: \(error)")
}
}
// Show the system manage subscriptions sheet
func showManageSubscriptions() async {
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene else { return }
do {
try await AppStore.showManageSubscriptions(in: windowScene)
} catch {
print("Failed to show manage subscriptions: \(error)")
}
}
}
Offer Codes and Promotional Offers
extension StoreManager {
// Present the system offer code redemption sheet
func presentOfferCodeRedemption() async {
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene else { return }
do {
try await AppStore.presentOfferCodeRedeemSheet(in: windowScene)
} catch {
print("Failed to present offer code sheet: \(error)")
}
}
// Check introductory offer eligibility
func checkIntroEligibility(for product: Product) async -> Bool {
guard let subscription = product.subscription else { return false }
return await subscription.isEligibleForIntroOffer
}
// Purchase with promotional offer (requires server-signed offer)
func purchaseWithPromotionalOffer(
_ product: Product,
offerID: String,
keyID: String,
nonce: UUID,
signature: Data,
timestamp: Int
) async throws -> Transaction? {
let offer = Product.PurchaseOption.promotionalOffer(
offerID: offerID,
keyID: keyID,
nonce: nonce,
signature: signature,
timestamp: timestamp
)
let result = try await product.purchase(options: [offer])
switch result {
case .success(let verification):
let transaction = try checkVerified(verification)
await transaction.finish()
return transaction
case .userCancelled, .pending:
return nil
@unknown default:
return nil
}
}
}
StoreKit Configuration File for Testing
Create a StoreKit configuration file in Xcode: File > New > File > StoreKit Configuration File.
Xcode Configuration Steps
Create a
.storekit
configuration file in your project
Add subscription groups and products with IDs matching your code
Set the configuration in your scheme: Edit Scheme > Run > Options > StoreKit Configuration
Products defined here are available in the simulator and SwiftUI previews
Configuration Options
Subscription Group
: Group related subscription tiers with upgrade/downgrade ordering
Introductory Offer
: Free trial, pay-up-front, or pay-as-you-go
Promotional Offer
: Discounted pricing for existing or lapsed subscribers
Localization
: Add localized display names and descriptions per region
Price
: Set price in your base currency; Xcode simulates other currencies
Synced Configuration (Xcode 14+)
Instead of a local file you can sync your StoreKit configuration directly from App Store Connect. This ensures product definitions match your live setup.
Testing in Sandbox Environment
// Sandbox testing on device:
// 1. Create sandbox tester in App Store Connect > Users and Access > Sandbox
// 2. Sign in on device: Settings > App Store > Sandbox Account
// 3. Sandbox subscriptions renew at accelerated rates:
// - 1 week = 3 minutes
// - 1 month = 5 minutes
// - 1 year = 1 hour
// 4. Subscriptions auto-renew up to 6 times then expire
// StoreKit Testing in Xcode (local .storekit file):
// - Use Transaction Manager: Debug > StoreKit > Manage Transactions
// - Approve or decline Ask to Buy transactions
// - Refund transactions
// - Trigger billing retry and grace period scenarios
// - Speed up or expire subscriptions
// - Force interrupted purchases
#if DEBUG
extension StoreManager {
func debugPrintAllTransactions() async {
var transactions: [Transaction] = []
for await result in Transaction.all {
if let transaction = try? checkVerified(result) {
transactions.append(transaction)
}
}
print("Total transactions: \(transactions.count)")
for t in transactions {
print(" \(t.productID) - \(t.purchaseDate) - revoked: \(t.revocationDate != nil)")
}
}
}
#endif
Complete Purchase Flow Example
import SwiftUI
import StoreKit
struct PaywallView: View {
@StateObject private var store = StoreManager()
@State private var isPurchasing = false
@State private var errorMessage: String?
var body: some View {
VStack(spacing: 20) {
Text("Upgrade to Premium")
.font(.largeTitle.bold())
ForEach(store.products, id: \.id) { product in
ProductCard(product: product) {
Task {
await purchaseProduct(product)
}
}
.disabled(isPurchasing || store.purchasedProductIDs.contains(product.id))
}
if let error = errorMessage {
Text(error)
.foregroundColor(.red)
.font(.caption)
}
Button("Restore Purchases") {
Task { await store.checkEntitlements() }
}
}
.padding()
.task {
await store.loadProducts()
}
}
func purchaseProduct(_ product: Product) async {
isPurchasing = true
defer { isPurchasing = false }
do {
if let transaction = try await store.purchase(product) {
print("Purchased: \(transaction.productID)")
}
} catch {
errorMessage = error.localizedDescription
}
}
}
struct ProductCard: View {
let product: Product
let onPurchase: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(product.displayName)
.font(.headline)
Text(product.description)
.font(.subheadline)
.foregroundColor(.secondary)
HStack {
Text(product.displayPrice)
.font(.title2.bold())
if let subscription = product.subscription {
Text("/ \(subscription.subscriptionPeriod.debugDescription)")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
Button("Subscribe", action: onPurchase)
.buttonStyle(.borderedProminent)
}
}
.padding()
.background(.regularMaterial)
.cornerRadius(12)
}
}
// SubscriptionStoreView (iOS 17+) - Apple's built-in paywall UI
struct SimplePaywall: View {
var body: some View {
SubscriptionStoreView(groupID: "your_group_id") {
VStack {
Image(systemName: "star.fill")
.font(.system(size: 60))
.foregroundStyle(.yellow)
Text("Unlock Premium Features")
.font(.title.bold())
}
}
.subscriptionStoreButtonLabel(.multiline)
.subscriptionStorePickerItemBackground(.thinMaterial)
.storeButton(.visible, for: .restorePurchases)
}
}
iOS 18+ & Marketplace Additions
PaymentMethodBinding
PaymentMethodBinding
allows apps to bind a payment method to an Apple Account for streamlined purchasing, particularly useful for services that manage recurring billing outside of the App Store.
import StoreKit
// Request payment method binding for an Apple Account
func bindPaymentMethod() async {
do {
let binding = try await PaymentMethodBinding.request(
// Provide your app-specific payment method configuration
)
print("Payment method bound successfully: \(binding)")
} catch {
print("Payment method binding failed: \(error)")
}
}
AdAttributionKit
AdAttributionKit
is the modern replacement for
SKAdNetwork
, providing privacy-preserving ad attribution for app install campaigns. It supports both StoreKit-rendered ads and view-through attribution.
import AdAttributionKit
// Register an app impression for attribution
func registerAdImpression() async {
do {
// Create an impression for a displayed ad
let impression = try AdImpression(
adNetworkID: "example123.adnetwork",
sourceIdentifier: 5678,
advertisedItemIdentifier: 1234567890,
adType: .banner,
adPurchaserName: "AdNetwork Inc.",
impressionType: .view // or .storeKitRendered
)
// Register the impression with the system
try await impression.handleImpression()
print("Ad impression registered for attribution")
} catch {
print("Failed to register impression: \(error)")
}
}
// For postbacks (server-side), AdAttributionKit sends cryptographically signed
// postbacks to ad networks after a conversion, similar to SKAdNetwork but with:
// - Improved reengagement attribution
// - Support for multiple postback windows
// - Developer postbacks for your own analytics
External Purchases / Alternative Marketplaces (EU DMA)
For apps distributed in the EU, iOS 18 introduces APIs for external purchase links and alternative marketplace support under the Digital Markets Act (DMA).
import StoreKit
// MARK: - External Purchase Link (EU only)
// Present an external purchase link that navigates the user
// to your website for completing the purchase
func presentExternalPurchaseLink() async {
do {
// Check if external purchases are available (EU region only)
guard ExternalPurchaseLink.canOpen else {
print("External purchase links not available in this region")
return
}
// Open the external purchase flow
// The system shows a confirmation sheet before navigating to your URL
try await ExternalPurchaseLink.open(
url: URL(string: "https://yourapp.com/subscribe")!
)
} catch {
print("Failed to open external purchase link: \(error)")
}
}
// MARK: - External Purchase (Alternative Billing)
// For apps that use alternative payment processing
func processExternalPurchase() async {
do {
guard ExternalPurchase.canMakePayments else {
print("External purchases not available")
return
}
// Notify the system about an external purchase for compliance tracking
let token = try await ExternalPurchase.token
// Send this token to your server for validation and to report
// the transaction to Apple (required for commission compliance)
print("External purchase token: \(token)")
} catch {
print("External purchase failed: \(error)")
}
}
MarketplaceKit
MarketplaceKit
supports alternative app marketplace distribution in the EU. Marketplace apps can distribute third-party apps outside of the App Store.
import MarketplaceKit
// MARK: - Alternative Marketplace Distribution
// Check if the current app was installed from an alternative marketplace
func checkMarketplaceInstallation() async {
do {
let appInstallation = try await AppInstallation.current
// Verify the marketplace that distributed this app
print("Marketplace: \(appInstallation.marketplace)")
print("Install date: \(appInstallation.installDate)")
// Marketplace-distributed apps can:
// 1. Use alternative payment processors
// 2. Distribute apps not available on the App Store
// 3. Set their own curation and review policies
} catch {
print("Failed to check installation: \(error)")
}
}
// For building a marketplace app itself:
// 1. Request the Marketplace entitlement from Apple
// 2. Implement app distribution via MarketplaceKit APIs
// 3. Handle app installation, updates, and license verification
// 4. Comply with Apple's marketplace requirements (notarization, etc.)
// Marketplace apps must handle:
struct MarketplaceAppManager {
// Install an app from the marketplace catalog
func installApp(bundleID: String, licenseToken: Data) async throws {
// MarketplaceKit handles the installation flow
// Apps must be notarized by Apple before distribution
}
// Check for and deliver app updates
func checkForUpdates() async throws -> [AppUpdate] {
// Query your marketplace server for available updates
return []
}
}
struct AppUpdate {
let bundleID: String
let version: String
let downloadURL: URL
}
---
# Swift Charts
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-swift-charts.html
← Back to all frameworks & guides
All articles →Core UI and Apps · Reference guideSwift ChartsRepository guidance for Swift Charts. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
1. Two decisions that determine whether the chart is any good
2. The pattern
3. Colors come from tokens, and encode meaning
4. Accessibility
5. Large datasets
Anti-Patterns
Checklist
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Choose the comparison
↓2
Map data to marks
↓3
Configure scales and labels
↓4
Verify accessible interpretation
02 / ArchitectureResponsibility boundariesBoundary 1
Source dataBoundary 2
Encodings and scalesBoundary 3
Chart presentationConnected responsibilities, not a required class hierarchy or an execution trace.
Load this when:
rendering any chart — line, bar, area, scatter, sector — or
when a chart scrolls badly, is unreadable to VoiceOver, or looks wrong in dark
mode.
Covers
Chart
, the mark types, scales and axes, selection, accessibility via
AXChartDescriptor
, and what to do when the dataset is too large to plot
honestly.
Availability:
Swift Charts iOS 16+;
chartScrollableAxes
,
chartXSelection
, and
chartScrollPosition
iOS 17+
;
SectorMark
(donut and
pie)
iOS 17+
;
chartGesture
iOS 18+.
1. Two decisions that determine whether the chart is any good
Plot a value type keyed by a stable identity.
Every mark needs an
id
that
survives a reload, or SwiftUI animates the wrong bars into each other on every
update.
Identifiable
on the model,
ForEach
over it — not indices.
Decide what "too much data" means before you hit it.
Swift Charts will
happily try to draw 50,000 marks and drop frames doing it. There is no
virtualisation. The fix is to
downsample before plotting
(§5), not to hope. A
line chart 350 points wide cannot show more than about 350 distinguishable
values regardless of how many you hand it — plotting 50,000 is not more honest,
it is the same picture rendered slowly.
2. The pattern
import Charts
import SwiftUI
struct StepSample: Identifiable, Hashable, Sendable {
let id: UUID
let day: Date
let steps: Int
let source: String
init(id: UUID = UUID(), day: Date, steps: Int, source: String) {
self.id = id
self.day = day
self.steps = steps
self.source = source
}
}
struct StepChart: View {
let samples: [StepSample]
@State private var selectedDay: Date?
private var selected: StepSample? {
guard let selectedDay else { return nil }
return samples.min {
abs($0.day.timeIntervalSince(selectedDay))
< abs($1.day.timeIntervalSince(selectedDay))
}
}
var body: some View {
Chart(samples) { sample in
BarMark(
// .value's first argument is the axis LABEL. It is user-facing
// and read aloud by VoiceOver, so it is localized, not a
// property name.
x: .value(String(localized: "Day"), sample.day, unit: .day),
y: .value(String(localized: "Steps"), sample.steps)
)
.foregroundStyle(by: .value(String(localized: "Source"), sample.source))
.opacity(selected == nil || selected?.id == sample.id ? 1 : 0.4)
}
// Never let a bar chart's y-axis start anywhere but zero: a truncated
// baseline makes a 3% difference look like a 300% one.
.chartYScale(domain: .automatic(includesZero: true))
.chartXAxis {
AxisMarks(values: .stride(by: .day, count: 7)) { value in
AxisGridLine()
AxisValueLabel(format: .dateTime.month(.abbreviated).day())
}
}
.chartYAxis {
AxisMarks(position: .leading) { value in
AxisGridLine()
AxisValueLabel()
}
}
.chartXSelection(value: $selectedDay) // iOS 17+
.chartLegend(position: .bottom, alignment: .leading)
.frame(height: 220) // height only — never width
.accessibilityLabel(String(localized: "Daily step count"))
}
}
.frame(height:) and nothing else.
A chart with a fixed width breaks on
every device it was not designed on, and inside a
ScrollView
it silently
clips. Height is a design decision; width belongs to the layout.
3. Colors come from tokens, and encode meaning
.chartForegroundStyleScale([
String(localized: "Watch"): Color.appAccent,
String(localized: "Phone"): Color.appSecondary
])
Two rules the default palette will not enforce for you:
Never encode meaning by hue alone.
Roughly 8% of men cannot separate the
default red/green pairing. Add a symbol (
.symbol(by:)
), a dash pattern, or a
direct label. A chart whose only distinction is colour is unreadable to a
measurable share of users and to anyone printing it.
Semantic colours must stay semantic across modes.
Color.red
for "over
budget" is fine; a literal
Color(red: 0.9, green: 0.2, blue: 0.2)
is not —
it does not adapt, and on a dark background it vibrates. See
docs/design/design-tokens.md
.
4. Accessibility
A chart is a picture of numbers. VoiceOver users get nothing from the picture,
so the numbers must be reachable directly. There are two levels.
Level 1 — per-mark, and the cheap 80%.
Every mark gets a label and a value:
BarMark(
x: .value(String(localized: "Day"), sample.day, unit: .day),
y: .value(String(localized: "Steps"), sample.steps)
)
.accessibilityLabel(sample.day.formatted(.dateTime.weekday(.wide)))
.accessibilityValue(String(localized: "\(sample.steps) steps"))
Level 2 — AXChartDescriptor, which enables Audio Graphs.
This is what lets
a VoiceOver user
hear
the series as a tone sweep and navigate axes properly.
It is the difference between "a list of numbers" and "a chart".
import Accessibility
import SwiftUI
struct StepChartDescriptor: AXChartDescriptorRepresentable {
let samples: [StepSample]
func makeChartDescriptor() -> AXChartDescriptor {
let steps = samples.map { Double($0.steps) }
let days = samples.map(\.day)
let xAxis = AXNumericDataAxisDescriptor(
title: String(localized: "Day"),
range: 0...Double(max(samples.count - 1, 1)),
gridlinePositions: [],
valueDescriptionProvider: { position in
let index = Int(position.rounded())
guard days.indices.contains(index) else { return "" }
return days[index].formatted(.dateTime.month().day())
}
)
let yAxis = AXNumericDataAxisDescriptor(
title: String(localized: "Steps"),
range: 0...(steps.max() ?? 1),
gridlinePositions: [],
valueDescriptionProvider: { value in
String(localized: "\(Int(value)) steps")
}
)
let series = AXDataSeriesDescriptor(
name: String(localized: "Daily steps"),
isContinuous: false,
dataPoints: samples.enumerated().map { index, sample in
AXDataPoint(x: Double(index), y: Double(sample.steps))
}
)
return AXChartDescriptor(
title: String(localized: "Daily step count"),
summary: String(localized: "Steps recorded each day over the last month."),
xAxis: xAxis,
yAxis: yAxis,
additionalAxes: [],
series: [series]
)
}
// Required, and required to actually update: returning without reassigning
// leaves VoiceOver describing the previous dataset.
func updateChartDescriptor(_ descriptor: AXChartDescriptor) {
descriptor.series = makeChartDescriptor().series
}
}
Attach it, and hide the decorative marks from the accessibility tree so
VoiceOver does not read 400 individual bars
and
the summary:
Chart(samples) { … }
.accessibilityChartDescriptor(StepChartDescriptor(samples: samples))
.accessibilityElement(children: .ignore)
.accessibilityLabel(String(localized: "Daily step count"))
Also honour Dynamic Type: axis labels scale, and at accessibility sizes they
overlap. Reduce
AxisMarks
density as the size category grows rather than
pinning a font size.
5. Large datasets
The order of operations is:
aggregate, then downsample, then plot.
Nothing
else fixes it.
extension Array where Element == StepSample {
/// Largest-Triangle-Three-Buckets preserves visual peaks and troughs that
/// naive stride-sampling drops. Dropping every Nth point deletes the spike
/// the user opened the chart to look at.
func downsampled(to threshold: Int) -> [StepSample] {
guard count > threshold, threshold > 2 else { return self }
let bucketSize = Double(count - 2) / Double(threshold - 2)
var result: [StepSample] = [self[0]]
var previous = 0
for bucket in 0..<(threshold - 2) {
let start = Int(Double(bucket) * bucketSize) + 1
let end = Swift.min(Int(Double(bucket + 1) * bucketSize) + 1, count - 1)
let nextStart = end
let nextEnd = Swift.min(Int(Double(bucket + 2) * bucketSize) + 1, count)
let nextSlice = self[nextStart..<Swift.max(nextEnd, nextStart)]
let avgX = nextSlice.isEmpty ? 0 :
nextSlice.map(\.day.timeIntervalSince1970).reduce(0, +) / Double(nextSlice.count)
let avgY = nextSlice.isEmpty ? 0 :
nextSlice.map { Double($0.steps) }.reduce(0, +) / Double(nextSlice.count)
var bestArea = -1.0
var bestIndex = start
for index in start..<Swift.max(end, start + 1) where indices.contains(index) {
let area = abs(
(self[previous].day.timeIntervalSince1970 - avgX)
* (Double(self[index].steps) - Double(self[previous].steps))
- (self[previous].day.timeIntervalSince1970
- self[index].day.timeIntervalSince1970)
* (avgY - Double(self[previous].steps))
) / 2
if area > bestArea {
bestArea = area
bestIndex = index
}
}
result.append(self[bestIndex])
previous = bestIndex
}
result.append(self[count - 1])
return result
}
}
Do this
off the main actor
, in the model, not in
body
:
@MainActor
@Observable
final class StepChartModel {
private(set) var plotted: [StepSample] = []
private let samples: any StepSampleLoading
init(samples: any StepSampleLoading) { self.samples = samples }
func load(pointBudget: Int = 400) async {
// The load and the downsample both happen off the main actor. `body`
// must never compute this — it runs on every layout pass.
plotted = await samples.recent().downsampled(to: pointBudget)
}
}
Other things that matter at scale:
.drawingGroup()
flattens the chart into a single Metal layer. It helps
with thousands of marks and
hurts
with dozens — measure before adding it.
Scrolling windows
:
.chartScrollableAxes(.horizontal)
plus
.chartXVisibleDomain(length:)
keeps the plotted set bounded while the user
pans (iOS 17+).
RectangleMark beats BarMark
for dense heatmap-style data — fewer
layout passes per mark.
Anti-Patterns
// WRONG — ForEach over indices.
// Identity is positional, so on every reload SwiftUI animates bar 3 into bar 4
// and the chart visibly scrambles.
Chart { ForEach(0..<samples.count, id: \.self) { i in BarMark(…, y: .value("", samples[i].steps)) } }
// RIGHT — stable identity from the model.
Chart(samples) { sample in BarMark(…) }
// WRONG — a bar chart with a truncated y-axis.
// A 3% difference is rendered as a 300% one. This is the single most common way
// a chart lies.
.chartYScale(domain: 9_500...10_000)
// RIGHT — bars are read as area, so the baseline must be zero.
.chartYScale(domain: .automatic(includesZero: true))
// WRONG — a property name as the axis label.
// It is user-facing and read aloud by VoiceOver. "stepCount" is not a word.
x: .value("stepCount", sample.steps)
// RIGHT
x: .value(String(localized: "Steps"), sample.steps)
// WRONG — meaning encoded by hue alone.
// Unreadable to roughly 8% of men, and to anyone who prints it.
.foregroundStyle(sample.isOverBudget ? .red : .green)
// RIGHT — a second channel carries the same information.
.foregroundStyle(by: .value(String(localized: "Status"), sample.status))
.symbol(by: .value(String(localized: "Status"), sample.status))
// WRONG — a fixed width.
// Breaks on every device it was not designed on, and clips inside a ScrollView.
.frame(width: 350, height: 200)
// RIGHT — height is a design decision; width belongs to the layout.
.frame(height: 200)
// WRONG — filtering or downsampling inside body.
// body runs on every layout pass, on the main actor. This is the hitch.
Chart(allSamples.filter { $0.day > cutoff }.sorted { … }) { … }
// RIGHT — the model prepares the data; body renders it.
Chart(model.plotted) { … }
// WRONG — plotting the raw dataset and hoping.
// There is no virtualisation. 50,000 marks drops frames and shows no more
// information than 400 does at that pixel width.
Chart(fiftyThousandSamples) { … }
// RIGHT
Chart(samples.downsampled(to: 400)) { … }
// WRONG — stride-sampling to reduce points.
// Deletes the spike the user opened the chart to look at.
let reduced = samples.enumerated().filter { $0.offset % 100 == 0 }.map(\.element)
// RIGHT — peak-preserving downsampling.
let reduced = samples.downsampled(to: 400)
// WRONG — no accessibility at all.
// A chart is a picture of numbers. Without a descriptor, VoiceOver gets the
// picture and none of the numbers.
Chart(samples) { BarMark(…) }
// RIGHT
Chart(samples) { BarMark(…) }
.accessibilityChartDescriptor(StepChartDescriptor(samples: samples))
// WRONG — a descriptor attached but never updated.
// updateChartDescriptor that does nothing leaves VoiceOver reading the dataset
// from two loads ago, which is worse than no descriptor.
func updateChartDescriptor(_ descriptor: AXChartDescriptor) { }
// RIGHT
func updateChartDescriptor(_ descriptor: AXChartDescriptor) {
descriptor.series = makeChartDescriptor().series
}
// WRONG — a chart with no empty state.
// Zero marks renders as blank axes, which reads as a broken screen.
Chart(samples) { … }
// RIGHT
if samples.isEmpty { ContentUnavailableView(…) } else { Chart(samples) { … } }
// WRONG — .drawingGroup() applied reflexively.
// It costs an offscreen render pass. On a 12-bar chart it is a regression.
Chart(twelveBars) { … }.drawingGroup()
// RIGHT — add it only after measuring, and only at thousands of marks.
// WRONG — SectorMark guarded at iOS 16.
// It does not exist before iOS 17; this does not compile against an iOS 16
// minimum, and guarding it at the wrong version drops working devices.
SectorMark(angle: .value("Share", slice.value))
// RIGHT
if #available(iOS 17, *) { SectorMark(angle: …) } else { BarMark(…) }
Checklist
[ ] Marks keyed by stable
Identifiable
identity, never by index
[ ] Bar and area charts include zero in the y domain
[ ] Axis labels localized — they are read aloud
[ ] Meaning carried by more than hue (symbol, dash, or label)
[ ] Colours from tokens; adapts in dark mode
[ ]
.frame(height:)
only
[ ] No filtering, sorting, or downsampling in
body
[ ] Datasets over ~500 points downsampled peak-preservingly, off the main actor
[ ]
AXChartDescriptor
attached, and
updateChartDescriptor
actually updates
[ ] Decorative marks hidden from the accessibility tree
[ ] Axis density reduces at accessibility text sizes
[ ] An empty state that is not blank axes
[ ] iOS 17+ API (
SectorMark
, selection, scrolling) guarded at
17
, not 26
[ ]
#Preview
for loaded, empty, single-point, and dark mode
---
# SwiftData
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-swiftdata.html
← Back to all frameworks & guides
All articles →Data Management · Reference guideSwiftDataRepository guidance for SwiftData. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
@Model Macro and Schema Definition
ModelContainer and ModelContext
@Query for Fetching
#Predicate Macro for Type-Safe Queries
SortDescriptor
Bool is not Comparable, so you cannot sort by one
The rest
Relationships and Cascade Rules
Migration with VersionedSchema
SwiftData with CloudKit
iOS 18+ Additions
History API
@Index Macro
Custom Data Stores
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Define model schema
↓2
Configure model container
↓3
Query and mutate through context
↓4
Test persistence and migration
02 / ArchitectureResponsibility boundariesBoundary 1
Model containerBoundary 2
Model contextBoundary 3
Observable queriesConnected responsibilities, not a required class hierarchy or an execution trace.
@Model Macro and Schema Definition
import SwiftData
@Model
class Task {
var id: UUID
var title: String
var notes: String
var isCompleted: Bool
var priority: Int
var createdAt: Date
var dueDate: Date?
// Relationship
var category: Category?
var tags: [Tag]
// Transient (not persisted)
@Transient var isSelected = false
// Unique constraint
#Unique<Task>([\.id])
init(title: String, priority: Int = 0) {
self.id = UUID()
self.title = title
self.notes = ""
self.isCompleted = false
self.priority = priority
self.createdAt = Date()
self.tags = []
}
}
@Model
class Category {
var id: UUID
var name: String
var color: String
// Inverse relationship with cascade delete
@Relationship(deleteRule: .cascade, inverse: \Task.category)
var tasks: [Task]
init(name: String, color: String = "blue") {
self.id = UUID()
self.name = name
self.color = color
self.tasks = []
}
}
@Model
class Tag {
var id: UUID
var name: String
@Relationship(inverse: \Task.tags)
var tasks: [Task]
init(name: String) {
self.id = UUID()
self.name = name
self.tasks = []
}
}
ModelContainer and ModelContext
// App setup
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(for: [Task.self, Category.self, Tag.self])
}
}
// Custom configuration
@main
struct MyApp: App {
let container: ModelContainer
init() {
let schema = Schema([Task.self, Category.self, Tag.self])
let config = ModelConfiguration(
"MyApp",
schema: schema,
isStoredInMemoryOnly: false,
allowsSave: true,
groupContainer: .identifier("group.com.myapp.shared")
)
do {
container = try ModelContainer(for: schema, configurations: [config])
} catch {
fatalError("Failed to create ModelContainer: \(error)")
}
}
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(container)
}
}
// Using ModelContext directly
struct TaskListView: View {
@Environment(\.modelContext) private var modelContext
func addTask(title: String) {
let task = Task(title: title)
modelContext.insert(task)
// SwiftData auto-saves; explicit save if needed:
// try? modelContext.save()
}
func deleteTask(_ task: Task) {
modelContext.delete(task)
}
}
@Query for Fetching
struct TaskListView: View {
// Basic query with sort
@Query(sort: \Task.createdAt, order: .reverse)
private var tasks: [Task]
// Filtered and sorted query
@Query(
filter: #Predicate<Task> { !$0.isCompleted },
sort: [
SortDescriptor(\Task.priority, order: .reverse),
SortDescriptor(\Task.createdAt, order: .reverse),
],
animation: .default
)
private var pendingTasks: [Task]
var body: some View {
List(pendingTasks) { task in
TaskRow(task: task)
}
}
}
// Dynamic query with init parameter
struct FilteredTaskList: View {
@Query private var tasks: [Task]
init(showCompleted: Bool, searchText: String) {
let filter = #Predicate<Task> { task in
(showCompleted || !task.isCompleted) &&
(searchText.isEmpty || task.title.localizedStandardContains(searchText))
}
_tasks = Query(
filter: filter,
sort: \Task.createdAt,
order: .reverse
)
}
var body: some View {
List(tasks) { task in
TaskRow(task: task)
}
}
}
#Predicate Macro for Type-Safe Queries
// Simple predicate
let highPriority = #Predicate<Task> { $0.priority >= 2 }
// Compound predicate
let urgentIncomplete = #Predicate<Task> { task in
!task.isCompleted && task.priority >= 2
}
// String search
let searchPredicate = #Predicate<Task> { task in
task.title.localizedStandardContains("meeting")
}
// Date-based predicate
let today = Calendar.current.startOfDay(for: Date())
let dueTodayPredicate = #Predicate<Task> { task in
if let dueDate = task.dueDate {
return dueDate >= today
}
return false
}
// Using predicates with FetchDescriptor
func fetchOverdueTasks(context: ModelContext) throws -> [Task] {
let now = Date()
let descriptor = FetchDescriptor<Task>(
predicate: #Predicate { task in
!task.isCompleted && task.dueDate != nil && task.dueDate! < now
},
sortBy: [SortDescriptor(\.dueDate)]
)
return try context.fetch(descriptor)
}
// Fetch with limit
func fetchRecentTasks(context: ModelContext, limit: Int = 10) throws -> [Task] {
var descriptor = FetchDescriptor<Task>(
sortBy: [SortDescriptor(\.createdAt, order: .reverse)]
)
descriptor.fetchLimit = limit
return try context.fetch(descriptor)
}
// Count
func countIncompleteTasks(context: ModelContext) throws -> Int {
let descriptor = FetchDescriptor<Task>(
predicate: #Predicate { !$0.isCompleted }
)
return try context.fetchCount(descriptor)
}
SortDescriptor
Bool
is not
Comparable
, so you cannot sort by one
This is the first thing to know, because
isPinned
/
isCompleted
/
isFavourite
is the most common sort key there is, and the compiler's
diagnostic sends you somewhere else entirely:
// WRONG — does not compile.
@Query(sort: [SortDescriptor(\Note.isPinned, order: .reverse)])
private var notes: [Note]
// error: initializer 'init(_:order:)' requires that 'Note'
// inherit from 'NSObject'
SortDescriptor.init(_:order:)
needs
Value: Comparable
. Swift's
Bool
is
not
Comparable
, so overload resolution falls through to the
NSObject
key-path overload and reports
that
failure — pointing at a Core Data problem
you do not have. Nothing in the message mentions
Bool
.
Two fixes, and the second is usually the right one:
// 1. Sort in Swift after fetching. Fine for a screen's worth of rows.
@Query(sort: \Note.updatedAt, order: .reverse) private var notes: [Note]
var ordered: [Note] {
notes.sorted { ($0.isPinned ? 0 : 1) < ($1.isPinned ? 0 : 1) }
}
// 2. Store an Int rank alongside the Bool. This sorts in the store, so it
// still works with pagination and with a fetch limit — which sorting in
// Swift does not, because the limit is applied before your sort runs.
@Model
final class Note {
var isPinned: Bool = false
/// Kept in sync with `isPinned`; 0 sorts before 1.
private(set) var pinRank: Int = 1
func setPinned(_ pinned: Bool) {
isPinned = pinned
pinRank = pinned ? 0 : 1
}
}
@Query(sort: [SortDescriptor(\Note.pinRank), SortDescriptor(\Note.updatedAt, order: .reverse)])
private var notes: [Note]
The same applies to any non-
Comparable
property: an enum without a
Comparable
conformance, or a custom struct.
The rest
// Single sort
@Query(sort: \Task.title) private var tasks: [Task]
// Multiple sorts — note the Int rank, not the Bool, for the first key.
@Query(sort: [
SortDescriptor(\Task.completionRank),
SortDescriptor(\Task.priority, order: .reverse),
SortDescriptor(\Task.createdAt, order: .reverse),
])
private var tasks: [Task]
// Dynamic sorting
struct SortableTaskList: View {
@State private var sortOrder = [SortDescriptor(\Task.createdAt, order: .reverse)]
var body: some View {
TaskListContent(sort: sortOrder)
.toolbar {
Menu("Sort") {
Button("By Date") {
sortOrder = [SortDescriptor(\Task.createdAt, order: .reverse)]
}
Button("By Priority") {
sortOrder = [SortDescriptor(\Task.priority, order: .reverse)]
}
Button("By Title") {
sortOrder = [SortDescriptor(\Task.title)]
}
}
}
}
}
struct TaskListContent: View {
@Query private var tasks: [Task]
init(sort: [SortDescriptor<Task>]) {
_tasks = Query(sort: sort)
}
var body: some View {
List(tasks) { task in
TaskRow(task: task)
}
}
}
Relationships and Cascade Rules
@Model
class Project {
var name: String
// Delete rule options: .cascade, .nullify, .deny, .noAction
@Relationship(deleteRule: .cascade, inverse: \Milestone.project)
var milestones: [Milestone]
@Relationship(deleteRule: .nullify, inverse: \TeamMember.projects)
var members: [TeamMember]
init(name: String) {
self.name = name
self.milestones = []
self.members = []
}
}
@Model
class Milestone {
var title: String
var project: Project?
init(title: String) {
self.title = title
}
}
@Model
class TeamMember {
var name: String
var projects: [Project] // Many-to-many
init(name: String) {
self.name = name
self.projects = []
}
}
// Working with relationships
func addMilestone(to project: Project, title: String, context: ModelContext) {
let milestone = Milestone(title: title)
milestone.project = project // Automatically updates project.milestones
}
Migration with VersionedSchema
// Version 1
enum SchemaV1: VersionedSchema {
static var versionIdentifier = Schema.Version(1, 0, 0)
static var models: [any PersistentModel.Type] { [TaskV1.self] }
@Model
class TaskV1 {
var id: UUID
var title: String
var isCompleted: Bool
init(title: String) {
self.id = UUID()
self.title = title
self.isCompleted = false
}
}
}
// Version 2 — adds priority field
enum SchemaV2: VersionedSchema {
static var versionIdentifier = Schema.Version(2, 0, 0)
static var models: [any PersistentModel.Type] { [TaskV2.self] }
@Model
class TaskV2 {
var id: UUID
var title: String
var isCompleted: Bool
var priority: Int // New field
init(title: String, priority: Int = 0) {
self.id = UUID()
self.title = title
self.isCompleted = false
self.priority = priority
}
}
}
// Migration plan
enum TaskMigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] { [SchemaV1.self, SchemaV2.self] }
static var stages: [MigrationStage] {
[migrateV1toV2]
}
static let migrateV1toV2 = MigrationStage.lightweight(
fromVersion: SchemaV1.self,
toVersion: SchemaV2.self
)
}
// Apply migration
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(for: SchemaV2.TaskV2.self, migrationPlan: TaskMigrationPlan.self)
}
}
SwiftData with CloudKit
// CloudKit-enabled container
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(for: [Task.self, Category.self]) {
// Container is configured — CloudKit syncs automatically
// when the app's CloudKit container is set in entitlements
}
}
}
// Requirements for CloudKit compatibility:
// 1. All properties must have default values or be optional
// 2. No unique constraints (CloudKit doesn't support them)
// 3. All relationships must be optional
// 4. Enable "CloudKit" capability in Xcode
// 5. Set the CloudKit container identifier in entitlements
@Model
class CloudTask {
var id: UUID = UUID()
var title: String = ""
var isCompleted: Bool = false
var priority: Int = 0
var createdAt: Date = Date()
// Optional relationship for CloudKit
var category: CloudCategory?
init(title: String) {
self.title = title
}
}
iOS 18+ Additions
History API
The SwiftData History API enables tracking time-based model changes (inserts, updates, deletes) for server sync, auditing, or undo support. It uses
HistoryDescriptor
and the
ModelContext
history-fetching methods to retrieve changes since a given point in time.
import SwiftData
// Fetching model history for sync
func fetchChangesSinceLastSync(context: ModelContext, lastToken: DefaultHistoryToken?) async throws -> DefaultHistoryToken? {
// Build a descriptor starting from the last known sync point
var descriptor = HistoryDescriptor<DefaultHistoryTransaction>()
if let lastToken {
descriptor.predicate = #Predicate { transaction in
transaction.token > lastToken
}
}
// Fetch transactions from history
let transactions = try context.fetchHistory(descriptor)
for transaction in transactions {
// Each transaction contains changes grouped atomically
for change in transaction.changes {
switch change {
case let change as DefaultHistoryInsert<Task>:
let modelID = change.persistentIdentifier
print("Inserted Task: \(modelID)")
// Sync insert to server
case let change as DefaultHistoryUpdate<Task>:
let modelID = change.persistentIdentifier
let updatedProperties = change.updatedProperties
print("Updated Task: \(modelID), fields: \(updatedProperties)")
// Sync update to server
case let change as DefaultHistoryDelete<Task>:
let modelID = change.persistentIdentifier
print("Deleted Task: \(modelID)")
// Sync delete to server
default:
break
}
}
}
// Return the latest token to persist for next sync
return transactions.last?.token
}
// Persisting the sync token
class SyncManager {
private let tokenKey = "lastHistorySyncToken"
func saveToken(_ token: DefaultHistoryToken) {
let data = try? JSONEncoder().encode(token)
UserDefaults.standard.set(data, forKey: tokenKey)
}
func loadToken() -> DefaultHistoryToken? {
guard let data = UserDefaults.standard.data(forKey: tokenKey) else { return nil }
return try? JSONDecoder().decode(DefaultHistoryToken.self, from: data)
}
func performSync(context: ModelContext) async throws {
let lastToken = loadToken()
if let newToken = try await fetchChangesSinceLastSync(context: context, lastToken: lastToken) {
saveToken(newToken)
}
}
}
@Index Macro
The
#Index
macro defines database indexes on model properties, improving query performance for frequently searched or sorted fields.
import SwiftData
@Model
class Task {
var id: UUID
var title: String
var isCompleted: Bool
var priority: Int
var createdAt: Date
var dueDate: Date?
var category: String
// Single-property index for fast lookups by title
#Index<Task>([\.title])
// Compound index for queries that filter by completion status and sort by priority
#Index<Task>([\.isCompleted, \.priority])
// Index on createdAt for time-based sorting queries
#Index<Task>([\.createdAt])
// Compound index for category-based filtered and sorted queries
#Index<Task>([\.category, \.dueDate])
init(title: String, priority: Int = 0, category: String = "general") {
self.id = UUID()
self.title = title
self.isCompleted = false
self.priority = priority
self.createdAt = Date()
self.category = category
}
}
// The indexes above optimize queries like:
// @Query(filter: #Predicate<Task> { !$0.isCompleted }, sort: \.priority)
// @Query(filter: #Predicate<Task> { $0.category == "work" }, sort: \.dueDate)
Custom Data Stores
The
DataStore
protocol allows SwiftData to use custom storage backends beyond the default SQLite/CoreData store. You can back SwiftData models with JSON files, remote APIs, or any custom persistence layer.
import SwiftData
// A custom data store backed by JSON files
actor JSONDataStore: DataStore {
typealias Snapshot = DefaultSnapshot
let configuration: DataStoreConfiguration
let fileURL: URL
init(_ configuration: DataStoreConfiguration, fileURL: URL) {
self.configuration = configuration
self.fileURL = fileURL
}
// Fetch models from the custom store
func fetch<T: PersistentModel>(_ descriptor: FetchDescriptor<T>) throws -> [T] {
// Read from your custom backend (JSON file, API, etc.)
let data = try Data(contentsOf: fileURL)
let snapshots = try JSONDecoder().decode([DefaultSnapshot].self, from: data)
// Convert snapshots back to models
// Implementation depends on your storage format
return []
}
// Save changes to the custom store
func save(_ insert: [DefaultSnapshot], _ update: [DefaultSnapshot], _ delete: [PersistentIdentifier]) throws {
// Persist inserts, updates, and deletes to your custom backend
var existing = loadExistingSnapshots()
// Apply inserts
existing.append(contentsOf: insert)
// Apply updates
for updated in update {
if let index = existing.firstIndex(where: { $0.persistentIdentifier == updated.persistentIdentifier }) {
existing[index] = updated
}
}
// Apply deletes
existing.removeAll { snapshot in
delete.contains(snapshot.persistentIdentifier)
}
// Write back to storage
let data = try JSONEncoder().encode(existing)
try data.write(to: fileURL)
}
private func loadExistingSnapshots() -> [DefaultSnapshot] {
guard let data = try? Data(contentsOf: fileURL) else { return [] }
return (try? JSONDecoder().decode([DefaultSnapshot].self, from: data)) ?? []
}
}
// Custom configuration for the data store
struct JSONStoreConfiguration: DataStoreConfiguration {
var name: String
var schema: Schema?
var fileURL: URL
init(name: String, schema: Schema? = nil, fileURL: URL) {
self.name = name
self.schema = schema
self.fileURL = fileURL
}
}
// Using the custom data store with ModelContainer
@main
struct MyApp: App {
let container: ModelContainer
init() {
let schema = Schema([Task.self, Category.self])
let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
let storeURL = documentsURL.appendingPathComponent("data.json")
let config = JSONStoreConfiguration(
name: "JSONStore",
schema: schema,
fileURL: storeURL
)
do {
container = try ModelContainer(for: schema, configurations: [config])
} catch {
fatalError("Failed to create ModelContainer with custom store: \(error)")
}
}
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(container)
}
}
---
# TipKit
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-tipkit.html
← Back to all frameworks & guides
All articles →Core UI and Apps · Reference guideTipKitRepository guidance for TipKit. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
Tip Protocol (Title, Message, Image, Actions)
TipView and popoverTip Modifier
Tip Rules (Parameter-Based and Event-Based)
Tip.Event and Donation
MaxDisplayCount and Display Frequency
Tips.configure() Setup
Invalidation and Status
Updating Parameters at Runtime
Complete Onboarding Tips Example
Key Considerations
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Define a contextual tip
↓2
Configure display rules
↓3
Show eligible guidance
↓4
Invalidate after completion
02 / ArchitectureResponsibility boundariesBoundary 1
Tip definitionsBoundary 2
Eligibility rulesBoundary 3
Tip presentationConnected responsibilities, not a required class hierarchy or an execution trace.
TipKit is Apple's framework for creating contextual, rule-based tips that educate users about features in your app. Tips appear as inline views or popovers and are managed by a centralized system that handles display frequency, eligibility rules, and synchronization across devices via iCloud.
Tip Protocol (Title, Message, Image, Actions)
Define tips by conforming to the
Tip
protocol. Each tip has a title, optional message, image, and action buttons.
import TipKit
struct FavoritesTip: Tip {
// Required: the tip title
var title: Text {
Text("Save Your Favorites")
}
// Optional: detailed message
var message: Text? {
Text("Tap the heart icon to save articles for later reading.")
}
// Optional: leading image
var image: Image? {
Image(systemName: "heart.fill")
}
// Optional: action buttons
var actions: [Action] {
Action(id: "learn-more", title: "Learn More")
Action(id: "dismiss", title: "Got It")
}
}
struct ShareTip: Tip {
var title: Text {
Text("Share with Friends")
}
var message: Text? {
Text("Use the share button to send articles to friends and family.")
}
var image: Image? {
Image(systemName: "square.and.arrow.up")
}
}
struct FilterTip: Tip {
var title: Text {
Text("Filter Your Feed")
}
var message: Text? {
Text("Swipe down to reveal filter options and find exactly what you need.")
}
var image: Image? {
Image(systemName: "line.3.horizontal.decrease.circle")
}
}
TipView and popoverTip Modifier
Display tips as inline views or popovers attached to any SwiftUI element.
import SwiftUI
import TipKit
struct ArticleListView: View {
let favoritesTip = FavoritesTip()
let shareTip = ShareTip()
let filterTip = FilterTip()
let articles: [Article]
var body: some View {
NavigationStack {
List {
// Inline tip — appears as a row in the list
TipView(favoritesTip) { action in
if action.id == "learn-more" {
// Handle action
} else if action.id == "dismiss" {
favoritesTip.invalidate(reason: .actionPerformed)
}
}
ForEach(articles) { article in
ArticleRow(article: article)
}
}
.navigationTitle("Articles")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
// Share action
} label: {
Image(systemName: "square.and.arrow.up")
}
// Popover tip — anchored to the share button
.popoverTip(shareTip, arrowEdge: .top)
}
ToolbarItem(placement: .topBarLeading) {
Button {
// Filter action
} label: {
Image(systemName: "line.3.horizontal.decrease.circle")
}
.popoverTip(filterTip)
}
}
}
}
}
// Customizing tip appearance
struct StyledTipView: View {
let tip = FavoritesTip()
var body: some View {
VStack {
// Style the inline TipView
TipView(tip)
.tipBackground(Color.blue.opacity(0.1))
// Popover tip on custom view
Image(systemName: "heart")
.font(.largeTitle)
.popoverTip(tip, arrowEdge: .bottom) { action in
if action.id == "dismiss" {
tip.invalidate(reason: .actionPerformed)
}
}
}
}
}
Tip Rules (Parameter-Based and Event-Based)
Rules determine when a tip is eligible for display. Combine parameter rules and event rules for precise targeting.
import TipKit
struct ProFeatureTip: Tip {
// Parameter-based rule — tip eligible when condition is met
@Parameter
static var isLoggedIn: Bool = false
@Parameter
static var hasUsedBasicFeatures: Bool = false
var title: Text {
Text("Unlock Pro Features")
}
var message: Text? {
Text("Upgrade to access advanced analytics and priority support.")
}
var image: Image? {
Image(systemName: "star.circle.fill")
}
// Rules that must ALL be true for the tip to display
var rules: [Rule] {
// Show only when user is logged in
#Rule(Self.$isLoggedIn) { $0 == true }
// Show only after user has explored basic features
#Rule(Self.$hasUsedBasicFeatures) { $0 == true }
}
}
// Event-based rules — tip appears after specific events occur
struct AdvancedSearchTip: Tip {
// Define an event that can be donated multiple times
static let searchPerformed = Event(id: "searchPerformed")
var title: Text {
Text("Try Advanced Search")
}
var message: Text? {
Text("Use filters and operators to find exactly what you need.")
}
var image: Image? {
Image(systemName: "magnifyingglass")
}
var rules: [Rule] {
// Show after the user has searched at least 3 times
#Rule(Self.searchPerformed) { event in
event.donations.count >= 3
}
}
}
// Combined rules
struct WeeklyReportTip: Tip {
static let appLaunched = Event(id: "appLaunched")
@Parameter
static var hasActiveSubscription: Bool = false
var title: Text {
Text("Check Your Weekly Report")
}
var message: Text? {
Text("Your personalized weekly insights are ready to view.")
}
var rules: [Rule] {
// Must have an active subscription
#Rule(Self.$hasActiveSubscription) { $0 == true }
// Must have launched the app at least 5 times
#Rule(Self.appLaunched) { event in
event.donations.count >= 5
}
}
}
Tip.Event and Donation
Donate events to track user actions. The system evaluates event-based rules against accumulated donations.
import TipKit
import SwiftUI
struct SearchView: View {
@State private var searchText = ""
let advancedSearchTip = AdvancedSearchTip()
var body: some View {
VStack {
HStack {
TextField("Search...", text: $searchText)
.textFieldStyle(.roundedBorder)
Button("Search") {
performSearch()
}
.popoverTip(advancedSearchTip)
}
.padding()
}
}
private func performSearch() {
// Donate the event each time the user searches
Task {
await AdvancedSearchTip.searchPerformed.donate()
}
// Perform the actual search...
}
}
// Donate events with associated values for richer rules
struct PurchaseTip: Tip {
static let itemViewed = Event(id: "itemViewed")
var title: Text {
Text("Ready to Buy?")
}
var message: Text? {
Text("Items in your recently viewed list are available at a discount.")
}
var rules: [Rule] {
// Show after viewing 5+ items within the last 3 days
#Rule(Self.itemViewed) { event in
event.donations.filter {
$0.date > Date.now.addingTimeInterval(-3 * 24 * 60 * 60)
}.count >= 5
}
}
}
// Donating the event
func userViewedItem(_ item: Item) {
Task {
await PurchaseTip.itemViewed.donate()
}
}
MaxDisplayCount and Display Frequency
Control how often tips appear to prevent user fatigue.
import TipKit
struct DailyTip: Tip {
var title: Text {
Text("Daily Insight")
}
var message: Text? {
Text("Check your daily statistics in the dashboard.")
}
// Limit how many times this specific tip is shown
var options: [TipOption] {
// Show this tip a maximum of 3 times
MaxDisplayCount(3)
}
}
struct OneTimeTip: Tip {
var title: Text {
Text("Welcome!")
}
var message: Text? {
Text("Swipe through to explore all features.")
}
var options: [TipOption] {
// Show only once
MaxDisplayCount(1)
}
}
Tips.configure() Setup
Configure TipKit when your app launches. This sets global display frequency and data store options.
import SwiftUI
import TipKit
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.task {
configureTips()
}
}
}
private func configureTips() {
do {
try Tips.configure([
// How often any tip can appear across the app
.displayFrequency(.daily),
// Use .immediate for testing (shows tips right away)
// .displayFrequency(.immediate),
// Data store location — use .applicationDefault for production
.datastoreLocation(.applicationDefault)
])
} catch {
print("Failed to configure TipKit: \(error)")
}
}
}
// Display frequency options:
// .immediate — no delay between tips (best for testing)
// .hourly — at most one tip per hour
// .daily — at most one tip per day
// .weekly — at most one tip per week
// .monthly — at most one tip per month
Invalidation and Status
Invalidate tips when users complete the associated action, or check tip status programmatically.
import TipKit
import SwiftUI
struct FeatureView: View {
let featureTip = FavoritesTip()
var body: some View {
VStack {
TipView(featureTip)
Button("Add to Favorites") {
addToFavorites()
// Invalidate the tip — it won't appear again
featureTip.invalidate(reason: .actionPerformed)
}
Button("Dismiss Tip") {
// Tip can potentially reappear in the future
featureTip.invalidate(reason: .tipClosed)
}
}
}
private func addToFavorites() {
// Perform the action...
}
}
// Check tip status programmatically
struct ConditionalTipView: View {
let tip = ShareTip()
var body: some View {
VStack {
switch tip.status {
case .available:
TipView(tip)
case .invalidated(let reason):
switch reason {
case .actionPerformed:
Text("You've already used this feature!")
.foregroundStyle(.green)
case .tipClosed:
EmptyView()
case .maxDisplayCountExceeded:
EmptyView()
default:
EmptyView()
}
case .pending:
// Rules not yet met
EmptyView()
}
}
}
}
// Reset all tips (useful for testing or settings screen)
func resetAllTips() {
try? Tips.resetDatastore()
}
// Show all tips immediately (testing)
func showAllTipsForTesting() {
try? Tips.configure([
.displayFrequency(.immediate),
.datastoreLocation(.applicationDefault)
])
}
Updating Parameters at Runtime
Set parameters dynamically as the user interacts with your app.
import TipKit
import SwiftUI
struct LoginView: View {
var body: some View {
Button("Log In") {
performLogin()
}
}
private func performLogin() {
// After successful login, update the parameter
ProFeatureTip.isLoggedIn = true
}
}
struct OnboardingCompletionView: View {
var body: some View {
Button("Complete Setup") {
completeOnboarding()
}
}
private func completeOnboarding() {
ProFeatureTip.hasUsedBasicFeatures = true
}
}
struct AppLaunchHandler {
static func trackLaunch() {
Task {
await WeeklyReportTip.appLaunched.donate()
}
}
}
Complete Onboarding Tips Example
A full onboarding flow using TipKit with sequential tips that guide users through key features.
import TipKit
import SwiftUI
// MARK: - Onboarding Tips Definition
struct WelcomeTip: Tip {
var title: Text { Text("Welcome to ReadIt") }
var message: Text? { Text("Discover a curated feed of articles tailored to your interests.") }
var image: Image? { Image(systemName: "hand.wave.fill") }
var options: [TipOption] { MaxDisplayCount(1) }
}
struct SwipeActionTip: Tip {
static let articleViewed = Event(id: "articleViewed")
var title: Text { Text("Swipe for Quick Actions") }
var message: Text? { Text("Swipe left on any article to save, share, or archive it.") }
var image: Image? { Image(systemName: "hand.draw.fill") }
var options: [TipOption] { MaxDisplayCount(2) }
var rules: [Rule] {
#Rule(Self.articleViewed) { $0.donations.count >= 2 }
}
}
struct PersonalizeTip: Tip {
static let savedArticle = Event(id: "savedArticle")
@Parameter
static var hasCompletedOnboarding: Bool = false
var title: Text { Text("Personalize Your Feed") }
var message: Text? { Text("Go to Settings to select your favorite topics and sources.") }
var image: Image? { Image(systemName: "slider.horizontal.3") }
var options: [TipOption] { MaxDisplayCount(1) }
var actions: [Action] {
Action(id: "go-to-settings", title: "Open Settings")
}
var rules: [Rule] {
#Rule(Self.$hasCompletedOnboarding) { $0 == true }
#Rule(Self.savedArticle) { $0.donations.count >= 1 }
}
}
// MARK: - Main View with Onboarding Tips
struct OnboardingArticleListView: View {
let welcomeTip = WelcomeTip()
let swipeActionTip = SwipeActionTip()
let personalizeTip = PersonalizeTip()
@State private var articles: [Article] = Article.sampleData
@State private var showSettings = false
var body: some View {
NavigationStack {
List {
// Welcome tip at the top
TipView(welcomeTip)
// Personalize tip with action handler
TipView(personalizeTip) { action in
if action.id == "go-to-settings" {
showSettings = true
personalizeTip.invalidate(reason: .actionPerformed)
}
}
ForEach(articles) { article in
ArticleRowView(article: article)
.onTapGesture {
Task {
await SwipeActionTip.articleViewed.donate()
}
}
.swipeActions(edge: .trailing) {
Button {
saveArticle(article)
} label: {
Label("Save", systemImage: "bookmark")
}
.tint(.blue)
Button {
// Share
} label: {
Label("Share", systemImage: "square.and.arrow.up")
}
.tint(.green)
}
}
}
.navigationTitle("ReadIt")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
showSettings = true
} label: {
Image(systemName: "gearshape")
}
.popoverTip(swipeActionTip)
}
}
.sheet(isPresented: $showSettings) {
SettingsView()
}
}
}
private func saveArticle(_ article: Article) {
Task {
await PersonalizeTip.savedArticle.donate()
}
}
}
// MARK: - Supporting Views
struct ArticleRowView: View {
let article: Article
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text(article.title)
.font(.headline)
Text(article.summary)
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(2)
HStack {
Text(article.source)
.font(.caption)
.foregroundStyle(.blue)
Spacer()
Text(article.date, style: .relative)
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
.padding(.vertical, 4)
}
}
struct SettingsView: View {
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
List {
Section("Topics") {
ForEach(["Technology", "Science", "Design", "Business"], id: \.self) { topic in
Toggle(topic, isOn: .constant(false))
}
}
Section("Debug") {
Button("Reset All Tips") {
try? Tips.resetDatastore()
}
}
}
.navigationTitle("Settings")
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") {
PersonalizeTip.hasCompletedOnboarding = true
dismiss()
}
}
}
}
}
}
// MARK: - Data Model
struct Article: Identifiable {
let id = UUID()
let title: String
let summary: String
let source: String
let date: Date
static var sampleData: [Article] {
[
Article(title: "SwiftUI 6 Announced", summary: "Apple reveals major updates to SwiftUI at WWDC.", source: "Apple Newsroom", date: .now.addingTimeInterval(-3600)),
Article(title: "The Future of AI", summary: "How artificial intelligence is reshaping every industry.", source: "Tech Review", date: .now.addingTimeInterval(-7200)),
Article(title: "Design Systems at Scale", summary: "Building consistent design systems for large organizations.", source: "Design Weekly", date: .now.addingTimeInterval(-10800))
]
}
}
// MARK: - App Entry Point
@main
struct ReadItApp: App {
var body: some Scene {
WindowGroup {
OnboardingArticleListView()
.task {
try? Tips.configure([
.displayFrequency(.immediate),
.datastoreLocation(.applicationDefault)
])
}
}
}
}
Key Considerations
Availability
: TipKit requires iOS 17+, macOS 14+, watchOS 10+, tvOS 17+.
iCloud sync
: Tip state syncs across devices via iCloud by default. A tip dismissed on iPhone won't reappear on iPad.
Display frequency
: Set at the global level via
Tips.configure()
. Individual tips respect
MaxDisplayCount
independently.
Testing
: Use
Tips.resetDatastore()
to clear all tip state. Use
.displayFrequency(.immediate)
during development.
Invalidation reasons
:
.actionPerformed
means the user completed the action (permanent).
.tipClosed
means the user dismissed the tip (may reappear if display count allows).
Performance
: Tips are lightweight. The system evaluates rules lazily and only renders tips when eligible.
Accessibility
: TipKit views automatically support VoiceOver and Dynamic Type.
---
# UserNotifications
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-usernotifications.html
← Back to all frameworks & guides
All articles →System Integration · Reference guideUserNotificationsRepository guidance for UserNotifications. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
UNUserNotificationCenter and Requesting Permission
Local Notifications (UNNotificationRequest, Content, Triggers)
Time, Calendar, and Location Triggers
Time Interval Trigger
Calendar Trigger
Location Trigger
Notification Actions and Categories
Handling Notification Actions via Delegate
Remote Notifications (APNs Registration and Handling)
Notification Service Extension for Rich Notifications
Provisional and Critical Alerts
Managing Pending and Delivered Notifications
Complete Example: Notification Setup in App
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Explain notification purpose
↓2
Request authorization
↓3
Schedule or register delivery
↓4
Handle notification actions
02 / ArchitectureResponsibility boundariesBoundary 1
App schedulingBoundary 2
System deliveryBoundary 3
Action handlingConnected responsibilities, not a required class hierarchy or an execution trace.
The UserNotifications framework manages local and remote notifications in iOS. It provides a unified API for scheduling, delivering, and handling notifications with rich content, actions, and various trigger types.
UNUserNotificationCenter and Requesting Permission
import UserNotifications
class NotificationManager: ObservableObject {
static let shared = NotificationManager()
@Published var isAuthorized = false
let center = UNUserNotificationCenter.current()
func requestPermission() async -> Bool {
do {
let granted = try await center.requestAuthorization(
options: [.alert, .sound, .badge, .providesAppNotificationSettings]
)
await MainActor.run { isAuthorized = granted }
return granted
} catch {
print("Permission request failed: \(error)")
return false
}
}
func checkCurrentSettings() async {
let settings = await center.notificationSettings()
switch settings.authorizationStatus {
case .authorized:
print("Fully authorized")
case .denied:
print("Denied - direct user to Settings")
case .provisional:
print("Provisional (quiet delivery)")
case .ephemeral:
print("Ephemeral (App Clips)")
case .notDetermined:
print("Not yet requested")
@unknown default:
break
}
// Check individual setting states
print("Alert: \(settings.alertSetting)")
print("Badge: \(settings.badgeSetting)")
print("Sound: \(settings.soundSetting)")
print("Lock screen: \(settings.lockScreenSetting)")
print("Notification center: \(settings.notificationCenterSetting)")
}
}
Local Notifications (UNNotificationRequest, Content, Triggers)
extension NotificationManager {
func scheduleLocalNotification() async throws {
let content = UNMutableNotificationContent()
content.title = "Reminder"
content.subtitle = "Daily Check-in"
content.body = "Time to log your progress for today."
content.sound = .default
content.badge = 1
content.userInfo = ["screen": "journal", "entryID": "abc123"]
// Thread identifier groups related notifications together
content.threadIdentifier = "daily-reminders"
// Relevance score (0.0 to 1.0) affects position in notification summary
content.relevanceScore = 0.8
// Interruption level (iOS 15+)
content.interruptionLevel = .timeSensitive
// .passive - silently added to notification center
// .active - default: sound + banner (respects Focus)
// .timeSensitive - breaks through most Focus filters
// .critical - plays sound even on mute (requires entitlement)
// Attach an image
if let imageURL = Bundle.main.url(forResource: "notification", withExtension: "png") {
let attachment = try UNNotificationAttachment(
identifier: "image",
url: imageURL,
options: [UNNotificationAttachmentOptionsTypeHintKey: "public.png"]
)
content.attachments = [attachment]
}
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 5, repeats: false)
let request = UNNotificationRequest(
identifier: UUID().uuidString,
content: content,
trigger: trigger
)
try await center.add(request)
}
}
Time, Calendar, and Location Triggers
Time Interval Trigger
extension NotificationManager {
// Fire after a delay. For repeating, interval must be >= 60 seconds.
func scheduleAfterDelay(seconds: TimeInterval, repeating: Bool = false) -> UNTimeIntervalNotificationTrigger {
return UNTimeIntervalNotificationTrigger(timeInterval: seconds, repeats: repeating)
}
}
Calendar Trigger
extension NotificationManager {
// Fire at a specific date and time, optionally repeating
func scheduleDailyReminder(hour: Int, minute: Int) async throws {
let content = UNMutableNotificationContent()
content.title = "Daily Reminder"
content.body = "Don't forget to check in!"
content.sound = .default
var dateComponents = DateComponents()
dateComponents.hour = hour
dateComponents.minute = minute
// Omitting day/month/year makes it repeat daily at this time
let trigger = UNCalendarNotificationTrigger(dateMatching: dateComponents, repeats: true)
let request = UNNotificationRequest(
identifier: "daily-reminder",
content: content,
trigger: trigger
)
try await center.add(request)
}
// Weekly trigger: every Monday at 9:00 AM
func scheduleWeeklyReminder() async throws {
let content = UNMutableNotificationContent()
content.title = "Weekly Review"
content.body = "Time for your weekly review."
content.sound = .default
var dateComponents = DateComponents()
dateComponents.weekday = 2 // 1 = Sunday, 2 = Monday, ...
dateComponents.hour = 9
dateComponents.minute = 0
let trigger = UNCalendarNotificationTrigger(dateMatching: dateComponents, repeats: true)
let request = UNNotificationRequest(
identifier: "weekly-review",
content: content,
trigger: trigger
)
try await center.add(request)
}
// One-time notification at a specific date
func scheduleOnDate(_ date: Date) async throws {
let content = UNMutableNotificationContent()
content.title = "Scheduled Event"
content.body = "Your event is starting now."
content.sound = .default
let components = Calendar.current.dateComponents(
[.year, .month, .day, .hour, .minute],
from: date
)
let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: false)
let request = UNNotificationRequest(
identifier: "event-\(date.timeIntervalSince1970)",
content: content,
trigger: trigger
)
try await center.add(request)
}
}
Location Trigger
import CoreLocation
extension NotificationManager {
func scheduleLocationNotification() async throws {
let content = UNMutableNotificationContent()
content.title = "Welcome!"
content.body = "You've arrived at the office."
content.sound = .default
let coordinate = CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194)
let region = CLCircularRegion(center: coordinate, radius: 100, identifier: "office")
region.notifyOnEntry = true
region.notifyOnExit = false
let trigger = UNLocationNotificationTrigger(region: region, repeats: true)
let request = UNNotificationRequest(
identifier: "office-arrival",
content: content,
trigger: trigger
)
try await center.add(request)
}
}
Notification Actions and Categories
extension NotificationManager {
func registerCategories() {
// Define actions
let replyAction = UNTextInputNotificationAction(
identifier: "REPLY",
title: "Reply",
options: [],
textInputButtonTitle: "Send",
textInputPlaceholder: "Type your reply..."
)
let markReadAction = UNNotificationAction(
identifier: "MARK_READ",
title: "Mark as Read",
options: .destructive
)
let viewAction = UNNotificationAction(
identifier: "VIEW",
title: "View",
options: .foreground // Opens the app when tapped
)
// Define category grouping related actions
let messageCategory = UNNotificationCategory(
identifier: "MESSAGE",
actions: [replyAction, markReadAction, viewAction],
intentIdentifiers: [],
hiddenPreviewsBodyPlaceholder: "New message",
categorySummaryFormat: "%u more messages",
options: [.customDismissAction] // Notifies delegate when user dismisses
)
let reminderCategory = UNNotificationCategory(
identifier: "REMINDER",
actions: [
UNNotificationAction(identifier: "SNOOZE", title: "Snooze 15 min", options: []),
UNNotificationAction(identifier: "COMPLETE", title: "Mark Complete", options: .destructive)
],
intentIdentifiers: [],
options: []
)
center.setNotificationCategories([messageCategory, reminderCategory])
}
// Send a notification with a category
func sendMessageNotification(from sender: String, message: String) async throws {
let content = UNMutableNotificationContent()
content.title = sender
content.body = message
content.categoryIdentifier = "MESSAGE"
content.sound = .default
content.threadIdentifier = "chat-\(sender)"
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false)
let request = UNNotificationRequest(
identifier: UUID().uuidString,
content: content,
trigger: trigger
)
try await center.add(request)
}
}
Handling Notification Actions via Delegate
class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
// Called when user taps a notification or performs an action
func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void
) {
let actionIdentifier = response.actionIdentifier
let userInfo = response.notification.request.content.userInfo
switch actionIdentifier {
case "REPLY":
if let textResponse = response as? UNTextInputNotificationResponse {
print("User replied: \(textResponse.userText)")
// Send the reply to your server
}
case "MARK_READ":
print("Marked as read")
case "VIEW":
print("Opening detail view")
// Navigate to the relevant screen using userInfo
case "SNOOZE":
// Reschedule the notification for 15 minutes later
Task { try? await rescheduleNotification(from: response.notification.request) }
case "COMPLETE":
print("Task marked complete")
case UNNotificationDefaultActionIdentifier:
// User tapped the notification banner itself
if let screen = userInfo["screen"] as? String {
print("Navigate to: \(screen)")
}
case UNNotificationDismissActionIdentifier:
// User dismissed the notification (requires .customDismissAction)
print("Notification dismissed")
default:
break
}
completionHandler()
}
// Called when a notification arrives while the app is in the foreground
func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
) {
// Show banner even when app is in foreground
completionHandler([.banner, .sound, .badge, .list])
}
private func rescheduleNotification(from request: UNNotificationRequest) async throws {
let content = request.content.mutableCopy() as! UNMutableNotificationContent
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 900, repeats: false)
let newRequest = UNNotificationRequest(
identifier: request.identifier + "-snoozed",
content: content,
trigger: trigger
)
try await UNUserNotificationCenter.current().add(newRequest)
}
}
Remote Notifications (APNs Registration and Handling)
class AppDelegate: NSObject, UIApplicationDelegate {
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
UNUserNotificationCenter.current().delegate = NotificationDelegate()
application.registerForRemoteNotifications()
return true
}
// Called when APNs registration succeeds
func application(
_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
) {
let token = deviceToken.map { String(format: "%02.2hhx", $0) }.joined()
print("APNs device token: \(token)")
// Send this token to your backend server
}
// Called when APNs registration fails
func application(
_ application: UIApplication,
didFailToRegisterForRemoteNotificationsWithError error: Error
) {
print("APNs registration failed: \(error)")
}
// Handle silent push notifications (content-available: 1)
func application(
_ application: UIApplication,
didReceiveRemoteNotification userInfo: [AnyHashable: Any],
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void
) {
if let aps = userInfo["aps"] as? [String: Any],
aps["content-available"] as? Int == 1 {
Task {
// Perform background data fetch
completionHandler(.newData)
}
} else {
completionHandler(.noData)
}
}
}
// Example APNs JSON payload (sent from your server):
// {
// "aps": {
// "alert": {
// "title": "New Message",
// "subtitle": "From John",
// "body": "Hey, are you available?"
// },
// "badge": 3,
// "sound": "default",
// "category": "MESSAGE",
// "mutable-content": 1,
// "thread-id": "chat-john",
// "interruption-level": "time-sensitive"
// },
// "messageId": "msg-123",
// "senderId": "user-456"
// }
Notification Service Extension for Rich Notifications
Create a new target: File > New > Target > Notification Service Extension. This allows modifying remote notification content before display (download images, decrypt payloads, etc.). The extension has approximately 30 seconds to complete.
// NotificationService.swift (in the service extension target)
import UserNotifications
class NotificationService: UNNotificationServiceExtension {
var contentHandler: ((UNNotificationContent) -> Void)?
var bestAttemptContent: UNMutableNotificationContent?
override func didReceive(
_ request: UNNotificationRequest,
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void
) {
self.contentHandler = contentHandler
bestAttemptContent = request.content.mutableCopy() as? UNMutableNotificationContent
guard let content = bestAttemptContent else {
contentHandler(request.content)
return
}
// Download and attach an image from the payload
if let imageURLString = content.userInfo["imageURL"] as? String,
let imageURL = URL(string: imageURLString) {
downloadAttachment(from: imageURL) { localURL in
if let localURL = localURL,
let attachment = try? UNNotificationAttachment(
identifier: "image",
url: localURL
) {
content.attachments = [attachment]
}
contentHandler(content)
}
} else {
contentHandler(content)
}
}
// Called if the extension is about to be terminated (time limit reached)
override func serviceExtensionTimeWillExpire() {
if let content = bestAttemptContent {
contentHandler?(content)
}
}
private func downloadAttachment(from url: URL, completion: @escaping (URL?) -> Void) {
URLSession.shared.downloadTask(with: url) { localURL, _, error in
guard let localURL = localURL, error == nil else {
completion(nil)
return
}
let tmpURL = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString + ".jpg")
try? FileManager.default.moveItem(at: localURL, to: tmpURL)
completion(tmpURL)
}.resume()
}
}
Provisional and Critical Alerts
extension NotificationManager {
// Provisional: delivered quietly without showing a permission prompt.
// Notifications appear in Notification Center but not on Lock Screen or as banners.
// The user can later promote to prominent or disable entirely.
func requestProvisionalPermission() async -> Bool {
do {
return try await center.requestAuthorization(
options: [.alert, .sound, .badge, .provisional]
)
} catch {
return false
}
}
// Critical alerts bypass Do Not Disturb and silent mode.
// Requires a special entitlement from Apple (medical, security, public safety apps only).
func requestCriticalPermission() async -> Bool {
do {
return try await center.requestAuthorization(
options: [.alert, .sound, .badge, .criticalAlert]
)
} catch {
return false
}
}
// Send a critical alert
func sendCriticalAlert(title: String, body: String) async throws {
let content = UNMutableNotificationContent()
content.title = title
content.body = body
content.interruptionLevel = .critical
content.sound = UNNotificationSound.criticalSoundNamed(
UNNotificationSoundName("alarm.caf"),
withAudioVolume: 1.0
)
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false)
let request = UNNotificationRequest(
identifier: "critical-\(UUID().uuidString)",
content: content,
trigger: trigger
)
try await center.add(request)
}
}
Managing Pending and Delivered Notifications
extension NotificationManager {
// List all pending (scheduled but not yet delivered) notifications
func getPendingNotifications() async -> [UNNotificationRequest] {
return await center.pendingNotificationRequests()
}
// Remove specific pending notifications by identifier
func removePending(identifiers: [String]) {
center.removePendingNotificationRequests(withIdentifiers: identifiers)
}
// Remove all pending notifications
func removeAllPending() {
center.removeAllPendingNotificationRequests()
}
// Remove delivered notifications from Notification Center
func removeDelivered(identifiers: [String]) {
center.removeDeliveredNotifications(withIdentifiers: identifiers)
}
// Clear the badge count
func clearBadge() async throws {
try await center.setBadgeCount(0)
}
}
Complete Example: Notification Setup in App
import SwiftUI
@main
struct MyApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
var body: some Scene {
WindowGroup {
ContentView()
.task {
let manager = NotificationManager.shared
let granted = await manager.requestPermission()
if granted {
manager.registerCategories()
}
}
}
}
}
struct NotificationSettingsView: View {
@StateObject private var manager = NotificationManager.shared
@State private var dailyReminderEnabled = false
@State private var reminderHour = 9
var body: some View {
Form {
Section("Permissions") {
Button("Request Notification Permission") {
Task { await manager.requestPermission() }
}
Text(manager.isAuthorized ? "Authorized" : "Not authorized")
.foregroundStyle(manager.isAuthorized ? .green : .red)
}
Section("Daily Reminder") {
Toggle("Enable Daily Reminder", isOn: $dailyReminderEnabled)
.onChange(of: dailyReminderEnabled) { _, enabled in
Task {
if enabled {
try? await manager.scheduleDailyReminder(
hour: reminderHour,
minute: 0
)
} else {
manager.removePending(identifiers: ["daily-reminder"])
}
}
}
if dailyReminderEnabled {
Picker("Hour", selection: $reminderHour) {
ForEach(0..<24, id: \.self) { hour in
Text("\(hour):00").tag(hour)
}
}
}
}
Section("Debug") {
Button("Send Test Notification") {
Task { try? await manager.scheduleLocalNotification() }
}
Button("Clear Badge") {
Task { try? await manager.clearBadge() }
}
}
}
.navigationTitle("Notifications")
}
}
---
# VisionKit
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-visionkit.html
← Back to all frameworks & guides
All articles →Core UI and Apps · Reference guideVisionKitRepository guidance for VisionKit. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
Context
Pattern
Framework Choice
Availability and Privacy
Anti-Patterns
Checklist
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Choose scanning interaction
↓2
Check device support
↓3
Present scanning interface
↓4
Consume recognized items
02 / ArchitectureResponsibility boundariesBoundary 1
Camera interfaceBoundary 2
Recognition interactionBoundary 3
App contentConnected responsibilities, not a required class hierarchy or an execution trace.
Context
Use VisionKit when the app needs Apple-provided camera/document UI for scanning, data capture, Live Text-style interaction, or visual lookup workflows. Use Vision when you need lower-level image analysis requests. Use AVFoundation when you need a custom camera pipeline.
VisionKit is a UI-facing framework: it gives you system experiences that already handle camera presentation, capture affordances, and platform interaction patterns. Keep business logic outside the VisionKit view/controller and pass extracted results into an injected service.
Pattern
Wrap VisionKit behind a protocol so SwiftUI views can preview without camera hardware:
import Foundation
protocol DocumentScanning {
func scanReceipt() async throws -> ScannedDocument
}
struct ScannedDocument: Sendable, Equatable {
var pages: [Data]
var recognizedText: String
}
The UI layer asks for a scan; it does not own parsing, persistence, or network upload:
import SwiftUI
@MainActor
@Observable
final class ReceiptScanModel {
private let scanner: any DocumentScanning
var document: ScannedDocument?
var errorMessage: String?
init(scanner: any DocumentScanning) {
self.scanner = scanner
}
func scan() async {
do {
document = try await scanner.scanReceipt()
} catch {
errorMessage = "Scanning failed. Please try again."
}
}
}
For UIKit-backed VisionKit controllers, keep the delegate bridge small and mark UI updates as main-actor work. Do not store scanned images in
UserDefaults
; persist them through the app's document store, SwiftData/Core Data metadata, or an encrypted file path.
Framework Choice
Need
Prefer
System document scanner UI
VisionKit
Live Text / visual lookup style interaction
VisionKit
Barcode/text/object analysis without system UI
Vision
Fully custom camera preview and capture
AVFoundation
OCR results feeding an on-device LLM
VisionKit or Vision -> Foundation Models, with private local context
Availability and Privacy
Gate VisionKit features by platform and API availability.
Provide camera usage copy when the flow opens camera capture.
Keep extracted text and images local unless the user explicitly exports or syncs them.
Show a manual fallback for unsupported devices, simulator runs, parental controls, or camera denial.
Anti-Patterns
// WRONG: force camera-only scanning with no fallback.
Button("Scan") {
showScanner = true
}
// RIGHT: branch by availability and authorization, then offer import/manual entry.
if scannerIsAvailable {
Button("Scan") {
showScanner = true
}
} else {
Button("Import File") {
showImporter = true
}
}
// WRONG: upload recognized text automatically.
try await api.upload(scan.recognizedText)
// RIGHT: make export explicit and explain where content goes.
try await exporter.share(scan, destination: selectedDestination)
Checklist
[ ] VisionKit is chosen for system UI, not low-level image analysis.
[ ] Camera permission copy explains the user-visible scanning purpose.
[ ] The simulator and unsupported devices have a manual/import fallback.
[ ] Extracted text/images do not leave the device without explicit user action.
[ ] The SwiftUI screen previews with a fake
DocumentScanning
implementation.
---
# WidgetKit
https://nagarjuna2997.github.io/ios-agent-skill/guides/frameworks-widgetkit.html
← Back to all frameworks & guides
All articles →Core UI and Apps · Reference guideWidgetKitRepository guidance for WidgetKit. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
Widget Protocol and Configuration
TimelineProvider (Placeholder, Snapshot, Timeline)
TimelineEntry Design
Widget Families (systemSmall, Medium, Large, ExtraLarge)
Lock Screen Widgets (Accessory Families)
WidgetBundle for Multiple Widgets
Interactive Widgets (iOS 17+ Button/Toggle)
Live Activities and ActivityKit
App Intent Configuration
Reloading Widgets from the Main App
iOS 18+ Additions
Controls API (ControlWidget)
Control Center Placement and Sizing
Live Activity Intents (iOS 18)
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Define widget content
↓2
Produce timeline entries
↓3
Render each widget family
↓4
Request relevant reloads
02 / ArchitectureResponsibility boundariesBoundary 1
App dataBoundary 2
Timeline providerBoundary 3
Widget viewsConnected responsibilities, not a required class hierarchy or an execution trace.
WidgetKit enables glanceable, timely content on the Home Screen, Lock Screen, and StandBy mode. Widgets use SwiftUI for their views and a timeline-based system for updates.
Widget Protocol and Configuration
import WidgetKit
import SwiftUI
// Static configuration (no user configuration needed)
struct SimpleWidget: Widget {
let kind: String = "SimpleWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: SimpleProvider()) { entry in
SimpleWidgetView(entry: entry)
.containerBackground(.fill.tertiary, for: .widget)
}
.configurationDisplayName("Daily Summary")
.description("Shows your daily progress at a glance.")
.supportedFamilies([
.systemSmall, .systemMedium, .systemLarge,
.accessoryCircular, .accessoryRectangular, .accessoryInline
])
.contentMarginsDisabled() // iOS 17+: opt out of default content margins
}
}
// App Intent configuration (iOS 17+ user-configurable widget)
struct ConfigurableWidget: Widget {
let kind: String = "ConfigurableWidget"
var body: some WidgetConfiguration {
AppIntentConfiguration(
kind: kind,
intent: SelectCategoryIntent.self,
provider: ConfigurableProvider()
) { entry in
ConfigurableWidgetView(entry: entry)
.containerBackground(.fill.tertiary, for: .widget)
}
.configurationDisplayName("Category Widget")
.description("Shows items from a selected category.")
.supportedFamilies([.systemSmall, .systemMedium])
}
}
TimelineProvider (Placeholder, Snapshot, Timeline)
struct SimpleEntry: TimelineEntry {
let date: Date
let title: String
let value: Int
let icon: String
}
struct SimpleProvider: TimelineProvider {
// Shown while widget is loading. Must return synchronously.
func placeholder(in context: Context) -> SimpleEntry {
SimpleEntry(date: .now, title: "Loading...", value: 0, icon: "star")
}
// Shown in the widget gallery and transient situations.
func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> Void) {
if context.isPreview {
// Return sample data for the gallery preview
completion(SimpleEntry(date: .now, title: "Steps Today", value: 8432, icon: "figure.walk"))
} else {
// Fetch real data for transient display
let entry = SimpleEntry(date: .now, title: "Steps Today", value: fetchStepCount(), icon: "figure.walk")
completion(entry)
}
}
// Provides the timeline of entries that drive the widget's display.
func getTimeline(in context: Context, completion: @escaping (Timeline<SimpleEntry>) -> Void) {
var entries: [SimpleEntry] = []
let currentDate = Date()
// Create entries for the next 5 hours
for hourOffset in 0..<5 {
let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)!
let entry = SimpleEntry(
date: entryDate,
title: "Steps Today",
value: fetchStepCount() + (hourOffset * 500),
icon: "figure.walk"
)
entries.append(entry)
}
// Timeline reload policies:
// .atEnd - reload after the last entry's date passes
// .after(d) - reload after a specific date
// .never - only reload when the app explicitly requests it
let timeline = Timeline(entries: entries, policy: .atEnd)
completion(timeline)
}
private func fetchStepCount() -> Int { return 8432 }
}
// Async provider using AppIntentTimelineProvider (cleaner async/await API)
struct ConfigurableProvider: AppIntentTimelineProvider {
typealias Entry = SimpleEntry
typealias Intent = SelectCategoryIntent
func placeholder(in context: Context) -> SimpleEntry {
SimpleEntry(date: .now, title: "Loading...", value: 0, icon: "star")
}
func snapshot(for configuration: SelectCategoryIntent, in context: Context) async -> SimpleEntry {
SimpleEntry(date: .now, title: configuration.category?.name ?? "All", value: 42, icon: "star")
}
func timeline(for configuration: SelectCategoryIntent, in context: Context) async -> Timeline<SimpleEntry> {
let entries = [
SimpleEntry(
date: .now,
title: configuration.category?.name ?? "All",
value: 42,
icon: "star"
)
]
return Timeline(entries: entries, policy: .after(.now.addingTimeInterval(3600)))
}
}
TimelineEntry Design
// Rich timeline entry with multiple data points
struct DashboardEntry: TimelineEntry {
let date: Date
let tasks: [TaskItem]
let completedCount: Int
let totalCount: Int
let streakDays: Int
var completionPercentage: Double {
guard totalCount > 0 else { return 0 }
return Double(completedCount) / Double(totalCount)
}
static var preview: DashboardEntry {
DashboardEntry(
date: .now,
tasks: [
TaskItem(name: "Morning workout", isComplete: true),
TaskItem(name: "Read 30 minutes", isComplete: false),
TaskItem(name: "Meditate", isComplete: true)
],
completedCount: 5,
totalCount: 8,
streakDays: 12
)
}
}
struct TaskItem: Identifiable {
let id = UUID()
let name: String
let isComplete: Bool
}
Widget Families (systemSmall, Medium, Large, ExtraLarge)
struct SimpleWidgetView: View {
var entry: SimpleEntry
@Environment(\.widgetFamily) var family
var body: some View {
switch family {
case .systemSmall:
smallView
case .systemMedium:
mediumView
case .systemLarge:
largeView
case .systemExtraLarge:
extraLargeView // iPad only
case .accessoryCircular:
circularView
case .accessoryRectangular:
rectangularView
case .accessoryInline:
inlineView
@unknown default:
smallView
}
}
// Home Screen small (~169x169 pt)
var smallView: some View {
VStack(alignment: .leading, spacing: 4) {
Image(systemName: entry.icon)
.font(.title2)
.foregroundStyle(.blue)
Spacer()
Text(entry.title)
.font(.caption)
.foregroundStyle(.secondary)
Text("\(entry.value)")
.font(.title.bold())
.contentTransition(.numericText())
}
.padding()
.widgetURL(URL(string: "myapp://steps"))
}
// Home Screen medium (~360x169 pt)
var mediumView: some View {
HStack {
smallView
Spacer()
VStack(alignment: .trailing) {
Text("Goal: 10,000")
.font(.caption)
ProgressView(value: Double(entry.value), total: 10000)
.tint(.blue)
Text("\(10000 - entry.value) remaining")
.font(.caption2)
.foregroundStyle(.secondary)
}
.padding()
}
}
// Home Screen large (~360x376 pt)
var largeView: some View {
VStack(alignment: .leading, spacing: 12) {
mediumView
Divider()
Text("Hourly Breakdown")
.font(.headline)
ForEach(0..<4) { hour in
HStack {
Text("\(hour + 9):00")
.font(.caption.monospacedDigit())
ProgressView(value: Double.random(in: 0.2...1.0))
.tint(.blue)
}
}
Spacer()
}
.padding()
}
var extraLargeView: some View {
largeView // Customize further for iPad extra large
}
}
Lock Screen Widgets (Accessory Families)
extension SimpleWidgetView {
// Lock Screen circular gauge
var circularView: some View {
Gauge(value: Double(entry.value), in: 0...10000) {
Image(systemName: entry.icon)
} currentValueLabel: {
Text("\(entry.value / 1000)k")
.font(.caption2)
}
.gaugeStyle(.accessoryCircular)
}
// Lock Screen rectangular
var rectangularView: some View {
VStack(alignment: .leading) {
Label("\(entry.value)", systemImage: entry.icon)
.font(.headline)
Text(entry.title)
.font(.caption)
.foregroundStyle(.secondary)
ProgressView(value: Double(entry.value), total: 10000)
}
}
// Lock Screen inline (single line of text beside clock)
var inlineView: some View {
Label("\(entry.value) \(entry.title)", systemImage: entry.icon)
}
}
// Rendering mode for lock screen
// Lock Screen widgets are rendered in one of three modes:
// - .vibrant: tinted semi-transparent material (iOS Lock Screen)
// - .accented: tinted with user's chosen accent color (watchOS)
// - .fullColor: standard colors (Home Screen)
// Use @Environment(\.widgetRenderingMode) to adapt
WidgetBundle for Multiple Widgets
@main
struct MyWidgets: WidgetBundle {
var body: some Widget {
SimpleWidget()
ConfigurableWidget()
DashboardWidget()
if #available(iOS 18, *) {
ControlWidget()
}
}
}
Interactive Widgets (iOS 17+ Button/Toggle)
iOS 17 introduced interactive widgets with Button and Toggle that perform AppIntents directly from the widget.
import AppIntents
// App Intent for toggling a task
struct ToggleTaskIntent: AppIntent {
static var title: LocalizedStringResource = "Toggle Task"
@Parameter(title: "Task ID")
var taskID: String
init() {}
init(taskID: String) {
self.taskID = taskID
}
func perform() async throws -> some IntentResult {
let store = TaskStore.shared
store.toggleTask(id: taskID)
// Reload the widget timeline to reflect the change
WidgetCenter.shared.reloadTimelines(ofKind: "TaskWidget")
return .result()
}
}
struct IncrementCountIntent: AppIntent {
static var title: LocalizedStringResource = "Increment Counter"
func perform() async throws -> some IntentResult {
CounterStore.shared.increment()
WidgetCenter.shared.reloadTimelines(ofKind: "CounterWidget")
return .result()
}
}
// Widget view with interactive elements
struct InteractiveTaskWidget: View {
let entry: DashboardEntry
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Tasks")
.font(.headline)
ForEach(entry.tasks) { task in
HStack {
// Interactive toggle button
Button(intent: ToggleTaskIntent(taskID: task.id.uuidString)) {
Image(systemName: task.isComplete ? "checkmark.circle.fill" : "circle")
.foregroundStyle(task.isComplete ? .green : .secondary)
}
.buttonStyle(.plain)
Text(task.name)
.strikethrough(task.isComplete)
.font(.subheadline)
}
}
Spacer()
// Interactive toggle
Toggle(isOn: entry.completedCount > 0, intent: IncrementCountIntent()) {
Text("Focus Mode")
}
.toggleStyle(.switch)
}
.padding()
}
}
Live Activities and ActivityKit
import ActivityKit
// Define the attributes for the Live Activity
struct DeliveryAttributes: ActivityAttributes {
// Dynamic content that changes over time
struct ContentState: Codable, Hashable {
var status: String
var estimatedArrival: Date
var driverName: String
var currentStep: Int // 0: preparing, 1: picked up, 2: nearby, 3: delivered
}
// Static content set at creation
let orderNumber: String
let restaurantName: String
}
class LiveActivityManager {
var currentActivity: Activity<DeliveryAttributes>?
func startDeliveryTracking(orderNumber: String, restaurant: String) throws {
guard ActivityAuthorizationInfo().areActivitiesEnabled else {
print("Live Activities not enabled")
return
}
let attributes = DeliveryAttributes(
orderNumber: orderNumber,
restaurantName: restaurant
)
let initialState = DeliveryAttributes.ContentState(
status: "Preparing your order",
estimatedArrival: .now.addingTimeInterval(1800),
driverName: "Alex",
currentStep: 0
)
let content = ActivityContent(state: initialState, staleDate: nil)
currentActivity = try Activity.request(
attributes: attributes,
content: content,
pushType: .token // .token for server push updates, nil for local-only
)
// Observe push token for server-driven updates
if let activity = currentActivity {
Task {
for await token in activity.pushTokenUpdates {
let tokenString = token.map { String(format: "%02x", $0) }.joined()
print("Live Activity push token: \(tokenString)")
// Send this token to your server
}
}
}
}
// Update the Live Activity locally
func updateDelivery(status: String, step: Int, eta: Date) async {
let updatedState = DeliveryAttributes.ContentState(
status: status,
estimatedArrival: eta,
driverName: "Alex",
currentStep: step
)
let content = ActivityContent(state: updatedState, staleDate: nil)
await currentActivity?.update(content)
}
// End the Live Activity
func endDelivery() async {
let finalState = DeliveryAttributes.ContentState(
status: "Delivered!",
estimatedArrival: .now,
driverName: "Alex",
currentStep: 3
)
let content = ActivityContent(state: finalState, staleDate: nil)
// Dismissal policies:
// .default - user can dismiss manually
// .immediate - disappears right away
// .after(date) - auto-dismiss after the specified date
await currentActivity?.end(content, dismissalPolicy: .after(.now.addingTimeInterval(300)))
}
}
// Live Activity UI (defined in your Widget extension target)
struct DeliveryLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: DeliveryAttributes.self) { context in
// Lock Screen and banner presentation
VStack(spacing: 12) {
HStack {
Text(context.attributes.restaurantName)
.font(.headline)
Spacer()
Text("Order #\(context.attributes.orderNumber)")
.font(.caption)
.foregroundStyle(.secondary)
}
ProgressView(value: Double(context.state.currentStep), total: 3)
.tint(.green)
HStack {
Label(context.state.status, systemImage: "bicycle")
.font(.subheadline)
Spacer()
Text(context.state.estimatedArrival, style: .timer)
.font(.subheadline.monospacedDigit())
}
}
.padding()
.activityBackgroundTint(.black.opacity(0.8))
.activitySystemActionForegroundColor(.white)
} dynamicIsland: { context in
DynamicIsland {
DynamicIslandExpandedRegion(.leading) {
Image(systemName: "bicycle")
.font(.title2)
}
DynamicIslandExpandedRegion(.trailing) {
Text(context.state.estimatedArrival, style: .timer)
.font(.caption.monospacedDigit())
}
DynamicIslandExpandedRegion(.center) {
Text(context.attributes.restaurantName)
.font(.headline)
}
DynamicIslandExpandedRegion(.bottom) {
VStack(spacing: 8) {
Text(context.state.status)
.font(.subheadline)
ProgressView(value: Double(context.state.currentStep), total: 3)
.tint(.green)
}
}
} compactLeading: {
Image(systemName: "bicycle")
} compactTrailing: {
Text(context.state.estimatedArrival, style: .timer)
.font(.caption.monospacedDigit())
} minimal: {
Image(systemName: "bicycle")
}
}
}
}
App Intent Configuration
import AppIntents
// Define a configurable entity for widget parameters
struct CategoryEntity: AppEntity {
static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Category")
static var defaultQuery = CategoryQuery()
var id: String
var name: String
var displayRepresentation: DisplayRepresentation {
DisplayRepresentation(title: "\(name)")
}
}
struct CategoryQuery: EntityQuery {
func entities(for identifiers: [String]) async throws -> [CategoryEntity] {
allCategories().filter { identifiers.contains($0.id) }
}
func suggestedEntities() async throws -> [CategoryEntity] {
allCategories()
}
func defaultResult() async -> CategoryEntity? {
allCategories().first
}
private func allCategories() -> [CategoryEntity] {
[
CategoryEntity(id: "work", name: "Work"),
CategoryEntity(id: "personal", name: "Personal"),
CategoryEntity(id: "health", name: "Health")
]
}
}
// Widget configuration intent
struct SelectCategoryIntent: WidgetConfigurationIntent {
static var title: LocalizedStringResource = "Select Category"
static var description = IntentDescription("Choose which category to display in the widget.")
@Parameter(title: "Category")
var category: CategoryEntity?
@Parameter(title: "Show Completed", default: true)
var showCompleted: Bool
}
Reloading Widgets from the Main App
import WidgetKit
class WidgetReloadManager {
// Reload a specific widget by kind
func reloadTaskWidget() {
WidgetCenter.shared.reloadTimelines(ofKind: "TaskWidget")
}
// Reload all widgets belonging to this app
func reloadAllWidgets() {
WidgetCenter.shared.reloadAllTimelines()
}
// Get current widget configurations on the user's device
func getWidgetInfo() {
WidgetCenter.shared.getCurrentConfigurations { result in
switch result {
case .success(let widgets):
for widget in widgets {
print("Kind: \(widget.kind), Family: \(widget.family)")
}
case .failure(let error):
print("Error fetching configurations: \(error)")
}
}
}
}
// Sharing data between the app and widget extension using App Groups.
// 1. Enable App Groups capability in both the app target and widget extension target
// 2. Use the shared container:
let sharedDefaults = UserDefaults(suiteName: "group.com.yourapp.shared")
sharedDefaults?.set(42, forKey: "stepCount")
let sharedContainer = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: "group.com.yourapp.shared"
)
// Write/read files in sharedContainer for larger data sets
iOS 18+ Additions
Controls API (ControlWidget)
iOS 18 introduces
ControlWidget
for adding interactive controls to Control Center and the Lock Screen. Controls are small, actionable widgets that can toggle states or trigger actions.
import WidgetKit
import SwiftUI
import AppIntents
// MARK: - Toggle Control (e.g., a light switch)
struct LightToggleControl: ControlWidget {
var body: some ControlWidgetConfiguration {
StaticControlConfiguration(kind: "com.app.light-toggle") {
ControlWidgetToggle(
"Living Room",
isOn: LightManager.shared.isLivingRoomOn,
action: ToggleLightIntent(room: "living-room")
) { isOn in
// The view displayed in the control
Label(isOn ? "On" : "Off", systemImage: isOn ? "lightbulb.fill" : "lightbulb")
}
.tint(.yellow)
}
.displayName("Light Toggle")
.description("Toggle a light on or off.")
}
}
// App Intent that the toggle invokes
struct ToggleLightIntent: SetValueIntent {
static var title: LocalizedStringResource = "Toggle Light"
@Parameter(title: "Room")
var room: String
@Parameter(title: "Is On")
var value: Bool
init() {
self.room = "living-room"
self.value = false
}
init(room: String) {
self.room = room
self.value = false
}
func perform() async throws -> some IntentResult {
LightManager.shared.setLight(room: room, isOn: value)
return .result()
}
}
// MARK: - Button Control (e.g., trigger an action)
struct CaffeineLogControl: ControlWidget {
var body: some ControlWidgetConfiguration {
StaticControlConfiguration(kind: "com.app.caffeine-log") {
ControlWidgetButton(action: LogCaffeineIntent()) {
Label("Log Coffee", systemImage: "cup.and.saucer.fill")
}
}
.displayName("Log Caffeine")
.description("Quickly log a coffee.")
}
}
struct LogCaffeineIntent: AppIntent {
static var title: LocalizedStringResource = "Log Caffeine"
func perform() async throws -> some IntentResult {
CaffeineStore.shared.logCoffee()
return .result()
}
}
// MARK: - Configurable Control with AppIntentControlConfiguration
struct ConfigurableLightControl: ControlWidget {
var body: some ControlWidgetConfiguration {
AppIntentControlConfiguration(
kind: "com.app.configurable-light",
intent: SelectRoomIntent.self
) { configuration in
ControlWidgetToggle(
configuration.room?.name ?? "Light",
isOn: LightManager.shared.isOn(room: configuration.room?.id ?? ""),
action: ToggleLightIntent(room: configuration.room?.id ?? "")
) { isOn in
Label(isOn ? "On" : "Off", systemImage: isOn ? "lightbulb.fill" : "lightbulb")
}
.tint(.orange)
}
.displayName("Room Light")
.description("Toggle a specific room's light.")
}
}
struct SelectRoomIntent: ControlConfigurationIntent {
static var title: LocalizedStringResource = "Select Room"
@Parameter(title: "Room")
var room: RoomEntity?
}
// MARK: - Registering Controls in WidgetBundle
@main
struct MyWidgets: WidgetBundle {
var body: some Widget {
SimpleWidget()
DashboardWidget()
// iOS 18+ Control Center widgets
if #available(iOS 18, *) {
LightToggleControl()
CaffeineLogControl()
ConfigurableLightControl()
}
}
}
Control Center Placement and Sizing
Controls appear in the redesigned iOS 18 Control Center. Users add them from the Control Center gallery, just like Home Screen widgets.
Key characteristics:
-
Small footprint
: Controls occupy a single Control Center cell (similar to the existing toggles like Wi-Fi, Bluetooth)
-
Two types
:
ControlWidgetToggle
for on/off state,
ControlWidgetButton
for one-shot actions
-
Lock Screen access
: Controls can also be placed on the Lock Screen as quick-action buttons (replacing the default flashlight/camera shortcuts)
-
Tinting
: Use
.tint()
to set the active color of a toggle control
-
Static vs Configurable
: Use
StaticControlConfiguration
for fixed controls,
AppIntentControlConfiguration
for user-configurable controls with parameters
-
Value providers
: For toggles, provide a value source that returns the current state; SwiftData or App Groups work well for shared state
Live Activity Intents (iOS 18)
iOS 18 enables Live Activities to include App Intent-driven buttons and toggles directly on the Lock Screen and Dynamic Island.
import ActivityKit
import AppIntents
import SwiftUI
import WidgetKit
// Live Activity with interactive intent-driven buttons
struct OrderTrackingLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: OrderAttributes.self) { context in
// Lock Screen / banner view with interactive buttons
VStack(spacing: 12) {
HStack {
Text(context.attributes.storeName)
.font(.headline)
Spacer()
Text(context.state.status)
.font(.subheadline)
.foregroundStyle(.secondary)
}
ProgressView(value: context.state.progress)
.tint(.blue)
HStack(spacing: 16) {
// Intent-driven button on the Live Activity
Button(intent: ContactDriverIntent(orderID: context.attributes.orderID)) {
Label("Contact Driver", systemImage: "phone.fill")
.font(.caption)
}
.buttonStyle(.bordered)
.tint(.green)
Button(intent: CancelOrderIntent(orderID: context.attributes.orderID)) {
Label("Cancel", systemImage: "xmark.circle")
.font(.caption)
}
.buttonStyle(.bordered)
.tint(.red)
}
}
.padding()
} dynamicIsland: { context in
DynamicIsland {
DynamicIslandExpandedRegion(.bottom) {
Button(intent: ContactDriverIntent(orderID: context.attributes.orderID)) {
Label("Contact Driver", systemImage: "phone.fill")
}
.buttonStyle(.bordered)
}
DynamicIslandExpandedRegion(.center) {
Text(context.state.status)
}
DynamicIslandExpandedRegion(.leading) {
Image(systemName: "bag.fill")
}
DynamicIslandExpandedRegion(.trailing) {
Text(context.state.eta, style: .timer)
}
} compactLeading: {
Image(systemName: "bag.fill")
} compactTrailing: {
Text(context.state.eta, style: .timer)
.font(.caption2)
} minimal: {
Image(systemName: "bag.fill")
}
}
}
}
struct ContactDriverIntent: AppIntent {
static var title: LocalizedStringResource = "Contact Driver"
@Parameter(title: "Order ID")
var orderID: String
init() { self.orderID = "" }
init(orderID: String) { self.orderID = orderID }
func perform() async throws -> some IntentResult {
// Open the call screen or in-app chat for the driver
return .result()
}
}
struct OrderAttributes: ActivityAttributes {
struct ContentState: Codable, Hashable {
var status: String
var progress: Double
var eta: Date
}
let orderID: String
let storeName: String
}
---
# MCP Usage Examples
https://nagarjuna2997.github.io/ios-agent-skill/guides/mcp-examples.html
← Back to all frameworks & guides
All articles →Mcp · Reference guideMCP Usage ExamplesRepository guidance for MCP Usage Examples. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
1. Onboarding onto an unfamiliar codebase
2. Diagnosing a data race
🟠 Sources/Views/FeedView.swift:7 — Task.detached drops actor isolation, priority, and task-locals.
🟠 Sources/Views/Card.swift:14 — glassEffect was introduced in iOS 26 but is guarded at iOS 27.
🔴 Sources/LocationService.swift:1 — Uses location but Info.plist has no NSLocationWhenInUseUsageDescription.
🔴 PrivacyInfo.xcprivacy:1 — No PrivacyInfo.xcprivacy found in the project.
🟠 Sources/Views/Toolbar.swift:22 — Icon-only button has no accessibility label.
8. Reading the project without asking
9. Checking the skill itself
10. In CI
What to expect
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Select a concrete review question
↓2
Call the narrowest tool
↓3
Inspect structured findings
↓4
Verify a focused fix
02 / ArchitectureResponsibility boundariesBoundary 1
Client requestBoundary 2
Review toolBoundary 3
Evidence-backed responseConnected responsibilities, not a required class hierarchy or an execution trace.
Load this when:
you want to see what a session with
ios-agent-mcp
actually
looks like, or how to combine it with the skill's subagents.
Every transcript below is the server's real output format.
1. Onboarding onto an unfamiliar codebase
You:
I just inherited this Swift project. What am I looking at?
Claude calls
analyze_swift_project
:
# Swift Project Analysis
## Structure
- **Swift files:** 12
- **Lines:** 1,998
- **Deployment target:** iOS 17
- **Swift tools version:** 5.9
- **Test files:** found
## Shape
- **UI framework:** unknown
- **Architecture:** Clean Architecture
- evidence: 1 use-case protocol(s) or a UseCases/ directory; 1 repository protocol(s)
- **Dependency injection:** protocol existentials injected through init
- **Third-party dependencies:** none detected
- **Frameworks:** Accelerate, Foundation, Observation, SwiftData, XCTest
## Findings by category
| Category | 🔴 Blocker | 🟠 Serious | 🟡 Minor | Tool |
|---|---:|---:|---:|---|
| Concurrency | 0 | 0 | 0 | `review_swift_concurrency` |
| Architecture | 0 | 0 | 0 | `review_swift_architecture` |
| SwiftUI | 0 | 0 | 0 | `review_swiftui` |
| Availability | 0 | 0 | 0 | `check_availability_guards` |
| Memory | 0 | 0 | 0 | `review_swift_memory` |
| Security | 0 | 0 | 0 | `review_swift_security` |
| Performance | 0 | 0 | 0 | `review_swift_performance` |
| Testing | 0 | 0 | 0 | `review_swift_testing` |
| App Store | 0 | 0 | 0 | `audit_app_store_readiness` |
**No findings in any category.**
That is genuinely the current output of running the server against this repo's
own
samples/SkillPatterns
, pasted rather than composed — the package builds
and its tests pass on macOS CI on every push.
UI framework: unknown
is correct, not a gap: the sample deliberately contains
no SwiftUI view code so it can build on a plain runner with no simulator.
Why start here:
the table tells you which of the ten tools is worth running.
Running all of them on a large project buries the signal.
2. Diagnosing a data race
You:
Users report the feed occasionally shows stale posts. Anything obviously wrong?
review_swift_concurrency
:
### 🔴 Sources/Views/FeedView.swift:3 — @Observable type is not @MainActor-isolated.
```swift
@Observable
Why it matters:
@Observable grants no isolation. SwiftUI reads this state
during layout while any task may write it — a data race under Swift 5 mode, a
compile error under Swift 6.
Fix:
Annotate the type:
@MainActor @Observable final class …
. Annotate the
type, not individual members — per-member isolation leaves gaps.
Rule observable-without-mainactor · see docs/swift/swift-concurrency.md
🟠 Sources/Views/FeedView.swift:7 — Task.detached drops actor isolation, priority, and task-locals.
Two findings that together explain the symptom: unisolated state plus a detached
task writing to it.
---
## 3. The finding people don't expect
> **You:** We shipped Liquid Glass last release but users on iOS 26 say they don't see it.
`check_availability_guards`:
🟠 Sources/Views/Card.swift:14 — glassEffect was introduced in iOS 26 but is guarded at iOS 27.
Why it matters:
Every device on iOS 26–26 falls back unnecessarily, losing
the feature for a large installed base. This is invisible when testing on a
current device.
Fix:
Guard on iOS 26, the version where the symbol was introduced — not the
newest SDK.
The code compiled, shipped, and worked on the developer's phone. Nothing but a
rule catches this.
---
## 4. Pre-submission audit
> **You:** We submit tomorrow. Anything that will get rejected?
`audit_app_store_readiness`:
🔴 Sources/LocationService.swift:1 — Uses location but Info.plist has no NSLocationWhenInUseUsageDescription.
Why it matters:
iOS terminates the app the moment the permission is
requested, and App Review rejects the submission.
🔴 PrivacyInfo.xcprivacy:1 — No PrivacyInfo.xcprivacy found in the project.
🟠 Sources/Views/Toolbar.swift:22 — Icon-only button has no accessibility label.
Two of these are hard rejections; the third is an accessibility failure a
reviewer may also catch.
---
## 5. Combined with the skill's subagents
The MCP server finds *what* is wrong. The subagents in `.claude/agents/` decide
*what to do*. A useful pairing:
analyze_swift_project → where the problems are
ios-plan → a fix plan, respecting existing seams
main agent → execute
review_swift_concurrency → confirm the category is now clean
swift-reviewer → build + tests, with real output
Step 4 and step 5 are doing different jobs, and both matter:
- The **MCP tool** proves the *pattern* is gone. It is deterministic and cannot
be argued with.
- The **reviewer subagent** proves the *code still works*, by running the build
and pasting the output.
A clean static report on code that no longer compiles is worthless. Neither
check substitutes for the other. See `../orchestration/verification.md`.
---
## 6. The four checks people run after something goes wrong
These were added in 2.1.0, and each answers a question the first six could not.
### Memory — "the screen is gone but it is still updating"
> **You:** deinit never runs on my ClockModel.
Claude → review_swift_memory
🟠 Sources/ClockModel.swift:12 — Repeating Timer captures self strongly.
Why: the run loop retains the timer, the timer retains the block, and the
block retains self. The cycle is self -> Timer -> closure -> self, and
ARC cannot break it.
Fix: capture
[weak self]
, and invalidate() when the owner goes away.
Deliberately narrow: a closure capturing `self` is **not** a leak, and flagging
every one would bury the handful that are. The rules fire only on APIs that
*store* the closure — repeating `Timer`, block `NotificationCenter` observers,
Combine `sink`, stored closure properties, non-`weak` delegates.
### Security — before handling credentials
> **You:** security pass before we ship the login screen.
Claude → review_swift_security
🔴 Sources/Session.swift:31 — Credential written to UserDefaults.
Why: UserDefaults is an unencrypted plist in the app container. It is
readable on a jailbroken device AND included in unencrypted backups,
so the token leaves the device entirely.
Fix: Keychain with kSecAttrAccessibleWhenUnlockedThisDeviceOnly.
`hardcoded-secret` skips the placeholders people legitimately commit —
`YOUR_API_KEY`, `<your-key>`, `changeme`. A rule that cries wolf on those is a
rule everyone learns to ignore.
### Testing — when the suite is green and you do not believe it
The inverse of every other tool: it runs **only** on test files.
Claude → review_swift_testing
🟠 Tests/FeedTests.swift:22 — Test contains no assertion.
Why: it passes as long as nothing throws, so it reports success whether the
behavior is right or wrong.
Fix: assert the outcome, or delete it. A test that cannot fail is not a test.
🔴 Tests/FeedTests.swift:41 —
await
inside an XCTAssert autoclosure.
Why: XCTAssert takes an autoclosure, which cannot contain an await. This
does not compile.
Fix:
let value = try await subject.run()
, then assert on
value
.
### Performance — when scrolling stutters
Claude → review_swift_performance
🟠 Sources/FeedView.swift:24 — Formatter allocated inside
body
.
Why: body re-runs on every state change, and constructing a DateFormatter is
one of the most expensive routine operations on the platform. In a list
this is the hitch.
Fix: hoist to a
static let
, or use
.formatted()
.
Rules that only matter on the render path are scoped to `var body: some View`
and stay quiet elsewhere — the same `DateFormatter()` is 🟠 inside `body` and
🟡 outside it.
---
## 7. Branching on a result without parsing prose
Every review tool returns `structuredContent` next to the markdown, so a
workflow can act on the numbers:
```jsonc
{
"summary": "Security Review: 1 blocker, 2 serious across 34 files.",
"score": 91,
"counts": { "blocker": 1, "serious": 2, "minor": 0, "total": 3 },
"files_checked": 34,
"issues": [ /* file, line, severity, rule, consequence, fix */ ],
"suggestions": ["hardcoded-secret: Move it server-side…"]
}
suggestions
groups by rule rather than repeating every issue's fix — forty
literal-spacing findings produce one instruction, not forty.
On score:
it is a published formula, not a judgement —
100 × (1 − penalty/capacity)
where
penalty = 10·blockers + 3·serious +
1·minor
and
capacity = files × 10
. Because it is a
density
, it is
comparable across runs on one project and
not
between projects: a UI-heavy
app and a networking library have different rule surfaces. Use
counts
for
anything that matters.
8. Reading the project without asking
Point the server at a project once, in the client config:
{
"mcpServers": {
"ios-agent": {
"command": "npx",
"args": ["-y", "ios-agent-mcp", "--project", "/Users/you/Projects/MyApp"]
}
}
}
Then
ios://project/info
is readable without the model deciding to call
anything:
{
"project_root": "/Users/you/Projects/MyApp",
"available": true,
"swift_files": 84,
"deployment_target": "17.0",
"ui_framework": "SwiftUI",
"architecture": "MVVM",
"architecture_evidence": ["12 ViewModel/Model type(s)", "Views/ and ViewModels/ directories"],
"uses_dependency_injection": true,
"has_tests": true
}
The
evidence
ships with the verdict. "MVVM" alone is a guess presented as a
fact; the list underneath is a claim you can check. When the signals are weak it
says
not determined
rather than picking the most popular answer.
Every payload also reports
project_root
, because the root is implicit — it
comes from
--project
,
IOS_AGENT_PROJECT
, or the directory the client
happened to spawn the server in, and a reader who cannot see which one won has
no way to tell an empty project from a wrong path.
There is no ios://project/build-status.
It would have to run
xcodebuild
,
which needs macOS and Xcode and breaks the
filesystem: read, network: none
contract that lets this install anywhere in ~26 KB.
9. Checking the skill itself
lint_skill
is the one tool that does not read Swift. Use it when a subagent
never gets invoked, or before publishing a skill:
Claude → lint_skill
🔴 .claude/agents/swift-reviewer.md:4 — described as read-only but granted Write.
Why: the main agent delegates on the strength of that promise. A reviewer
that can edit will fix what it was meant to report, and the separation
of duties the review depends on is gone with nothing in the output to
show it.
Fix: remove Write from `tools`, or stop describing the agent as read-only.
Run against this repository it found a real defect on its first run: one of the
ten subagents described what it
is
without saying when to
use
it, making it
measurably less likely to be selected than its nine peers.
10. In CI
The server is for interactive use, but the same rules run headless via
templates/hooks/forbid-antipatterns.sh
— the analyzers were derived from it.
Use the hook in CI and pre-commit, and the MCP server when you want an agent to
explain and fix what it found.
What to expect
A clean report is not a passing build.
Every tool says so in its own footer.
Static analysis cannot type-check, run, or prove behavior.
Fewer findings on unconventional layouts.
Architecture rules infer layers
from directory names. A project that does not use
Views/
or
Domain/
gets
fewer architecture findings — not wrong ones.
Test and mock files are exempt
from app-code-only rules, deliberately. So is
Package.swift
.
review_swift_testing
is the exception that proves it — it is
the only tool that runs
exclusively
on test files.
score is a density, not a grade.
Comparable across runs on one project,
meaningless between projects. The formula is published in
tools.md
so the
number is reproducible rather than a vibe.
---
# Installing the iOS Agent MCP Server
https://nagarjuna2997.github.io/ios-agent-skill/guides/mcp-installation.html
← Back to all frameworks & guides
All articles →Mcp · Reference guideInstalling the iOS Agent MCP ServerRepository guidance for Installing the iOS Agent MCP Server. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
One install, one MCP connection
Choose your client
Codex
ChatGPT plugin and remote MCP
Gemini CLI
Claude Code
Claude Desktop
Platform notes
Windows
macOS and Linux
Avoiding a fetch on every launch
From source
Requirements
Verifying it works
Troubleshooting
Privacy
Xcode 27
Muse Code
Verified compatibility, 2026-09-16
Recheck on upgrades
Support scope and optional source installer
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Choose an MCP client
↓2
Configure server command
↓3
Start a project connection
↓4
Confirm tool discovery
02 / ArchitectureResponsibility boundariesBoundary 1
AI clientBoundary 2
MCP connectionBoundary 3
Local serverConnected responsibilities, not a required class hierarchy or an execution trace.
One install, one MCP connection
claude mcp add ios-agent -- npx -y ios-agent-mcp@latest
The default server exposes 36 tools in 2.7.0: 12 review/metadata tools, 8 Apple reference tools, 14 simulator tools,
create_app
, and the local
prepare_issue_report
tool. App scaffolding and simulator packages install automatically as dependencies; no separate installation or MCP connection is needed. Remove the separate knowledge/simulator connections if you previously configured them to avoid duplicate tools.
Create a starter directly:
npx -y ios-agent-mcp@latest new MyApp --brief "A reading list with local storage" --xcodegen
Requires Node.js 20+. Simulator operations require macOS and Xcode; XcodeGen is required to generate an Xcode project from the starter specification. The agent implements app features using the starter, source tools and verification tools. One install is not autonomous app generation. The default connection now includes tools that write files and operate the simulator; review and reference tools remain read-only.
Load this when:
setting up
ios-agent-mcp
in Claude Code, Claude Desktop,
ChatGPT/Codex, Gemini, or another MCP-capable client.
The server includes Swift analysis and App Intents review plus
lint_skill
, which checks a
skill repository's own metadata. Full tool reference:
tools.md
.
It also serves three
resources
(
ios://project/info
,
.../dependencies
,
.../issues
), which need a project root. Resolution order:
--project PATH
in the client config
IOS_AGENT_PROJECT
the nearest ancestor of the working directory holding a
.ios-agent/
directory — the marker
ios-agent
writes (
docs/tooling/project-scaffolding.md
)
the working directory itself
The analysis tools only
read
that marker; it never creates one, while app creation and simulator tools have separate write/runtime effects.
ios://project/info
reports
resolved_from
alongside the path, because an implicit root is
otherwise unfalsifiable — "no Swift files" reads identically whether the project
is empty or the server is pointed at the wrong directory.
Tools take an explicit path argument and need no configuration.
Worked sessions:
examples.md
.
Choose your client
Client
Install path
Capabilities
Claude Code/Desktop
Local stdio MCP configuration below
Unified local tools
Codex
Codex MCP CLI/config or the release plugin ZIP
Local analysis, knowledge and app-building skill
ChatGPT
Portable skills-only plugin ZIP; optional hosted knowledge MCP
Bundled workflows/references; implementation needs a coding environment
Muse Code
Local stdio setup below; existing AGENTS.md and Claude-format skills
MCP discovery and Stop command hook verified; model session unverified
Gemini CLI
GitHub extension or local MCP configuration
GEMINI instructions, analysis and knowledge tools
Codex
codex mcp add ios-agent -- npx -y ios-agent-mcp@latest
Equivalent
config.toml
entry:
[mcp_servers.ios-agent]
command = "npx"
args = ["-y", "ios-agent-mcp@latest"]
Pass
--project
and an absolute app path when project resource discovery needs an explicit root. The release plugin ZIP is an alternative; use one method to avoid duplicate tools.
ChatGPT plugin and remote MCP
The GitHub release includes
ios-agent-chatgpt.zip
, a self-contained skills-only plugin with the Apple references and app/icon workflow. It contains no local-process MCP configuration, so it does not pretend a browser can run
npx
on your Mac. Upload/import it through a supported plugin development or submission flow for your account. Public marketplace listing remains subject to publisher verification and platform review.
The optional
knowledge MCP server
supports Streamable HTTP. Deploy it at a stable HTTPS URL, then connect
/mcp
through ChatGPT’s supported developer-mode workflow. OpenAI also supports Secure MCP Tunnel for developer-mode access to a private stdio or HTTP server; see the
official connection guide
. This release does not create a tunnel or expose your Mac. Account/workspace policy may limit developer mode. The repository does not invent a production endpoint. Its remote tools serve public references and plans; actual local project analysis stays in the local MCP server.
Gemini CLI
Gemini CLI 0.49.0 validated the extension manifest and connected to the 2.7.0 server release candidate in an isolated project. A real model-driven app-building session remains unverified.
gemini mcp add ios-agent -- npx -y ios-agent-mcp@latest
The command above connects to the published package. For bundled
GEMINI.md
guidance, the repository’s
gemini-extension.json
is an alternative; check that its pinned npm version is published before running
gemini extensions install https://github.com/Nagarjuna2997/ios-agent-skill
. Alternatively use the Claude Desktop
mcpServers
object below in Gemini CLI settings. Use one method; the web chat is a different product and does not load CLI extensions.
Official client references:
Codex MCP
,
OpenAI plugin packaging
,
ChatGPT connection/testing
,
Gemini extension format
.
Claude Code
claude mcp add ios-agent -- npx -y ios-agent-mcp
Verify:
claude mcp list
Claude Desktop
Edit
~/Library/Application Support/Claude/claude_desktop_config.json
:
{
"mcpServers": {
"ios-agent": {
"command": "npx",
"args": ["-y", "ios-agent-mcp"]
}
}
}
Restart Claude Desktop. The tools appear under the connectors icon.
Platform notes
Config file locations differ by OS. The command itself is the same everywhere.
Claude Desktop config
macOS
~/Library/Application Support/Claude/claude_desktop_config.json
Windows
%APPDATA%\Claude\claude_desktop_config.json
Linux
~/.config/Claude/claude_desktop_config.json
Windows
npx
is a shell script, not an executable, so some MCP clients cannot spawn it
directly. If the server fails to start with no error, wrap it in
cmd
:
{
"mcpServers": {
"ios-agent": {
"command": "cmd",
"args": ["/c", "npx", "-y", "ios-agent-mcp"]
}
}
}
Use forward slashes or escaped backslashes in any absolute path — raw
\
in
JSON is an escape character:
"args": ["C:/Users/you/ios-agent-skill/mcp-server/dist/unified.js"]
macOS and Linux
The plain
npx
form works. If
npx
is not on the client's
PATH
(GUI apps do
not inherit your shell profile), use an absolute path to
node
:
which node # e.g. /opt/homebrew/bin/node
{
"command": "/opt/homebrew/bin/node",
"args": ["/absolute/path/to/mcp-server/dist/unified.js"]
}
This is the single most common cause of "the server won't start" on macOS.
Avoiding a fetch on every launch
npx -y
re-resolves the package each time. Install once instead:
npm install -g ios-agent-mcp
{ "command": "ios-agent-mcp", "args": [] }
From source
git clone https://github.com/Nagarjuna2997/ios-agent-skill.git
cd ios-agent-skill/mcp-server
npm install && npm run build
Then point the client at the built entry point:
{
"mcpServers": {
"ios-agent": {
"command": "node",
"args": ["/absolute/path/to/ios-agent-skill/mcp-server/dist/unified.js"]
}
}
}
Use an
absolute
path — a relative one breaks the moment the client's working
directory differs.
Requirements
Node 20+
. Check with
node --version
.
Review/reference tools do not require Xcode. Simulator builds and tests require macOS/Xcode.
Local review/reference tools read files. Builds may fetch dependencies, and simulator previews serve on loopback.
Verifying it works
Ask the agent:
Analyze the Swift project at /path/to/MyApp
You should get a structure summary and a per-category finding table. If instead
you get "No Swift files found", the path is wrong — pass the folder containing
Package.swift
or the
.xcodeproj
, not a subfolder.
Troubleshooting
Symptom
Cause
Tools do not appear
Client not restarted, or malformed JSON in the config
"Path does not exist"
Relative path passed — use an absolute one
"No Swift files found"
Pointed at a build directory, or the wrong folder
Everything is clean and you doubt it
Run analyze_swift_project — it reports the file count it scanned
npx fetches every launch
Install globally: npm i -g ios-agent-mcp, then use ios-agent-mcp as the command
Build outputs are skipped deliberately:
.build
,
DerivedData
,
Pods
,
Carthage
,
node_modules
, and
*.xcodeproj
bundles. So is
Package.swift
—
it is build configuration, not app source.
Privacy
Review and local-reference tools read files. App creation writes a new starter; simulator tools execute Xcode and manage devices. Builds may fetch dependencies, and preview serves on loopback. The MCP client can send tool outputs to its model provider; review that client's settings.
Xcode 27
Use the
in-Xcode agent setup
. Xcode uses its own agent configuration directories. This setup is documented from Apple’s released guidance; runtime acceptance remains unverified on the current Xcode 26.6 host.
Muse Code
Muse Code joins Claude, Codex and Gemini CLI through the same unified local MCP
server. No separate server package, plugin ZIP or
MUSE.md
is needed.
Install the server outside the agent sandbox, using Node.js 20 or later:
npm install -g ios-agent-mcp@latest
Merge this into
~/.config/muse/settings.json
; preserve your other settings and
existing server entries. Do not replace the whole file.
schema_version
is required.
{
"schema_version": 1,
"mcpServers": {
"ios-agent": {
"command": "ios-agent-mcp",
"args": []
}
}
}
Copyable template
·
Settings-key verification note
·
Hook verification scope
. If Muse cannot find
the command, use the absolute executable path printed by
command -v ios-agent-mcp
.
Restart Muse after updating settings. This avoids fetching a package inside Muse's
default proxy-only sandbox. It does not disable sandboxing or grant network access.
For local instructions, use the repository's
AGENTS.md
. For skill discovery,
keep the original frontmatter-bearing
SKILL.md
under your project's
project-local .claude/skills/ios-agent-skill folder with its companion references. Do not rename the
frontmatter-stripped
AGENTS.md
to
SKILL.md
. Trust only workspaces you recognize.
Verified compatibility, 2026-09-16
Muse Code
1.3.0 (1.3.0-R3233.1)
connected to the published
ios-agent-mcp 2.7.0
using stdio and discovered
36 tools
, including reviews, local references,
create_app
and simulator tools. Initialization and tool discovery used the actual
Muse executable with its local
echo
provider. No account credentials or model
request were needed.
muse init
and discovery of the repository's Claude-format
skill were separately verified earlier.
A command-based
Stop hook executed
in an isolated test. The
optional maintainer hook template
runs
this repository's existing verification script. It is for sessions rooted in this
repository only: confirm the working directory before enabling it. Do not copy it
into an unrelated user's app or apply it globally across projects. Hook commands
execute shell code, so inspect commands before merging them into your settings.
Not verified:
model-directed tool invocation, PreToolUse/PostToolUse ports,
verification observer behavior, sandboxed simulator operations and end-to-end
app creation by Muse. Tool discovery is not evidence that a model completed an app.
No observer toggle or privacy/pricing-tier claim is supplied without verification.
Recorded verification result
.
The harness asserts four representative tools and records the complete discovered
catalog and installed server package version.
Recheck on upgrades
From this repository, with Muse and the MCP package already installed:
node scripts/verify-muse.mjs /absolute/path/to/muse /absolute/path/to/ios-agent-mcp/dist/unified.js
This uses temporary configuration, disables foreign personal context, and checks
MCP discovery plus a Stop hook without a model call. It prints versioned JSON
results and removes the temporary files. It does not edit your Muse settings.
Rerun it when either client or server changes; record model-based checks separately.
Updating npm's
@latest
does not automatically upgrade an existing global install:
rerun
npm install -g ios-agent-mcp@latest
when choosing to upgrade.
Official sources:
Meta announcement
,
configuration
, and
extensions
. The latter documentation
requires sign-in; the compatibility claims above come from executable tests.
Support scope and optional source installer
Active client families are Claude, ChatGPT/Codex, Gemini CLI and Muse. Other
standard MCP clients may work, but compatibility is not a maintained support
claim. Request another client through GitHub Issues and use 👍 reactions to
register demand; feasibility and verification also determine priorities.
The optional
install.sh
requires an explicit
--client
. It clones source skills
for Claude, Codex or Muse, and prints dedicated instructions for Gemini/ChatGPT.
It does not configure MCP. Existing installs update only when the checkout has
the expected origin, a clean worktree and the main branch.
---
# Apple Knowledge MCP
https://nagarjuna2997.github.io/ios-agent-skill/guides/mcp-knowledge-server.html
← Back to all frameworks & guides
All articles →Mcp · Reference guideApple Knowledge MCPRepository guidance for Apple Knowledge MCP. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
Context
Pattern
Anti-Patterns
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Search for the topic
↓2
Inspect reference outline
↓3
Read bounded sections
↓4
Apply source-backed guidance
02 / ArchitectureResponsibility boundariesBoundary 1
Knowledge requestBoundary 2
Local reference indexBoundary 3
Source excerptsConnected responsibilities, not a required class hierarchy or an execution trace.
Context
ios-agent-knowledge
is the public-reference companion to
ios-agent-mcp
. Both binaries ship in
ios-agent-mcp@2.4.0
. The analyzer keeps its eleven local-project tools; the knowledge server has eight separate tools and never exposes project paths over HTTP.
Pattern
Tool
Result
search_apple_technologies
Ranked matches in the 405-technology snapshot
get_apple_technology
Compact local routes by default; explicit full guide view
search_local_references
Local guides and reusable source matches without bodies
get_reference_outline
Heading offsets for focused section reads
read_local_reference
Exact source/guide content with bounded output and continuation
get_apple_updates
Search of 96 update/release-note landing pages
plan_ios_app
Implementation workflow and CLI executable/argument array
plan_app_icon
Editable foreground-layer specification and Icon Composer workflow
Start a local MCP connection:
npx -y --package=ios-agent-mcp@2.4.0 ios-agent-knowledge
For a remotely hosted ChatGPT MCP connection, run the HTTP transport behind your host’s HTTPS ingress:
npx -y --package=ios-agent-mcp@2.4.0 ios-agent-knowledge --http --host 0.0.0.0 --port 3000
The MCP route is
/mcp
; readiness is
/health
. Without
--http
, the process uses stdio. The default HTTP host is loopback. The Dockerfile at
mcp-server/Dockerfile.knowledge
builds this public-reference service from the repository root.
This service accepts only search strings, indexed reference IDs/ranges, catalog IDs and app/icon briefs. It reads a bundled allowlisted knowledge file, never arbitrary caller-supplied filesystem paths. It has no source upload, code execution, build or publishing tools, makes no runtime network requests, and does not store briefs. Configure HTTPS, request limits and operational logging on the deployment host. Account-specific or private-data tools would need a separate authentication design.
The data has explicit snapshot dates. Refresh source snapshots in the repository and rebuild before release. A successful HTTP handshake proves protocol compatibility, not that an arbitrary host or client account has been configured.
Read
docs/tooling/offline-source-library.md
for offline search, source reuse, attribution and verification limits.
Anti-Patterns
Exposing the local analyzer to the internet with arbitrary filesystem paths.
Advertising a localhost address as a published ChatGPT endpoint.
Treating
plan_ios_app
as an autonomous code generator; the coding agent performs implementation.
Describing catalog snapshots as live Apple updates.
Sources:
OpenAI MCP server guidance
,
plugin submission
. A public directory submission needs a stable HTTPS endpoint and publisher verification; a skills-only plugin bundle is also supported.
---
# Official MCP Registry publication
https://nagarjuna2997.github.io/ios-agent-skill/guides/mcp-registry.html
← Back to project internals
All articles →Mcp · Project internalsOfficial MCP Registry publicationRepository guidance for Official MCP Registry publication. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Align namespace and version
↓2
Publish tested npm artifact
↓3
Authenticate namespace ownership
↓4
Publish and verify record
02 / ArchitectureResponsibility boundariesBoundary 1
Package metadataBoundary 2
Registry publicationBoundary 3
Public registry recordConnected responsibilities, not a required class hierarchy or an execution trace.
The namespace is
io.github.Nagarjuna2997/ios-agent-skill
.
mcp-server/package.json
carries the matching
mcpName
;
mcp-server/server.json
declares the npm artifact and stdio transport. The existing version-sync script keeps package and registry versions aligned.
The
official publication flow
requires the matching npm version to be public before registry submission, plus authentication proving control of the namespace. Metadata alone does not publish a listing.
From
mcp-server
, after package checks and npm publication:
node scripts/sync-version.mjs --check
mcp-publisher login github
mcp-publisher publish
Keep authentication files outside the repository. Verify the returned registry record and version before claiming acceptance. Downstream directories have their own ingestion schedule; a successful registry publication does not establish a PulseMCP listing.
---
# MCP Tool Reference
https://nagarjuna2997.github.io/ios-agent-skill/guides/mcp-tools.html
← Back to all frameworks & guides
All articles →Mcp · Reference guideMCP Tool ReferenceRepository guidance for MCP Tool Reference. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
Choosing a tool
Severity
analyze_swift_project
review_swift_concurrency
review_swift_architecture
review_swiftui
check_availability_guards
audit_app_store_readiness
review_swift_memory
review_swift_security
review_swift_testing
review_swift_performance
lint_skill
Structured output
Limits
review_app_intents
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Choose review scope
↓2
Run the relevant analyzer
↓3
Interpret severity and location
↓4
Validate the proposed change
02 / ArchitectureResponsibility boundariesBoundary 1
Project inputBoundary 2
Static review rulesBoundary 3
Structured findingsConnected responsibilities, not a required class hierarchy or an execution trace.
Load this when:
choosing which
ios-agent-mcp
tool to call, or interpreting
a finding it returned.
Every tool takes one argument — an
absolute
path to the project root:
{ "path": "/Users/you/Projects/MyApp" }
Ten of the eleven analyze Swift source.
lint_skill
is the exception: it reads a
skill repository's own metadata, so its path is the folder containing
SKILL.md
.
Choosing a tool
You want to…
Tool
Understand an unfamiliar codebase
analyze_swift_project
Diagnose a data race or migrate to Swift 6
review_swift_concurrency
Find out why a screen can't be previewed
review_swift_architecture
Review SwiftUI views and state
review_swiftui
Check before shipping, or after an SDK bump
check_availability_guards
Prepare an App Store submission
audit_app_store_readiness
Memory grows, or deinit never runs
review_swift_memory
Handle credentials, or run a security pass
review_swift_security
Tests are flaky, or a green suite looks too easy
review_swift_testing
Scrolling stutters or launch is slow
review_swift_performance
Author or review an Agent Skill, or work out why a subagent never gets invoked
lint_skill
Start with
analyze_swift_project
— it reports counts per category and names
the tool to run for each, so you do not run all ten blindly.
lint_skill
sits
outside that flow; it inspects skill metadata, not Swift.
Severity
Meaning
Act
🔴 blocker
Crash, data race, or App Review rejection
Before shipping
🟠 serious
Real defect — untestable code, accessibility failure, deprecated API
This sprint
🟡 minor
Maintainability and consistency
When touching the file
Findings are sorted most severe first.
analyze_swift_project
Structure — Swift file count, line count, deployment target, Swift tools
version, frameworks in use, whether tests exist — plus a finding count for every
category with the tool that explains it.
It also reports the project's
shape
: UI framework, inferred architecture,
third-party dependencies, and whether dependency injection is in use.
The architecture read always ships its
evidence
. "MVVM" alone is a guess
presented as a fact; "MVVM — 12 ViewModel types, Views/ and ViewModels/
directories" is a claim you can check. When the signals are weak it says
not determined
rather than picking the most popular answer.
Dependency injection is detected from
any Protocol
parameters in initializers,
not from a directory named
DI/
— a project that only ever constructs concrete
types has no seam, whatever it calls itself.
review_swift_concurrency
Rule
Severity
observable-without-mainactor
🔴
type-named-task
🔴
task-detached
🟠
dispatchqueue-main-async
🟠
unchecked-sendable
🟠
task-in-onappear
🟠
empty-catch
🟠
redundant-mainactor-run
🟡
nonisolated-unsafe
🟡
observable-not-final
🟡
Background:
../swift/swift-concurrency.md
,
../../patterns/mvvm.md
.
review_swift_architecture
Rule
Severity
live-default-dependency
🔴
domain-imports-ui
🔴
presentation-names-data-type
🟠
singleton-in-viewmodel
🟠
nested-navigation-stack
🟠
deprecated-navigationview
🟠
Background:
../../patterns/clean-architecture.md
.
review_swiftui
Rule
Severity
environmentobject
🟠
force-try
🟠
fixed-font-size
🟠
any-view, fixed-height, literal-spacing, deprecated-corner-radius, material-possibly-on-solid, view-state-on-model, legacy-observableobject
🟡
Only runs on files that
import SwiftUI
.
Background:
../swiftui/state-and-data-flow.md
,
../design/design-tokens.md
.
check_availability_guards
Rule
Severity
missing-availability-guard
🔴
missing-runtime-model-check
🔴
over-restrictive-guard
🟠
over-restrictive-guard
is the one worth understanding. Guarding an
iOS 26
API at
#available(iOS 27, *)
compiles, ships, and silently sends every iOS 26
device down your fallback path. It is invisible when testing on a current
device, which is why a rule catches it.
missing-runtime-model-check
covers the other half: an
@available
guard proves
the
symbol
exists; it does not prove Foundation Models is usable on this
device, in this region, with Apple Intelligence enabled.
Background:
../compatibility-matrix.md
,
../frameworks/foundation-models.md
.
audit_app_store_readiness
Rule
Severity
missing-purpose-string
🔴
missing-privacy-manifest
🔴
unlabeled-icon-button
🟠
hardcoded-string, print-logging
🟡
missing-privacy-manifest
only fires on
apps
(an Info.plist or
.xcodeproj
is present). A library is never submitted to App Review.
Background:
../../checklists/app-store-submission.md
,
../frameworks/accessibility.md
.
review_swift_memory
Rule
Severity
timer-retain-cycle, notification-observer-retain, sink-retain-cycle
🟠
strong-delegate, stored-closure-captures-self, long-lived-task-captures-self
🟠
unowned-self
🟠
Deliberately narrow. A closure capturing
self
is
not
a leak — most closures
are consumed immediately. A leak needs the closure to be
stored
by something
the object itself owns, so these rules fire on the specific storing APIs rather
than on
self.
inside any closure, which would bury the real findings.
unowned-self
is a crash rather than a leak: unlike
weak
it does not nil out,
so an escaping closure running after deallocation traps instead of no-oping.
review_swift_security
Rule
Severity
hardcoded-secret, secret-in-userdefaults, ats-disabled
🔴
tls-validation-bypassed, keychain-always-accessible
🔴
cleartext-http, weak-hash, non-cryptographic-randomness, secret-logged
🟠
javascript-string-interpolation
🟠
keychain-migrates-to-new-device
🟡
hardcoded-secret
skips the placeholders people legitimately commit
(
YOUR_API_KEY
,
<your-key>
,
changeme
) and values under 8 characters —
flagging those trains readers to ignore the rule, which is worse than not having
it.
cleartext-http
allows
localhost
and loopback.
review_swift_testing
The inverse of every other analyzer: it runs
only
on test files.
Rule
Severity
await-inside-xctassert
🔴
test-sleeps, test-without-assertion, network-in-test, no-tests
🟠
force-try-in-test, shared-mutable-test-state, long-test-timeout
🟡
assert-true-on-equality, sparse-tests
🟡
A flaky or vacuous test is worse than a missing one: it costs the same to run
and reports success either way.
test-without-assertion
catches the case that
passes as long as nothing throws;
await-inside-xctassert
catches code that
does not compile at all, because
XCTAssert
takes an autoclosure.
review_swift_performance
Rule
Severity
blocking-io-in-body
🔴
formatter-allocated-in-body, collection-work-in-body, foreach-over-indices
🟠
eager-stack-in-scrollview, image-decode-in-body
🟠
asyncimage-without-frame, geometryreader-wraps-body, formatter-allocated-repeatedly
🟡
The unifying rule:
body
runs many times per second, on the main actor, for
reasons you do not control. Anything expensive inside it is multiplied by a
number nobody measured. Rules that only matter on that path are scoped to the
var body: some View
block and are silent elsewhere — the same
DateFormatter()
is 🟠 inside
body
and 🟡 outside it.
lint_skill
The one tool that does not read Swift. It checks whether an Agent Skill
repository is
well-formed and internally consistent
— the class of defect
that produces no error anywhere, just an instruction nobody follows.
Rule
Severity
skill-file-missing, skill-missing-frontmatter, skill-frontmatter-missing-key
🔴
agent-missing-frontmatter
🔴
agent-read-only-holds-write-tool
🔴
skill-version-not-semver, skill-name-not-kebab-case, skill-description-too-long
🟠
skill-unknown-tool, agent-unknown-tool
🟠
agent-name-filename-mismatch, agent-missing-name, agent-missing-description, agent-missing-tools, agent-name-not-kebab-case
🟠
mirror-out-of-sync, broken-doc-reference
🟠
skill-description-too-short, agent-description-lacks-trigger
🟡
Three of these are worth calling out, because each fails
silently
:
agent-read-only-holds-write-tool
— a subagent whose description promises
it is read-only while its frontmatter grants
Edit
or
Write
. The main agent
delegates on the strength of that promise. A reviewer that can edit will fix
what it was meant to report, and the separation of duties the review depends
on is gone with nothing in the output to show it.
agent-name-filename-mismatch
— rename the file, forget the frontmatter,
and every delegation prompt references an identifier the loader never
registered.
*-unknown-tool
— a misspelled tool name is not granted
and not
reported
. The agent's prompt assumes a capability it does not have, and only
finds out at the moment it tries to use it.
Mirror checking is self-calibrating.
Files like
CLAUDE.md
and
AGENTS.md
are compared against
SKILL.md
's body only when at least one of them already
matches byte-for-byte. That proves the repository generates its mirrors; without
it, a project with a hand-written
CLAUDE.md
would be told all supported mirrors had
drifted. When no mirror matches, the check is skipped and the report says so.
The report opens with what it inspected — SKILL.md found or not, agents counted,
mirrors compared, references resolved — because a clean report that never states
its scope is indistinguishable from a check that never ran.
Background:
../orchestration/subagents.md
,
../orchestration/verification.md
.
Structured output
Every review tool returns
both
halves of a result:
content
— markdown, for a human reading the transcript.
structuredContent
— typed data, declared via
outputSchema
, for a workflow
that must branch on the result without regexing prose.
{
"summary": "Security Review: 1 blocker, 2 serious across 34 files.",
"score": 91,
"counts": { "blocker": 1, "serious": 2, "minor": 0, "total": 3 },
"files_checked": 34,
"issues": [ { "file": "…", "line": 12, "severity": "blocker", "rule": "…", "fix": "…" } ],
"suggestions": [ "hardcoded-secret: Move it server-side…" ]
}
About score.
It is a defect
density
, computed as
100 × (1 − penalty / capacity)
where
penalty = 10·blockers + 3·serious +
1·minor
and
capacity = files × 10
, clamped to 0–100. The formula is fixed and
published so the number is reproducible rather than a vibe.
It is
not comparable between projects
— a UI-heavy app and a networking
library have different rule surfaces. Use it as a direction of travel for one
codebase, and use
counts
for anything that matters.
suggestions
deduplicates by rule rather than restating every issue's
fix
:
forty literal-spacing findings produce one instruction, not forty.
Limits
Static analysis.
It reads source; it does not build, run, or type-check.
A clean report is not a passing build — run
swift build
and
swift test
.
lint_skill checks form, not content.
It can prove a subagent is declared
correctly and that its tool grant matches its stated contract. It cannot judge
whether the instructions in it are any good.
Heuristics on paths.
Layer rules infer the presentation and domain layers
from directory names (
Views/
,
Presentation/
,
Domain/
). An unconventional
layout produces fewer architecture findings, not wrong ones.
Exemptions are deliberate.
Test, mock, stub, fake, and preview files skip
the app-code-only rules;
Package.swift
is skipped entirely. Doubling the noise
would halve the chance anyone reads the output.
Caps.
2000 files and 512 KB per file, so a huge monorepo returns something
rather than hanging.
review_app_intents
Read-only project review. Inputs: absolute
path
, optional booleans
appleIntelligence
and
onscreenContent
(both default false). Returns the same structured findings as other reviewers. SiriKit migration, missing schema and missing onscreen association are minor advisories with scope limitations; no blanket SiriKit deprecation is asserted. See
rule semantics
.
---
# vNext MCP Analysis Tools
https://nagarjuna2997.github.io/ios-agent-skill/guides/mcp-vnext-analysis-tools.html
← Back to project internals
All articles →Mcp · Project internalsvNext MCP Analysis ToolsRepository guidance for vNext MCP Analysis Tools. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
Context
Implemented in the 2.6.0 source
Tool Plan
Output Contract
Rule Design
Static vs. Runtime Boundary
Anti-Patterns
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Define a supported rule
↓2
Specify evidence and limits
↓3
Implement focused checks
↓4
Verify false-positive cases
02 / ArchitectureResponsibility boundariesBoundary 1
Rule contractBoundary 2
Analyzer implementationBoundary 3
Finding schemaConnected responsibilities, not a required class hierarchy or an execution trace.
Context
Use this as the contract for expanding
ios-agent-mcp
beyond the current Swift engineering analyzers. These tools remain static, read-only project reviewers. Runtime behavior belongs in
ios-simulator-mcp
.
Implemented in the 2.6.0 source
review_app_intents
: SiriKit migration advice plus opt-in schema and onscreen entity association checks. See
integration contract
. Parameter-summary/localization/identifier analysis remains future work.
Tool Plan
Tool
Focus
Typical signals
review_ui_ux
Visual structure, native conventions, state coverage
hardcoded spacing, fixed fonts, missing loading/empty/error states, inconsistent radii, excessive GeometryReader
review_motion
SwiftUI animation correctness
broad .animation, missing value dependency, infinite animation, layout-heavy animation, Reduce Motion gaps
review_accessibility
Semantic and inclusive design
missing labels, small tap targets, fixed sizes, contrast-risk colors, ignored Dynamic Type
review_haptics
Meaningful feedback
haptics fired on appear, duplicated feedback, no reduced-sensory fallback, heavy impact for minor state
review_realitykit
3D scene safety and native fit
unbounded asset loads, missing collision/input components, ARKit without permission copy, no fallback
review_metal
GPU rendering safety
per-frame allocation, missing drawable guard, unsafe buffer sizing, no pixel-format/depth consistency
review_webkit
Web/native interop safety
untyped JS bridge, broad navigation, injected secrets, WKWebView used where native controls fit better
review_foundation_models
Foundation Models usage
missing availability gates, no graceful fallback, unsafe prompt logging, absent evaluation path
review_core_ai
Core AI model integration
model lifecycle, privacy boundaries, device capability checks, background work isolation
|
review_ai_security
| AI privacy and misuse resistance | prompt injection surfaces, secret leakage, unbounded tool calls, unsafe retrieval context |
|
review_ai_evaluations
| Evaluation coverage | no datasets, no code-based evaluators, no regression gate, no failure taxonomy |
|
review_networking
| Network correctness and resilience | unbounded retries, no cancellation, live API defaults in previews/tests, missing offline state |
|
review_persistence
| Data and storage safety | UserDefaults secrets, main-actor I/O, missing migrations, model objects crossing actor boundaries |
|
review_storekit
| Purchase readiness | missing restore path, unverified transactions, no pending/refund handling, live StoreKit in tests |
|
review_permissions
| Permission and entitlement correctness | missing purpose strings, overbroad entitlements, no denied/restricted state, privacy manifest gaps |
Output Contract
Every new review tool should match the existing analyzer shape:
{
"summary": "Motion review found 3 issues.",
"score": 82,
"counts": {
"critical": 0,
"high": 1,
"medium": 2,
"low": 0
},
"files_checked": 18,
"issues": [
{
"rule": "animation-without-reduced-motion",
"severity": "high",
"file": "Sources/App/HomeView.swift",
"line": 42,
"message": "Animated transition has no Reduce Motion alternative.",
"why": "Users who disable motion can still receive large movement.",
"fix": "Read accessibilityReduceMotion and switch to opacity or no animation."
}
],
"suggestions": [
"Centralize animation tokens for spring duration and response."
]
}
Rule Design
Prefer high-signal rules over style opinions:
Flag literal spacing only when repeated enough to indicate no token system, or when it creates inconsistent layout.
Flag fixed font sizes when used in user-facing text without a Dynamic Type path.
Flag animation problems when they can cause incorrect behavior, inaccessible motion, or performance cost.
Flag AI issues when they expose privacy, availability, evaluation, or tool-call safety risk.
Do not score "premium design" from static code alone. Leave aesthetic confirmation to the Visual Iteration Loop.
Static vs. Runtime Boundary
Static analyzer can say
Runtime loop must prove
A view uses fixed font sizes
Text actually fits at accessibility sizes
A button appears to lack a label
VoiceOver announces the correct label and trait
A Metal renderer allocates per frame
GPU frame pacing is stable on the simulator/device
A splash view ignores Reduce Motion
The captured intro avoids large motion when Reduce Motion is enabled
A Foundation Models flow lacks evaluations
The model meets pass/fail thresholds on a dataset
Anti-Patterns
// WRONG: add a review tool whose finding is only "looks bad".
Why: static analyzers need reproducible evidence.
// RIGHT: detect concrete code patterns, then route aesthetic verification to screenshots.
// WRONG: call App Intents or model providers during static review.
Why: ios-agent-mcp is read-only and network-free.
// RIGHT: inspect declarations, availability gates, privacy handling, and test/evaluation files.
// WRONG: create separate output shapes per tool.
Why: clients cannot branch reliably.
// RIGHT: reuse summary, score, counts, files_checked, issues, and suggestions.
---
# Dynamic Workflows and Large-Scale Jobs
https://nagarjuna2997.github.io/ios-agent-skill/guides/orchestration-dynamic-workflows.html
← Back to project internals
All articles →Orchestration · Project internalsDynamic Workflows and Large-Scale JobsRepository guidance for Dynamic Workflows and Large-Scale Jobs. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
1. The scale ladder
2. /batch — many isolated changes
Making a batch succeed
Worktrees
3. Dynamic workflows — orchestration in code
When not to script it
4. Failure handling at scale
5. Cost
6. Anti-patterns
Checklist before scaling out
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Partition independent work
↓2
Assign bounded tasks
↓3
Collect results and failures
↓4
Verify the integrated outcome
02 / ArchitectureResponsibility boundariesBoundary 1
CoordinatorBoundary 2
Isolated workersBoundary 3
Integration checksConnected responsibilities, not a required class hierarchy or an execution trace.
Load this when:
the work is too large for a handful of delegated tasks —
a codebase-wide migration, a rule applied across dozens of modules, or a batch of
independent changes that each want their own PR.
There is a scale at which conversational delegation stops working. Somewhere
around ten parallel units, the main agent's context becomes the bottleneck: it
is spending more effort tracking who is doing what than doing anything. Past
that point, the orchestration belongs in a script.
1. The scale ladder
Pick the cheapest rung that fits. Climbing early costs more than it saves.
Scale
Approach
Coordination lives in
1–2 files
Do it inline
Your own context
3–8 units, related
Delegate to subagents in one session
The main agent
Repeated until a condition
A loop, optionally with a verifier
The loop contract
5–30 isolated changes, each wanting its own PR
/batch
The batch tooling
Dozens of units, custom logic, non-linear
A dynamic workflow (script + SDK)
Your script
Two questions decide it:
Are the units independent?
If unit 7 needs unit 3's result, it is not a
batch — it is a pipeline, and the dependency has to be encoded somewhere.
Does each unit want its own review boundary?
Thirty commits on one branch
is unreviewable; thirty PRs is thirty reviews someone can actually do.
2.
/batch
— many isolated changes
/batch
is a packaged use of subagents plus
git worktrees
, aimed at roughly
5–30 isolated changes that each become their own PR. Each unit gets a fresh
subagent working in its own worktree, so the changes cannot collide on disk.
Good fits:
Apply one mechanical rule across many modules — "add
@MainActor
to every
view model", "replace literal spacing with
Space.*
tokens".
Fix the same class of bug in many independent places.
Bump a dependency across several packages.
Migrate N files to a new API where each file stands alone.
Bad fits:
Units that share files.
Two workers editing
AppDelegate.swift
will
produce conflicting PRs.
Units with ordering constraints.
Batch has no dependency graph.
Exploratory work.
Batch executes a known change; it does not decide what
the change should be. Plan first (
ios-plan
), then batch.
One large refactor.
That is one PR, not thirty.
Making a batch succeed
The per-unit prompt must be
complete and self-contained
— each worker starts
cold and cannot see the others.
For <module>:
goal: every @Observable type the UI renders is @MainActor final class
scope: only files under <module>/ — do not touch shared/ or Package.swift
rules: behavior must not change; add nothing but the isolation annotations
verify: swift build && swift test --filter <module>
report: the diff, plus the real output of the verify command
if the module has no such types: report "no change needed" and stop
That last line matters. Without an explicit no-op path, workers invent work to
justify their existence.
Worktrees
Each unit gets its own working copy, so parallel writes are safe:
git worktree add ../wt-cart -b batch/cart-mainactor
git worktree add ../wt-orders -b batch/orders-mainactor
# … worker per worktree …
git worktree remove ../wt-cart
Isolation is the whole point. Without it, parallel writers corrupt each other's
work in ways that are extremely hard to diagnose after the fact.
3. Dynamic workflows — orchestration in code
When the job needs logic that does not fit a fixed batch — conditional
branching, fan-out that depends on discovered results, retries with different
strategies, a dependency graph — move the orchestration into a script that
drives many subagents through the Agent SDK.
The shape:
discover -> a read-only pass produces the work list
plan -> group into independent units; identify ordering constraints
fan out -> one worker per unit, in parallel, each isolated
verify -> an independent verifier per unit
gather -> collect verdicts; retry, escalate, or report
# orchestrate.py — illustrative shape, not a copy-paste script.
# The point is the control flow, not the API surface.
units = discover_units() # e.g. every module with a view model
results = []
for unit in units: # parallelize as your runner allows
worker_report = run_agent(
agent="swift-refactorer",
prompt=build_unit_prompt(unit), # complete and self-contained
worktree=make_worktree(unit),
)
review = run_agent(
agent="swift-reviewer", # cold, read-only, no stake
prompt=f"Verify this change in {unit.path}. Run the build and tests "
f"and return their real output.\n\n{worker_report.diff}",
worktree=worker_report.worktree,
)
results.append((unit, worker_report, review))
report(results) # verdicts + evidence, not narrative
Three properties make this work, and all three are easy to get wrong:
Each unit is self-contained.
Workers start cold. Everything they need is
in the prompt.
The verifier is separate from the worker.
Same rule as everywhere else in
verification.md
— the thing declaring success is not the thing that wants
success.
The script owns the state.
Not the model's memory. The script knows what
ran, what passed, and what to retry.
When
not
to script it
Scripted orchestration is a real system with real maintenance cost. If the job
runs once and has under ten units, a session with subagents is cheaper and
easier to steer. Write the script when the workflow recurs, or when the unit
count makes conversational tracking unreliable.
4. Failure handling at scale
At thirty units, some will fail. Decide the policy up front:
Policy
Behavior
Use when
Fail fast
Stop the whole run on first failure
Units share risk; a failure implies a bad plan
Isolate and continue
Mark it failed, keep going, report at the end
Units are genuinely independent
Retry once, then isolate
One retry with the failure fed back
Flaky infra, transient toolchain issues
Default to
isolate and continue
for independent units, with a summary at the
end that lists every failure. Twenty-eight successes and two clearly-reported
failures is a good run. Twenty-eight successes and two silently-skipped units is
a bad one that looks identical from the outside — which is exactly why the final
report must enumerate failures explicitly.
Never let a unit "succeed" by doing nothing.
A worker that finds no work
should say "no change needed" — that is a distinct outcome from "changed and
verified", and collapsing the two hides coverage gaps.
5. Cost
Every subagent starts cold, which means re-establishing context it does not
inherit. Thirty subagents that each read the same five files pay that cost thirty
times.
Reduce it by:
Putting shared context in the prompt
rather than making each worker
rediscover it. One
ios-explore
pass up front, its findings pasted into every
unit prompt, beats thirty independent explorations.
Using cheaper models for mechanical units.
sonnet
for a token
replacement; reserve
inherit
/
opus
for planning and review.
Scoping tightly.
A worker told exactly which files to touch does not spend
its budget searching.
Not delegating what is inline-sized.
The most common waste is spawning an
agent for a two-call task.
6. Anti-patterns
# 1. Batching units that share files.
-> Conflicting PRs, and neither worker knows.
# 2. Batching work with ordering constraints.
-> Batch has no dependency graph. Sequence it, or encode the order in a script.
# 3. Batching exploratory work.
-> Plan first. Batch executes a decided change.
# 4. Scripting a one-off ten-unit job.
-> A session with subagents is cheaper and easier to redirect.
# 5. Workers that grade themselves at scale.
-> Thirty self-assessed successes is thirty unverified claims.
# 6. No explicit no-op path.
-> Workers invent work rather than report "nothing to do".
# 7. Silently dropping failed units.
-> Enumerate every failure in the final report.
# 8. Thirty cold agents rediscovering the same context.
-> One discovery pass, results pasted into each prompt.
# 9. Parallel writers without worktrees.
-> Corrupted working tree, unattributable damage.
Checklist before scaling out
[ ] The units are genuinely independent — no shared files, no ordering.
[ ] Each unit prompt is complete and self-contained (workers start cold).
[ ] Each unit has an explicit no-op path.
[ ] Each unit has a
verify
command and returns its real output.
[ ] Parallel writers are isolated in worktrees.
[ ] The verifier is separate from the worker.
[ ] A failure policy is chosen: fail fast, isolate, or retry-then-isolate.
[ ] The final report enumerates failures, not just successes.
[ ] Shared context was discovered once, not thirty times.
---
# Hooks — Deterministic Enforcement
https://nagarjuna2997.github.io/ios-agent-skill/guides/orchestration-hooks.html
← Back to all frameworks & guides
All articles →Orchestration · Reference guideHooks — Deterministic EnforcementRepository guidance for Hooks — Deterministic Enforcement. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
1. Hook vs. CI vs. reviewer
2. Events
3. The exit-code contract
4. Configuration
5. This repository's hooks
6. Hooks for iOS projects
7. Design rules
8. Anti-patterns
Checklist for a new hook
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Select a lifecycle event
↓2
Inspect tool input safely
↓3
Run bounded checks
↓4
Return the documented exit status
02 / ArchitectureResponsibility boundariesBoundary 1
Client eventBoundary 2
Hook scriptBoundary 3
Allow or block resultConnected responsibilities, not a required class hierarchy or an execution trace.
Load this when:
a rule must hold every time rather than usually, you are
repeating the same correction to an agent, or you are deciding between a hook,
a CI check, and a reviewer subagent.
A hook is a shell command that runs automatically at a fixed lifecycle point.
It is not a suggestion the model weighs — it executes, and its exit code decides
what happens next. That makes it the correct home for anything a script can
decide.
1. Hook vs. CI vs. reviewer
Three enforcement layers. Use the cheapest one that can decide the question.
Layer
Decides
Cost
Feedback speed
Hook
Rules a script can evaluate
~free
Immediate — model self-corrects mid-turn
CI
Same, plus full build/test
minutes
After push
Reviewer subagent
Judgment: is this correct, does it match intent
tokens
End of task
The ordering matters. Spending a reviewer subagent on "did you use
DispatchQueue.main.async
" is waste — a grep answers that for free, instantly,
and cannot be argued with. Reserve model judgment for what rules cannot express.
Rule of thumb:
if you can write the check as a grep or an exit code, it is a
hook. If it needs to understand intent, it is a reviewer.
2. Events
Event
Fires
Typical use
PreToolUse
Before a tool runs
Block edits to generated or protected files
PostToolUse
After a tool succeeds
Format, lint, check the file just written
UserPromptSubmit
On each user message
Inject project context
Stop
Before the turn ends
Build/test verification
SubagentStop
When a subagent finishes
Validate a subagent's output
SessionStart
Session begins
Environment setup, load state
SessionEnd
Session ends
Cleanup
PreCompact
Before context compaction
Persist state that must survive
PreToolUse
,
PostToolUse
, and
Stop
cover almost every practical need.
3. The exit-code contract
This is the whole interface:
Exit
Meaning
0
Pass. stdout goes to the transcript.
2
Block. stderr is fed back to the model as the reason.
other
Non-blocking error, surfaced to the user.
Exit 2 is what makes hooks valuable: the model reads the failure and fixes it
without the user having to notice, let alone intervene.
# The message on stderr IS the instruction the model acts on.
# Say what is wrong AND what to do instead.
echo "BLOCKED: CLAUDE.md is generated from SKILL.md." >&2
echo "Edit SKILL.md, then run ./scripts/sync-mirrors.sh" >&2
exit 2
A blocking message that only says "not allowed" wastes the round trip. Every
exit-2 message names the fix.
Hooks receive JSON on stdin —
tool_name
,
tool_input
,
cwd
,
session_id
,
and for
PostToolUse
also
tool_response
. Parse it rather than guessing:
INPUT="$(cat)"
FILE_PATH="$(printf '%s' "$INPUT" | python3 -c '
import json, sys
print(json.load(sys.stdin).get("tool_input", {}).get("file_path", ""))
')"
4. Configuration
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write|MultiEdit",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/swift-format.sh",
"timeout": 30
}
]
}
]
}
}
matcher
is a regex over the tool name. Omit it to match every invocation of
the event.
Always use
$CLAUDE_PROJECT_DIR
for paths — a relative path breaks the moment
the working directory differs.
Set a
timeout
. A hook that hangs blocks the session.
5. This repository's hooks
Wired in
.claude/settings.json
, implemented in
scripts/hooks/
:
Hook
Event
Effect
guard-generated-files.sh
PreToolUse
Denies edits to the 24 generated mirror files, points at SKILL.md
sync-mirrors-on-edit.sh
PostToolUse
Regenerates all mirrors whenever SKILL.md changes
verify-repo.sh
Stop
Runs the CI checks — mirror sync, frontmatter, doc references, subagent frontmatter
Together these make an entire class of mistake impossible rather than merely
discouraged: a hand-edited mirror is blocked, a forgotten sync is automatic, and
a broken doc reference cannot survive the end of a turn.
6. Hooks for iOS projects
Drop-in templates live in
templates/hooks/
:
Hook
Event
Effect
swift-format.sh
PostToolUse
SwiftFormat + SwiftLint autocorrect on the edited file
forbid-antipatterns.sh
PostToolUse
Blocks SKILL.md's banned patterns with line numbers and the fix
build-check.sh
Stop
Builds (and tests) before the turn ends
forbid-antipatterns.sh
catches, among others:
DispatchQueue.main.async
,
Task.detached
,
@Observable
without
@MainActor
, empty
catch
,
try!
,
NavigationView
,
AnyView
, fixed font sizes, live-implementation default
arguments,
print()
, and a type named
Task
. Test and mock files are exempt
from the app-code-only rules.
Installation is in
templates/hooks/README.md
.
7. Design rules
Fail fast, and say what to do.
A hook that blocks without naming the fix
costs a round trip and teaches nothing.
Be conservative about blocking.
A false positive on exit 2 stops legitimate
work. When a rule has real exceptions, either exempt them by path (as
forbid-antipatterns.sh
does for test and mock files) or downgrade to a warning
on exit 0.
Keep them fast.
PostToolUse
runs on every edit. Check the single file that
changed, never the whole tree.
Degrade gracefully.
A missing formatter or absent toolchain is not a failure
— exit 0 and say the check was skipped.
build-check.sh
prints
UNVERIFIED
rather than implying a build that never ran, which keeps it honest under the
evidence contract in
verification.md
.
Never let a hook fabricate a pass.
A check that cannot run reports that it
could not run. Silence reads as success and is the one failure mode that makes
the whole layer untrustworthy.
8. Anti-patterns
# 1. Using a reviewer subagent for what a grep decides.
-> Tokens spent on something free and deterministic.
# 2. Blocking without naming the fix.
echo "Not allowed" >&2; exit 2
-> The model does not know what to do next.
# 3. A PostToolUse hook that scans the whole repo.
-> Runs on every edit. Check only the changed file.
# 4. Treating a missing tool as a failure.
swiftformat ... || exit 2
-> The project may not use it. Exit 0 and skip.
# 5. A hook with no timeout.
-> One hang blocks the session.
# 6. Relative paths in the command.
"command": "./hooks/check.sh"
-> Breaks whenever cwd differs. Use $CLAUDE_PROJECT_DIR.
# 7. A check that silently passes when it could not run.
-> Reports success for work nobody verified.
# 8. Enforcing style opinions with exit 2.
-> Reserve blocking for correctness. Formatting is auto-fixed, not blocked.
Checklist for a new hook
[ ] The rule is genuinely deterministic — no judgment required.
[ ] It checks only what changed, not the whole tree.
[ ] Exit 2 messages name both the problem and the fix.
[ ] Legitimate exceptions are exempted by path, not by weakening the rule.
[ ] A missing tool or toolchain exits 0 and says the check was skipped.
[ ] A
timeout
is set.
[ ] The command uses
$CLAUDE_PROJECT_DIR
.
[ ] It has been run against both a passing and a failing input.
---
# Loops
https://nagarjuna2997.github.io/ios-agent-skill/guides/orchestration-looping.html
← Back to all frameworks & guides
All articles →Orchestration · Reference guideLoopsRepository guidance for Loops. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
1. The four loop patterns
2. Every loop declares four things
3. Goal-based loops
Detecting a stall
Never fake termination
4. Turn-based loops
5. Time-based loops
6. Proactive loops
7. Loops and subagents
8. Anti-patterns
Checklist before starting a loop
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Declare a goal and budget
↓2
Run one bounded attempt
↓3
Evaluate evidence
↓4
Stop or retry within limits
02 / ArchitectureResponsibility boundariesBoundary 1
Loop controllerBoundary 2
Work attemptBoundary 3
Completion predicateConnected responsibilities, not a required class hierarchy or an execution trace.
Load this when:
work must repeat until some condition holds — a failing
suite driven to green, a migration applied file by file, a CI run watched, a
recurring check — or when you are about to write "keep going until…".
A loop is a repeated cycle of work that continues until a
stop condition
is
met. The stop condition is the entire design. A loop without one is not a loop,
it is a runaway process.
1. The four loop patterns
Pattern
Stops when
Use for
Turn-based
A fixed number of iterations completes
Bounded batch work: 12 files to migrate
Goal-based
A measurable condition becomes true
"Until swift test is green"
Time-based
A schedule fires, or a deadline passes
Recurring checks, watching CI
Proactive
An external event arrives
Reacting to a webhook, a PR comment, a failure
Most real work is
goal-based with a turn cap
— repeat until the goal is met,
but never more than N times, so a goal that turns out to be unreachable
terminates instead of burning tokens forever.
2. Every loop declares four things
Before the first iteration, state these. If you cannot fill all four in, you do
not yet have a loop you should run.
GOAL: swift test exits 0 with no skipped tests
CHECK: $ swift test 2>&1 | tail -20 (the exact command, run every iteration)
MAX: 6 iterations
ON-STALL: if two consecutive iterations produce the same failure, stop and report
GOAL
— an outcome, not an activity. "Tests pass" is a goal. "Work on the
tests" is not.
CHECK
— a real command whose output decides whether to continue. Not your
own judgment. If the check is "does it look right", the loop cannot terminate
reliably.
MAX
— a hard iteration cap. Always.
ON-STALL
— what counts as no progress, and what to do about it.
3. Goal-based loops
The common case. Structure:
1. Run CHECK. Capture the output.
2. If the goal is met -> stop, report the passing output.
3. If MAX iterations reached -> stop, report the last failure and what you tried.
4. If the failure is identical to the previous iteration -> stop (stalled).
5. Otherwise: fix ONE thing, then go to 1.
Step 5 is where loops go wrong. Fixing several things per iteration means that
when the check still fails you cannot tell which change helped, which regressed,
and which did nothing.
Detecting a stall
Three signals that a loop should stop even though the goal is unmet:
Identical failure twice.
Your change did not affect the failure. Continuing
will not help.
Oscillation.
Failure A → fix → failure B → fix → failure A. You are
trading one problem for another; the design is wrong, not the code.
Growing blast radius.
Each iteration touches more files than the last. You
are chasing symptoms.
On any of these: stop and report. A loop that halts with "I could not get past
this, here is the failure and the three approaches I tried" is a good outcome. A
loop that runs twenty iterations and reports success is usually lying.
Never fake termination
# WRONG — these make the check pass without meeting the goal.
- Deleting or skipping the failing test
- Widening a catch until the error disappears
- Adding a sleep until a race stops reproducing
- Loosening an assertion to match wrong output
- Force-unwrapping to get past a compile error
# RIGHT
Stop. Report the failure, the root cause if you found it, and what you tried.
If the only way to satisfy the stop condition is to weaken the check, the loop
has failed and must say so.
4. Turn-based loops
Bounded, known work. The cap
is
the stop condition.
GOAL: all 14 view models in Features/ are @MainActor-isolated
CHECK: $ grep -rLn "@MainActor" --include="*ViewModel.swift" Features/
MAX: 14 (one per file)
Work one unit per iteration and verify each before moving on. Batching all
fourteen and verifying once at the end means a single failure invalidates the
whole pass with no way to tell which file caused it.
5. Time-based loops
Two distinct cases, and the difference matters:
Fixed schedule
— a recurring task. Use a scheduled trigger rather than a
sleeping session: "check the release branch for new failures every weekday at
09:00".
Self-paced polling
— waiting for something to change. Choose the interval
from how fast the thing actually changes:
Waiting on
Interval
A CI run that takes ~8 minutes
one check at ~8 min, not eight at 1 min
A deploy
matched to the deploy's typical duration
Nothing specific (idle tick)
20–30 minutes
A signal that will notify you anyway
a long fallback (20 min+), not polling
Never poll with sleep for something the harness will wake you for.
If
work is tracked and will notify you on completion, waiting in a sleep loop is
pure waste. Use a long fallback so the loop survives a hang, and let the
notification do the work.
6. Proactive loops
Driven by external events rather than your own schedule: a PR comment arrives, a
CI job fails, a webhook fires.
Rules:
Every event gets a visible outcome.
Either an action taken, or a stated
reason for not acting. Silently dropping an event is how a "watched" PR sits
broken for a day.
Deduplicate.
The same event can arrive twice. So can an echo of your own
action. Neither is a new request.
Do not narrate every round.
Report when a round resolves the task, hits a
blocker, or raises a question.
The loop ends when the underlying thing is done
— the PR merges, the job
goes green — not when you run out of events.
7. Loops and subagents
Two ways to combine them, with different properties:
Loop inside one agent
— the agent iterates itself. Cheaper, keeps context
across iterations, but the agent is grading its own work at every step.
Loop with a verifier
— the worker fixes, a fresh
swift-reviewer
subagent
checks, the loop continues on the reviewer's verdict. More expensive, and
substantially more trustworthy: the thing deciding "done" is not the thing that
wants to be done.
Use a verifier loop when the cost of a false "it works" is high — anything you
will not manually check before it ships. See
verification.md
.
iterate:
worker -> makes one change
reviewer -> runs the build and tests, returns VERDICT + real output
if VERDICT == pass -> stop
if MAX reached or stalled -> stop and report
else -> feed the reviewer's findings back to the worker
8. Anti-patterns
# 1. No stop condition.
"Keep improving the code."
-> Improve until what is true? Unbounded.
# 2. Self-assessed check.
CHECK: "does the code look correct now?"
-> Not a check. Use a command with an exit code.
# 3. No iteration cap.
-> One unreachable goal burns the whole budget.
# 4. Multiple changes per iteration.
-> You cannot attribute the result to a cause.
# 5. Polling with sleep for work that notifies you.
while true; do sleep 30; check_status; done
-> Wasted turns. Use a long fallback and let the notification wake you.
# 6. Ignoring a stall.
Same failure, iteration 3, 4, 5, 6…
-> Stop at 2. Report.
# 7. Weakening the check to terminate.
-> That is not success, and reporting it as success is a false claim.
# 8. Reporting success without the final check's output.
-> Paste the passing output. Otherwise it is an assertion.
Checklist before starting a loop
[ ] GOAL is a measurable outcome, not an activity.
[ ] CHECK is a real command with an exit code, run every iteration.
[ ] MAX iteration cap is set.
[ ] Stall detection is defined (identical failure, oscillation, growth).
[ ] One change per iteration.
[ ] The termination report includes the final check's real output.
[ ] For high-stakes work, an independent verifier decides "done", not the worker.
---
# Main Agent Router
https://nagarjuna2997.github.io/ios-agent-skill/guides/orchestration-router.html
← Back to project internals
All articles →Orchestration · Project internalsMain Agent RouterRepository guidance for Main Agent Router. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
1. The decision
2. When to delegate
Choosing the specialist
3. Standard sequences
Feature (multi-file)
Bug
Drive a red suite to green
Codebase-wide mechanical change
4. What the main agent stays responsible for
5. Cost discipline
6. Non-negotiables
7. Quick reference
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Classify the requested change
↓2
Select necessary specialists
↓3
Delegate bounded reviews
↓4
Integrate verified findings
02 / ArchitectureResponsibility boundariesBoundary 1
Main agentBoundary 2
Specialist reviewersBoundary 3
Final evidenceConnected responsibilities, not a required class hierarchy or an execution trace.
Load this when:
you are the main agent deciding how to execute a request —
do it yourself, delegate it, loop on it, or scale it out.
This is the entry point to
docs/orchestration/
. It answers one question:
given this request, what shape of execution does it need?
The default is
do it yourself
. Everything below is an exception that has to
earn its cost.
1. The decision
Work top to bottom. Take the first row that matches.
If the request…
Then
Doc
Touches 1–2 files you can already see
Do it inline. No delegation.
—
Is a question you can answer from files you have read
Answer it.
—
Requires sweeping many files to locate something
Delegate to ios-explore
subagents.md
Is a multi-file feature, migration, or architecture change
ios-plan first, then execute
subagents.md
Produced code that will ship
swift-reviewer after
verification.md
Is a failure whose cause is not obvious
swift-debugger
subagents.md
Is a behavior-preserving cleanup
swift-refactorer
subagents.md
Is documentation, README, or CHANGELOG work
ios-docs
subagents.md
Involves Foundation Models, Apple Intelligence, or on-device LLM work
foundation-models
../frameworks/foundation-models.md
Is an accessibility audit — VoiceOver, Dynamic Type, contrast
accessibility-reviewer
../frameworks/accessibility.md
Is a performance investigation — hitches, memory, main-actor contention
performance-reviewer
../../checklists/performance.md
Is migrating legacy SwiftUI/UIKit to modern APIs
swiftui-modernization
subagents.md
Must repeat until a condition holds
A loop with a stop condition
looping.md
Is 5–30 isolated changes each wanting its own PR
/batch
dynamic-workflows.md
Is dozens of units with branching or dependencies
A dynamic workflow
dynamic-workflows.md
Needs workers to talk to each other
Reconsider. That is agent teams — experimental, off by default
subagents.md §6
2. When to delegate
Delegate when at least one is true:
Context cost.
The investigation would read more files than you want in
context. One paragraph of findings beats forty files.
Independence.
The work needs judging by something that did not write it.
Parallelism.
Several genuinely independent read-only investigations.
Isolation.
The work should happen in a worktree, not your working tree.
Do
not
delegate because a task sounds big. "Thorough", "multiple angles",
"several parts", and "check everything" are not delegation triggers — they are
descriptions of ordinary work. A subagent starts cold and must be told
everything; for anything you could finish in a few tool calls, that overhead
exceeds the benefit.
Choosing the specialist
Where is X? / which files do Y? -> ios-explore (read-only, parallel-safe)
How should I build X? -> ios-plan (read-only, returns a plan)
Is this change correct? Does it build? -> swift-reviewer (read + Bash, no writes)
Why is this broken? -> swift-debugger (reproduce, fix, prove)
Clean this up without changing behavior-> swift-refactorer (baseline, change, re-verify)
Write/update docs -> ios-docs (structure + mirror sync)
Doesn't fit any of these -> general-purpose (built-in)
3. Standard sequences
Feature (multi-file)
1. ios-explore — find the existing seams and conventions [parallel-safe]
2. ios-plan — a step-by-step plan with file paths and verify commands
3. main agent — execute the plan
4. swift-reviewer — cold verification: build + tests + real output
5. main agent — route findings back, or report done with evidence
Skip steps 1–2 when the feature is small and the codebase is already familiar.
Never skip step 4 for code that ships.
Bug
1. swift-debugger — reproduce, isolate, root cause, fix, prove
2. swift-reviewer — independent confirmation, if the fix is non-trivial
The debugger already proves its own fix by re-running the failing command. A
second reviewer is for fixes that touch shared code or change behavior beyond
the bug.
Drive a red suite to green
GOAL: swift test exits 0
CHECK: $ swift test 2>&1 | tail -20
MAX: 6
loop:
swift-debugger — one root cause per iteration
swift-reviewer — verdict + real output
stop on: pass | MAX | identical failure twice
See
looping.md
for stall detection and the rules against faking termination.
Codebase-wide mechanical change
1. ios-explore — enumerate every affected file (this is the work list)
2. ios-plan — group into independent units; flag shared files
3. /batch — one worker per unit, own worktree, own PR
4. per-unit — swift-reviewer verdict before the PR opens
If step 2 finds that units share files, it is not a batch. Sequence it instead.
4. What the main agent stays responsible for
Delegation moves work, not accountability. The main agent always owns:
The user's actual request.
Subagents see a slice; you see the whole thing.
Deciding what "done" means
, and confirming evidence supports it.
Reconciling reports.
Subagents cannot talk to each other. If explore found
something plan needs,
you
carry it across.
Reporting faithfully
— including failures, skips, and anything a subagent
marked UNVERIFIED. Never launder a subagent's uncertainty into your own
confidence.
Not fabricating pending results.
A background subagent that has not
reported has no result. Say it is still running.
Subagent reports are input, not truth. If one says "tests pass" with no pasted
output, that is an unverified claim — treat it as such.
5. Cost discipline
Each subagent starts cold. Before spawning, ask:
Could I do this in two or three tool calls? → Do it.
Does an already-running subagent have this context? → Continue that one
instead of starting fresh.
Will I re-explain more than the task is worth? → Do it inline.
Are these units really independent? → If not, do not parallelize.
Then reduce the cost of the ones you do spawn:
One discovery pass, its findings pasted into every downstream prompt — not N
agents rediscovering the same five files.
sonnet
for mechanical work,
inherit
for reasoning work.
Tight scope: name the files, state what is out of scope.
6. Non-negotiables
These hold no matter which path you took:
Evidence, not assertion.
Every "it works" carries the command output that
proves it.
verification.md
.
The author does not grade the work
for anything that ships.
Every loop has a stop condition and an iteration cap.
looping.md
.
Parallel writers are isolated
in worktrees, or they are not parallel.
Failures are reported plainly
, never softened or buried.
Hooks and CI decide what they can decide.
Do not spend a reviewer subagent
on something a grep in a hook settles for free.
7. Quick reference
inline 1–2 files, context already loaded ← the default
ios-explore "where is X" across many files read-only, parallel
ios-plan multi-file feature or migration read-only
swift-reviewer verify work you or another agent did no write tools
swift-debugger something is broken, cause unclear reproduce → fix → prove
swift-refactorer cleanup with no behavior change green baseline required
ios-docs prose about code enforces doc structure
foundation-models on-device / PCC LLM work availability-aware
swiftui-modernization legacy → modern API migration behavior-preserving
accessibility-reviewer VoiceOver, Dynamic Type, contrast read-only audit
performance-reviewer hitches, memory, actor contention measures, never guesses
loop repeat until a measured condition needs GOAL/CHECK/MAX
/batch 5–30 isolated PRs worktree per unit
dynamic workflow dozens of units, branching logic orchestration in a script
---
# Subagents
https://nagarjuna2997.github.io/ios-agent-skill/guides/orchestration-subagents.html
← Back to all frameworks & guides
All articles →Orchestration · Reference guideSubagentsRepository guidance for Subagents. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
1. Why delegate at all
2. Defining a subagent
The description is the interface
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Define a narrow responsibility
↓2
Supply scoped context
↓3
Execute isolated work
↓4
Return findings to coordinator
02 / ArchitectureResponsibility boundariesBoundary 1
Delegation contractBoundary 2
Subagent contextBoundary 3
Coordinator reviewConnected responsibilities, not a required class hierarchy or an execution trace.
Load this when:
a task is large enough to split, a search would flood your
context, you need work verified by something other than the agent that wrote it,
or you are defining a new specialist in
.claude/agents/
.
A subagent is a separate Claude instance with its
own context window
, its
own system prompt
, and optionally a
restricted tool set
. The main agent
delegates a task to it, the subagent works in isolation, and it returns a single
final report.
1. Why delegate at all
Three reasons, in order of how often they apply:
Context preservation.
A search that reads forty files costs forty files
of your context. Delegated to a subagent, it costs one paragraph of findings.
This is the most common reason and the most undervalued.
Independent verification.
An agent that wrote code is a bad judge of
whether that code works — it is predisposed to see its own intent rather than
what it typed. A fresh subagent has no such stake. See
verification.md
.
Parallelism.
Independent read-only investigations run concurrently
instead of serially.
Delegation is not free. Each subagent starts
cold
— it does not inherit your
conversation, the file you just read, or the decision the user made three turns
ago. Everything it needs must be in the prompt you give it. For a task you could
finish in two tool calls, spawning a subagent is slower and worse.
Do the work inline when:
it is a couple of files, you already have the
context loaded, or the task is a single edit. "Thorough", "multiple angles", and
"several parts" are not by themselves reasons to delegate.
2. Defining a subagent
Subagents are markdown files with YAML frontmatter. Project-level definitions
live in
.claude/agents/
; user-level ones in
~/.claude/agents/
. Project
definitions take precedence when names collide.
---
name: swift-reviewer
description: Independent verifier for Swift changes. Use after code is written to check it against the repo rules and prove it builds. Runs builds and tests and returns real output.
tools: Read, Grep, Glob, Bash
model: inherit
---
You are an independent reviewer. …system prompt…
Field
Required
Notes
name
yes
Lowercase kebab-case, unique. This is how you invoke it.
description
yes
This is the routing signal. The main agent selects a subagent by matching the task against this text.
tools
no
Comma-separated allowlist. Omit to inherit all tools.
model
no
sonnet, opus, haiku, or inherit. Defaults to the configured subagent model.
The description is the interface
The main agent picks a subagent by reading descriptions, not by reading system
prompts. A vague description means the subagent never gets invoked, or gets
invoked for the wrong things.
# WEAK — nothing here says when to use it.
description: Reviews code.
# STRONG — names the trigger, the input, and the output.
description: Independent verifier for Swift/iOS changes. Use after another agent
has written code, to check it against repository rules and prove it builds and
tests pass. Runs builds and tests and returns their real output.
Write descriptions in the form *"
. Use when . Returns
."*
### Restrict tools deliberately
Tool restriction is a correctness feature, not just a safety one. A subagent
with no write tools **cannot** accidentally edit while investigating, which is
what makes it safe to run several in parallel.
| Subagent kind | Tools | Why |
|---------------|-------|-----|
| Search / explore | `Read, Grep, Glob` | Genuinely read-only; parallel-safe |
| Planner | `Read, Grep, Glob` | Produces a plan, never code |
| Reviewer | `Read, Grep, Glob, Bash` | Needs to run builds; must not edit |
| Debugger | `Read, Grep, Glob, Bash, Edit` | Must reproduce and fix |
| Refactorer | `Read, Grep, Glob, Edit, Write, Bash` | Edits, and verifies with tests |
| Docs | `Read, Grep, Glob, Edit, Write, Bash` | Writes prose, runs repo checks |
Giving every subagent every tool defeats the point. A reviewer with `Edit` will
eventually fix what it should have reported.
#### Verifying the restriction actually holds
Run `./scripts/eval-agents.sh`. It prints the grant matrix and fails when a
declaration contradicts the instructions that rely on it:
$ ./scripts/eval-agents.sh
AGENT TOOLS BOUNDARY
ios-explore Read, Grep, Glob read-only (enforced + declared)
swift-reviewer Read, Grep, Glob, Bash read-only (enforced + declared)
swift-refactorer Read, Grep, Glob, Edit, Write, Bash read-write
…
OK — 10 agents, every declared boundary is consistent with its instructions.
The tempting design is to prompt each agent to edit something and check that it
refuses. **That does not test the boundary.** Tool restriction is enforced by
the harness from the `tools:` line before the model is consulted, so a prompt
check passes for an agent whose frontmatter wrongly grants `Write` — the model
simply chose not to use it — and it is non-deterministic besides. What decides
the boundary is the declaration, so that is what gets checked, against the
prompt depending on it:
| Rule | Catches |
|------|---------|
| `read-only-holds-write-tool` | A description promising read-only while `tools` grants `Edit`/`Write` |
| `write-instruction-without-write-tool` | Instructions to modify files with no write tool granted |
| `command-without-bash` | A command in the prompt the agent cannot run |
| `bash-never-used` | `Bash` granted with nothing in the prompt to run |
| `unknown-tool` | A misspelled tool name — not granted, and not reported |
| `name-filename-mismatch` | Delegation prompts pointing at an unregistered identifier |
| `no-tools` | No `tools:` at all, which inherits everything |
Read-only status is taken from the **description**, never the body. Bodies are
full of scoped prohibitions that mean something else — `ios-docs` says "never
edit a *generated* file", `swift-refactorer` says "do not change *access
levels*" — and reading those as a read-only contract flags three correctly
configured agents. The description is what the main agent reads when it decides
to delegate, so it is the only place the promise counts.
`./scripts/eval-agents.sh --self-test` builds agents each broken in exactly one
way and asserts every rule fires, including a regression case for the four real
phrasings above. A check that cannot fail is not a check.
### Model selection
- `inherit` — matches the main agent. Use for reasoning-heavy work: planning,
review, debugging.
- `sonnet` — cheaper and faster. Use for mechanical work: search, refactors,
documentation.
- `haiku` — for high-volume trivial classification.
---
## 3. This repository's subagents
Defined in `.claude/agents/`:
| Subagent | Tools | Model | Use for |
|----------|-------|-------|---------|
| `ios-explore` | read-only | sonnet | "Where is X?" across a Swift codebase |
| `ios-plan` | read-only | inherit | Multi-file features, migrations, architecture decisions |
| `swift-reviewer` | read + Bash | inherit | Verifying work someone else did |
| `swift-debugger` | read + Bash + Edit | inherit | A failure whose cause is not obvious |
| `swift-refactorer` | read + write + Bash | sonnet | Behavior-preserving cleanups |
| `ios-docs` | read + write + Bash | sonnet | Docs, DocC, README, CHANGELOG |
They are prefixed `ios-` / `swift-` deliberately: Claude Code ships built-in
subagents including a **general-purpose** one, and in some configurations
`Explore` and `Plan`. Prefixing avoids shadowing a built-in whose behavior you
did not intend to replace, and makes it obvious in a transcript which one ran.
See `router.md` for when the main agent should reach for each.
---
## 4. Writing the delegation prompt
A subagent starts cold. This is the failure mode that makes delegation look
useless: an under-specified prompt produces a confident, irrelevant report.
Every delegation prompt carries:
1. **The goal**, as an outcome rather than an activity.
2. **The context it cannot see** — decisions already made, constraints, the
relevant file paths you already found.
3. **The boundary** — what is explicitly out of scope.
4. **The return format** — what you need back, and in what shape.
# WEAK
"Look at the networking code."
# STRONG
"Find every place that constructs a URLRequest in Sources/, and report which
ones set an Authorization header and which do not.
Context: we are adding a shared auth interceptor and need to know what would
be duplicated. The APIClient at Sources/Data/APIClient.swift:44 is already
known — I need the ones outside it.
Out of scope: test targets, and anything under Vendor/.
Return: a table of file:line, the endpoint, and whether it sets auth."
The `Out of scope` line matters more than it looks. Without it, subagents
reliably expand the task.
---
## 5. Parallelism
Read-only subagents parallelize cleanly. Launch several in one turn when the
investigations are genuinely independent:
- "Where is the auth token stored?"
- "Which screens call the orders endpoint?"
- "What is our current deployment target and Swift language mode?"
**Do not parallelize writes.** Two subagents editing the same file will clobber
each other, and neither will know. When several agents must change code, either
sequence them or give each an isolated git worktree — see
`dynamic-workflows.md`.
---
## 6. Subagents are not agent teams
This distinction is load-bearing and frequently confused.
| | Subagents | Agent teams |
|--|-----------|-------------|
| Communication | Report **only** to the main agent | Workers message each other directly |
| Topology | Hub and spoke | Peer to peer |
| Coordination | The main agent is the sole orchestrator | Emergent between workers |
| Availability | Generally available | **Experimental, disabled by default** |
Subagents **cannot** talk to each other. If subagent A discovers something
subagent B needs, that information travels A → main agent → B, and only if the
main agent passes it along. Design your delegation around that: do not split a
task in a way that requires two workers to negotiate.
If you actually need peer-to-peer worker communication, that is the agent-teams
feature, it is experimental, and it is off unless explicitly enabled. Do not
assume it in a workflow you expect to work today.
---
## 7. Anti-patterns
# 1. Delegating what you could do in two tool calls.
"Spawn a subagent to read Package.swift."
-> Just read it. Cold start costs more than the read.
# 2. Spawning a fresh agent for a task an existing one has context for.
-> Continue the existing subagent instead of starting cold again.
# 3. Assuming the subagent can see your conversation.
"Fix the bug we discussed."
-> It has no idea what you discussed. Restate it.
# 4. Parallel writers on the same files.
-> Sequence them, or isolate each in a worktree.
# 5. A reviewer that also edits.
-> Then it is grading its own work. Restrict its tools.
# 6. Trusting a report with no evidence.
"The subagent said tests pass."
-> Did it paste the output? If not, it asserted, it did not verify.
# 7. Predicting a background subagent's result.
-> A pending agent has no result. Say it is still running.
# 8. One mega-subagent with every tool and a vague description.
-> It never gets routed correctly and cannot be reasoned about.
---
## Checklist for a new subagent definition
- [ ] `name` is lowercase kebab-case and does not shadow a built-in.
- [ ] `description` states what it does, when to use it, and what it returns.
- [ ] `tools` is the minimum set the job needs — read-only if it investigates.
- [ ] The system prompt states the **return format** explicitly.
- [ ] The system prompt includes the evidence contract (`verification.md`).
- [ ] The system prompt states what is out of scope.
- [ ] It does not assume access to the main agent's conversation.
- [ ] `./scripts/eval-agents.sh` passes — the grant matches what the prompt does.
---
# Verification and the Evidence Contract
https://nagarjuna2997.github.io/ios-agent-skill/guides/orchestration-verification.html
← Back to all frameworks & guides
All articles →Orchestration · Reference guideVerification and the Evidence ContractRepository guidance for Verification and the Evidence Contract. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
1. The rule
2. Every claim carries a label
3. What counts as evidence
Evidence for iOS specifically
When you genuinely cannot build
4. Separation of duties
Three layers, cheapest first
5. The report format
6. Reporting failure
7. Anti-patterns
Checklist before reporting "done"
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
State a concrete claim
↓2
Collect matching evidence
↓3
Review independently
↓4
Report limits and outcome
02 / ArchitectureResponsibility boundariesBoundary 1
ImplementationBoundary 2
Independent checksBoundary 3
Evidence reportConnected responsibilities, not a required class hierarchy or an execution trace.
Load this when:
you are about to report that something works, reviewing
another agent's output, closing a loop, or deciding whether a task is done.
This is the most important document in
docs/orchestration/
. Everything else
scales work out; this is what stops the scaled-out work from being confidently
wrong.
1. The rule
Never assert that something works. Show the output that proves it.
"The tests pass" is a claim. This is evidence:
$ swift test
Test Suite 'All tests' passed at 2026-07-27 14:02:11.
Executed 47 tests, with 0 failures (0 unexpected) in 2.314 seconds
The difference is not stylistic. An agent that has written code has a strong
prior that the code is correct — it wrote it
intending
to be correct, and it
reads its own output through that intent. Requiring pasted command output
replaces that prior with a fact.
2. Every claim carries a label
Each factual claim in a report falls into exactly one bucket, and the report
says which:
Label
Means
Requires
VERIFIED
A command was run; this is its real output
The command and its output, verbatim
INSPECTED
The code was read and reasoned about
The file:line that was read
UNVERIFIED
Could not be checked
The reason (no scheme, no simulator, no network)
A report with zero VERIFIED claims and no explanation of why is a failed report,
regardless of how confident it sounds.
UNVERIFIED is a legitimate, useful result.
"I could not build this — there is
no Xcode on this machine, so the isolation fix is INSPECTED only" is honest and
actionable. Quietly implying it built is not.
3. What counts as evidence
Ordered by strength:
Test output
— the suite ran, with counts and pass/fail state.
Build output
— it compiles, with the exact command shown.
Command output
— a lint run, a script, a grep whose emptiness is the point.
A screenshot
— for UI work, the actual rendered result.
A file diff
— what changed, when the change itself is the deliverable.
A file:line citation
— for claims about what code does.
What does
not
count:
"It should work now."
"The change is correct."
"I verified the logic."
A summary of what a command would print.
A test you wrote but did not run.
Evidence for iOS specifically
# Build — SPM
swift build 2>&1 | tail -40
# Build — Xcode. List schemes first; never guess one.
xcodebuild -list -project MyApp.xcodeproj
xcodebuild build -scheme "MyApp" \
-destination 'platform=iOS Simulator,name=iPhone 16' 2>&1 | tail -40
# Tests
swift test 2>&1 | tail -60
xcodebuild test -scheme "MyApp" \
-destination 'platform=iOS Simulator,name=iPhone 16' 2>&1 | tail -60
# Lint
swiftlint lint --quiet
swiftformat --lint .
# Rule checks whose empty output IS the evidence
grep -rn "DispatchQueue.main.async" Sources/ # expect: nothing
grep -rn "APIClient\|URLSession" Sources/Presentation/ # expect: nothing
When a grep is the check,
show that it returned nothing
. An empty result you
did not display is indistinguishable from a check you did not run.
When you genuinely cannot build
Common on Linux CI, in containers without Xcode, or in a docs-only repository.
Say it once, plainly, and downgrade the affected claims:
UNVERIFIED — no Xcode toolchain in this environment (`xcodebuild: command not
found`). All Swift samples are INSPECTED against the framework docs in
docs/frameworks/, not compiled. A human should build before merging.
Then verify what you
can
: markdown structure, cross-references, script syntax
(
bash -n
), JSON/YAML validity. Partial verification honestly labelled beats
none.
4. Separation of duties
The agent that did the work does not decide whether the work is done.
Setup
Trust level
Use when
Worker self-checks
Low
Trivial, reversible changes
Worker self-checks with pasted output
Medium
Most routine work
Independent swift-reviewer subagent
High
Anything shipping, anything you will not manually read
Reviewer + deterministic hooks/CI
Highest
Rules that must never regress
The reviewer must:
- Have
no write tools
— so it cannot fix what it should report.
- Start
cold
— so it evaluates the diff, not the author's intent.
- Return a
verdict plus evidence
, not a narrative.
worker -> writes the change
reviewer -> cold context, read + Bash only, runs the build and tests
-> VERDICT: pass | pass-with-findings | fail + real output
main agent -> routes findings back, or accepts
Three layers, cheapest first
Hooks
— deterministic, run automatically, no model judgment. Formatting,
forbidden patterns, mirror sync. See
../../.claude/settings.json
and
templates/hooks/
.
CI
— deterministic, runs on push. Build, tests, repo consistency.
Reviewer subagent
— model judgment for what rules cannot express:
is this correct, does it match intent, is the test meaningful.
Never use a reviewer subagent for something a hook can decide. A hook is free,
instant, and cannot be talked out of its opinion.
5. The report format
Every agent in this repository returns this shape:
VERDICT: <done | blocked | partial>
EVIDENCE
$ <command>
<real output>
$ <command>
<real output>
WHAT CHANGED
- path/to/File.swift:88 — <what and why>
NOT VERIFIED
- <claim> — <why it could not be checked>
FOLLOW-UPS
- <anything deliberately left, so nobody assumes it is covered>
NOT VERIFIED
is not optional. An empty section is fine; omitting the section
suggests everything was verified, which is rarely true.
6. Reporting failure
Report outcomes faithfully. Specifically:
If tests fail, say so and paste the failure.
If you skipped a step, say which and why.
If you could not reproduce a bug, say that — do not ship a speculative fix as
a confirmed one.
If part of the scope is blocked, finish everything else in full and state
exactly what you left out.
A partial result honestly labelled is more useful than a complete-looking result
that is wrong, because the human can act on the first and will be misled by the
second.
7. Anti-patterns
# 1. Asserting without evidence.
"Fixed — tests pass now."
-> Paste the output.
# 2. Reporting a test you wrote but never ran.
"Added a test covering the cancellation path."
-> Did it pass? Did it fail before the fix?
# 3. Implying a build you could not run.
"The code compiles cleanly." (on a box with no Xcode)
-> UNVERIFIED, and say why.
# 4. A grep check with no output shown.
"Confirmed no DispatchQueue usage remains."
-> Show the empty grep.
# 5. The author reviewing their own work and passing it.
-> Cold reviewer, no write tools.
# 6. Burying a failure in a success summary.
"Done. (One test is still red but it seems unrelated.)"
-> Lead with the failure. "Seems unrelated" needs evidence too.
# 7. Predicting a pending subagent's result.
"The reviewer will confirm this is fine."
-> It has not reported. Say it is still running.
# 8. A green check achieved by weakening the check.
-> Deleting a test, widening a catch, or loosening an assertion is not a pass.
Checklist before reporting "done"
[ ] The final check's real output is pasted, not summarized.
[ ] Every claim is labelled VERIFIED, INSPECTED, or UNVERIFIED.
[ ] Anything unverifiable in this environment says so, with the reason.
[ ] The verifier is not the author, for anything that ships.
[ ] Failures are stated plainly, not softened.
[ ] Skipped or out-of-scope work is listed explicitly.
[ ] No test was deleted, skipped, or loosened to reach green.
---
# iOS Platform Guide
https://nagarjuna2997.github.io/ios-agent-skill/guides/platforms-ios.html
← Back to all frameworks & guides
All articles →Platforms · Reference guideiOS Platform GuideRepository guidance for iOS. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
App Lifecycle
UIApplicationDelegate (UIKit)
SceneDelegate (UIKit Multi-Window)
SwiftUI App Lifecycle
Background Tasks
BGTaskScheduler
Deep Linking
Universal Links
Custom URL Schemes
Share Extensions
HealthKit Basics
Core Haptics
Document-Based Apps
Key iOS Capabilities Summary
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Handle app lifecycle
↓2
Route a user action
↓3
Perform platform work
↓4
Restore meaningful state
02 / ArchitectureResponsibility boundariesBoundary 1
App lifecycleBoundary 2
Feature servicesBoundary 3
System capabilitiesConnected responsibilities, not a required class hierarchy or an execution trace.
App Lifecycle
UIApplicationDelegate (UIKit)
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
// Configure services, SDKs, appearance
return true
}
func application(
_ application: UIApplication,
configurationForConnecting connectingSceneSession: UISceneSession,
options: UIScene.ConnectionOptions
) -> UISceneConfiguration {
UISceneConfiguration(name: "Default", sessionRole: connectingSceneSession.role)
}
}
SceneDelegate (UIKit Multi-Window)
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options: UIScene.ConnectionOptions) {
guard let windowScene = scene as? UIWindowScene else { return }
window = UIWindow(windowScene: windowScene)
window?.rootViewController = MainViewController()
window?.makeKeyAndVisible()
// Handle incoming URL
if let urlContext = options.urlContexts.first {
handleDeepLink(urlContext.url)
}
}
func sceneDidBecomeActive(_ scene: UIScene) { }
func sceneWillResignActive(_ scene: UIScene) { }
func sceneDidEnterBackground(_ scene: UIScene) { }
}
SwiftUI App Lifecycle
@main
struct MyApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
@Environment(\.scenePhase) private var scenePhase
var body: some Scene {
WindowGroup {
ContentView()
}
.onChange(of: scenePhase) { oldPhase, newPhase in
switch newPhase {
case .active: print("App is active")
case .inactive: print("App is inactive")
case .background:
scheduleBackgroundTasks()
@unknown default: break
}
}
}
}
Background Tasks
BGTaskScheduler
// 1. Register in Info.plist under BGTaskSchedulerPermittedIdentifiers:
// ["com.app.refresh", "com.app.processing"]
// 2. Register handlers at launch
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
BGTaskScheduler.shared.register(forTaskWithIdentifier: "com.app.refresh", using: nil) { task in
self.handleAppRefresh(task: task as! BGAppRefreshTask)
}
BGTaskScheduler.shared.register(forTaskWithIdentifier: "com.app.processing", using: nil) { task in
self.handleProcessing(task: task as! BGProcessingTask)
}
return true
}
// 3. Schedule
func scheduleBackgroundTasks() {
let refreshRequest = BGAppRefreshTaskRequest(identifier: "com.app.refresh")
refreshRequest.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60)
let processingRequest = BGProcessingTaskRequest(identifier: "com.app.processing")
processingRequest.requiresNetworkConnectivity = true
processingRequest.requiresExternalPower = false
try? BGTaskScheduler.shared.submit(refreshRequest)
try? BGTaskScheduler.shared.submit(processingRequest)
}
// 4. Handle
func handleAppRefresh(task: BGAppRefreshTask) {
scheduleBackgroundTasks() // Reschedule
let operation = RefreshOperation()
task.expirationHandler = { operation.cancel() }
operation.completionBlock = {
task.setTaskCompleted(success: !operation.isCancelled)
}
OperationQueue().addOperation(operation)
}
Deep Linking
Universal Links
// apple-app-site-association (hosted at https://example.com/.well-known/)
{
"applinks": {
"apps": [],
"details": [
{
"appID": "TEAMID.com.example.app",
"paths": ["/product/*", "/user/*"],
"components": [
{ "/": "/product/*", "comment": "Product pages" }
]
}
]
}
}
// SwiftUI handling
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.onOpenURL { url in
DeepLinkRouter.shared.handle(url)
}
}
}
}
// Router
@Observable
class DeepLinkRouter {
static let shared = DeepLinkRouter()
var selectedTab: Tab = .home
var navigationPath = NavigationPath()
func handle(_ url: URL) {
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true) else { return }
let pathComponents = components.path.split(separator: "/").map(String.init)
switch pathComponents.first {
case "product":
if let id = pathComponents[safe: 1] {
selectedTab = .shop
navigationPath.append(Route.product(id: id))
}
case "user":
if let id = pathComponents[safe: 1] {
selectedTab = .profile
navigationPath.append(Route.profile(id: id))
}
default: break
}
}
}
Custom URL Schemes
// Info.plist: CFBundleURLSchemes = ["myapp"]
// Usage: myapp://action/param
func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
guard let url = URLContexts.first?.url else { return }
// Parse myapp://product/123
if url.host == "product", let id = url.pathComponents[safe: 1] {
navigateToProduct(id: id)
}
}
Share Extensions
// ShareViewController.swift (in Share Extension target)
import UIKit
import Social
class ShareViewController: SLComposeServiceViewController {
override func isContentValid() -> Bool {
return !contentText.isEmpty
}
override func didSelectPost() {
guard let items = extensionContext?.inputItems as? [NSExtensionItem] else {
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
return
}
for item in items {
for provider in item.attachments ?? [] {
if provider.hasItemConformingToTypeIdentifier("public.url") {
provider.loadItem(forTypeIdentifier: "public.url") { [weak self] data, error in
if let url = data as? URL {
self?.saveSharedURL(url)
}
self?.extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
}
}
}
}
}
private func saveSharedURL(_ url: URL) {
// Save to App Group shared container
let defaults = UserDefaults(suiteName: "group.com.example.app")
var urls = defaults?.stringArray(forKey: "sharedURLs") ?? []
urls.append(url.absoluteString)
defaults?.set(urls, forKey: "sharedURLs")
}
}
HealthKit Basics
import HealthKit
class HealthKitManager {
private let store = HKHealthStore()
func requestAuthorization() async throws {
guard HKHealthStore.isHealthDataAvailable() else {
throw HealthError.notAvailable
}
let readTypes: Set<HKObjectType> = [
HKQuantityType(.stepCount),
HKQuantityType(.heartRate),
HKCategoryType(.sleepAnalysis)
]
let writeTypes: Set<HKSampleType> = [
HKQuantityType(.stepCount)
]
try await store.requestAuthorization(toShare: writeTypes, read: readTypes)
}
func fetchTodaySteps() async throws -> Double {
let stepsType = HKQuantityType(.stepCount)
let startOfDay = Calendar.current.startOfDay(for: .now)
let predicate = HKQuery.predicateForSamples(withStart: startOfDay, end: .now)
let descriptor = HKStatisticsQueryDescriptor(
predicate: .init(quantityType: stepsType, predicate: predicate),
options: .cumulativeSum
)
let result = try await descriptor.result(for: store)
return result?.sumQuantity()?.doubleValue(for: .count()) ?? 0
}
}
Core Haptics
import CoreHaptics
class HapticsManager {
private var engine: CHHapticEngine?
func prepareEngine() {
guard CHHapticEngine.capabilitiesForHardware().supportsHaptics else { return }
engine = try? CHHapticEngine()
try? engine?.start()
engine?.resetHandler = { [weak self] in
try? self?.engine?.start()
}
}
func playSuccessPattern() {
let sharpness = CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.5)
let intensity = CHHapticEventParameter(parameterID: .hapticIntensity, value: 1.0)
let events = [
CHHapticEvent(eventType: .hapticTransient, parameters: [intensity, sharpness], relativeTime: 0),
CHHapticEvent(eventType: .hapticTransient, parameters: [intensity, sharpness], relativeTime: 0.15),
]
let pattern = try? CHHapticPattern(events: events, parameters: [])
let player = try? engine?.makePlayer(with: pattern!)
try? player?.start(atTime: 0)
}
/// Simple feedback using UIKit
static func impact(_ style: UIImpactFeedbackGenerator.FeedbackStyle = .medium) {
UIImpactFeedbackGenerator(style: style).impactOccurred()
}
static func notification(_ type: UINotificationFeedbackGenerator.FeedbackType) {
UINotificationFeedbackGenerator().notificationOccurred(type)
}
}
// SwiftUI usage
Button("Tap") { }
.sensoryFeedback(.success, trigger: didSucceed)
Document-Based Apps
import SwiftUI
import UniformTypeIdentifiers
// Define document type
struct MarkdownDocument: FileDocument {
static var readableContentTypes: [UTType] { [.plainText] }
var text: String
init(text: String = "") {
self.text = text
}
init(configuration: ReadConfiguration) throws {
guard let data = configuration.file.regularFileContents,
let string = String(data: data, encoding: .utf8)
else { throw CocoaError(.fileReadCorruptFile) }
text = string
}
func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
let data = Data(text.utf8)
return .init(regularFileWithContents: data)
}
}
// App entry point
@main
struct DocApp: App {
var body: some Scene {
DocumentGroup(newDocument: MarkdownDocument()) { file in
TextEditor(text: file.$document.text)
.font(.system(.body, design: .monospaced))
}
}
}
Key iOS Capabilities Summary
Feature
Framework
Min iOS
Background Tasks
BackgroundTasks
13.0
Universal Links
Associated Domains
9.0
HealthKit
HealthKit
8.0
Core Haptics
CoreHaptics
13.0
SwiftUI App Lifecycle
SwiftUI
14.0
Observation framework
Observation
17.0
Swift Testing
Testing
16.0+
Interactive Widgets
WidgetKit
17.0
---
# macOS Platform Guide
https://nagarjuna2997.github.io/ios-agent-skill/guides/platforms-macos.html
← Back to all frameworks & guides
All articles →Platforms · Reference guidemacOS Platform GuideRepository guidance for macOS. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
Menu Bar and NSMenu
Main Menu Customization
Menu Bar Extra (Status Bar App)
NSWindow Customization
Toolbar and Sidebar Patterns
Document-Based Apps
Sandboxing and Entitlements
Common Entitlements
Security-Scoped Bookmarks
AppKit-SwiftUI Interop
NSViewRepresentable
Hosting SwiftUI in AppKit
Settings / Preferences Window
Drag and Drop
macOS-Specific Patterns Summary
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Choose window structure
↓2
Route commands and documents
↓3
Respect sandbox boundaries
↓4
Test desktop interactions
02 / ArchitectureResponsibility boundariesBoundary 1
App and windowsBoundary 2
Commands and documentsBoundary 3
Sandboxed servicesConnected responsibilities, not a required class hierarchy or an execution trace.
Menu Bar and NSMenu
Main Menu Customization
// SwiftUI CommandMenu
@main
struct MyMacApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
.commands {
// Replace existing group
CommandGroup(replacing: .newItem) {
Button("New Document") { createDocument() }
.keyboardShortcut("n")
Button("New from Template...") { showTemplates() }
.keyboardShortcut("n", modifiers: [.command, .shift])
}
// Add custom menu
CommandMenu("Tools") {
Button("Run Analysis") { runAnalysis() }
.keyboardShortcut("r", modifiers: [.command, .shift])
Divider()
Toggle("Auto-Save", isOn: $autoSave)
}
// Toolbar commands
CommandGroup(after: .toolbar) {
Button("Toggle Sidebar") { toggleSidebar() }
.keyboardShortcut("s", modifiers: [.command, .control])
}
}
}
}
Menu Bar Extra (Status Bar App)
@main
struct StatusBarApp: App {
var body: some Scene {
MenuBarExtra("My App", systemImage: "star.fill") {
VStack(spacing: 12) {
Text("Status: Active")
.font(.headline)
Divider()
Button("Open Dashboard") { openDashboard() }
.keyboardShortcut("d")
Button("Preferences...") { openPreferences() }
.keyboardShortcut(",")
Divider()
Button("Quit") { NSApplication.shared.terminate(nil) }
.keyboardShortcut("q")
}
.padding()
}
.menuBarExtraStyle(.window) // .menu for simple dropdown
}
}
NSWindow Customization
// SwiftUI window styling
WindowGroup {
ContentView()
}
.windowStyle(.hiddenTitleBar)
.windowResizability(.contentSize)
.defaultSize(width: 800, height: 600)
.defaultPosition(.center)
// Full NSWindow control via NSViewRepresentable
struct WindowAccessor: NSViewRepresentable {
func makeNSView(context: Context) -> NSView {
let view = NSView()
DispatchQueue.main.async {
if let window = view.window {
window.isMovableByWindowBackground = true
window.titlebarAppearsTransparent = true
window.titleVisibility = .hidden
window.styleMask.insert(.fullSizeContentView)
window.backgroundColor = .clear
window.isOpaque = false
// Custom traffic light position
let buttons = [window.standardWindowButton(.closeButton),
window.standardWindowButton(.miniaturizeButton),
window.standardWindowButton(.zoomButton)]
buttons.compactMap { $0 }.enumerated().forEach { i, button in
button.frame.origin = CGPoint(x: 12 + CGFloat(i) * 20, y: 12)
}
}
}
return view
}
func updateNSView(_ nsView: NSView, context: Context) {}
}
Toolbar and Sidebar Patterns
struct ContentView: View {
@State private var selectedItem: SidebarItem? = .inbox
@State private var columnVisibility = NavigationSplitViewVisibility.all
var body: some View {
NavigationSplitView(columnVisibility: $columnVisibility) {
// Sidebar
List(selection: $selectedItem) {
Section("Favorites") {
Label("Inbox", systemImage: "tray")
.tag(SidebarItem.inbox)
Label("Sent", systemImage: "paperplane")
.tag(SidebarItem.sent)
}
Section("Folders") {
Label("Archive", systemImage: "archivebox")
.tag(SidebarItem.archive)
}
}
.listStyle(.sidebar)
.navigationSplitViewColumnWidth(min: 180, ideal: 220, max: 300)
} content: {
// Content list
if let selectedItem {
ItemListView(category: selectedItem)
}
} detail: {
// Detail
DetailView()
}
.navigationTitle("Mail")
.toolbar {
ToolbarItemGroup(placement: .primaryAction) {
Button(action: compose) {
Label("Compose", systemImage: "square.and.pencil")
}
}
ToolbarItem(placement: .navigation) {
Button(action: toggleSidebar) {
Label("Sidebar", systemImage: "sidebar.leading")
}
}
}
}
}
Document-Based Apps
@main
struct TextEditorApp: App {
var body: some Scene {
DocumentGroup(newDocument: TextDocument()) { file in
DocumentEditorView(document: file.$document)
}
.commands {
TextFormattingCommands()
}
// Additional window types
Window("Activity Log", id: "activity") {
ActivityLogView()
}
.keyboardShortcut("0", modifiers: [.command, .shift])
.defaultSize(width: 400, height: 300)
}
}
// Reference-type document (for complex data)
@Observable
class ProjectDocument: ReferenceFileDocument {
static var readableContentTypes: [UTType] { [.json] }
var project: Project
required init(configuration: ReadConfiguration) throws {
guard let data = configuration.file.regularFileContents else {
throw CocoaError(.fileReadCorruptFile)
}
project = try JSONDecoder().decode(Project.self, from: data)
}
func snapshot(contentType: UTType) throws -> Data {
try JSONEncoder().encode(project)
}
func fileWrapper(snapshot: Data, configuration: WriteConfiguration) throws -> FileWrapper {
FileWrapper(regularFileWithContents: snapshot)
}
}
Sandboxing and Entitlements
Common Entitlements
<!-- App.entitlements -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key> <true/>
<key>com.apple.security.files.user-selected.read-write</key> <true/>
<key>com.apple.security.files.bookmarks.app-scope</key> <true/>
<key>com.apple.security.network.client</key> <true/>
<key>com.apple.security.network.server</key> <true/>
<key>com.apple.security.print</key> <true/>
</dict>
</plist>
Security-Scoped Bookmarks
// Persist access to user-selected files
func saveBookmark(for url: URL) throws {
let bookmarkData = try url.bookmarkData(
options: .withSecurityScope,
includingResourceValuesForKeys: nil,
relativeTo: nil
)
UserDefaults.standard.set(bookmarkData, forKey: "savedFile")
}
func resolveBookmark() throws -> URL {
guard let data = UserDefaults.standard.data(forKey: "savedFile") else {
throw AppError.noBookmark
}
var isStale = false
let url = try URL(
resolvingBookmarkData: data,
options: .withSecurityScope,
relativeTo: nil,
bookmarkDataIsStale: &isStale
)
guard url.startAccessingSecurityScopedResource() else {
throw AppError.accessDenied
}
// Remember to call url.stopAccessingSecurityScopedResource() when done
return url
}
AppKit-SwiftUI Interop
NSViewRepresentable
struct WebView: NSViewRepresentable {
let url: URL
func makeNSView(context: Context) -> WKWebView {
let webView = WKWebView()
webView.navigationDelegate = context.coordinator
return webView
}
func updateNSView(_ webView: WKWebView, context: Context) {
webView.load(URLRequest(url: url))
}
func makeCoordinator() -> Coordinator { Coordinator() }
class Coordinator: NSObject, WKNavigationDelegate {
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
print("Page loaded")
}
}
}
// NSViewControllerRepresentable
struct LegacyEditorView: NSViewControllerRepresentable {
func makeNSViewController(context: Context) -> EditorViewController {
EditorViewController()
}
func updateNSViewController(_ controller: EditorViewController, context: Context) {}
}
Hosting SwiftUI in AppKit
let swiftUIView = SettingsContentView()
let hostingController = NSHostingController(rootView: swiftUIView)
// As a popover
let popover = NSPopover()
popover.contentViewController = hostingController
popover.behavior = .transient
popover.show(relativeTo: button.bounds, of: button, preferredEdge: .maxY)
Settings / Preferences Window
@main
struct MyApp: App {
var body: some Scene {
WindowGroup { ContentView() }
Settings {
TabView {
GeneralSettingsView()
.tabItem { Label("General", systemImage: "gear") }
AppearanceSettingsView()
.tabItem { Label("Appearance", systemImage: "paintbrush") }
AdvancedSettingsView()
.tabItem { Label("Advanced", systemImage: "gearshape.2") }
}
.frame(width: 450, height: 300)
}
}
}
struct GeneralSettingsView: View {
@AppStorage("launchAtLogin") private var launchAtLogin = false
@AppStorage("checkForUpdates") private var checkForUpdates = true
var body: some View {
Form {
Toggle("Launch at Login", isOn: $launchAtLogin)
Toggle("Check for Updates Automatically", isOn: $checkForUpdates)
Picker("Default View", selection: $defaultView) {
Text("List").tag(ViewMode.list)
Text("Grid").tag(ViewMode.grid)
}
}
.padding()
}
}
Drag and Drop
struct DragDropView: View {
@State private var items: [DragItem] = []
@State private var isTargeted = false
var body: some View {
VStack {
// Draggable items
ForEach(items) { item in
Text(item.name)
.draggable(item) // Requires Transferable conformance
}
// Drop target
RoundedRectangle(cornerRadius: 12)
.fill(isTargeted ? Color.accentColor.opacity(0.2) : Color.gray.opacity(0.1))
.frame(height: 200)
.overlay { Text("Drop files here") }
.dropDestination(for: URL.self) { urls, location in
handleDrop(urls: urls)
return true
} isTargeted: { targeted in
isTargeted = targeted
}
}
}
}
// Transferable conformance
struct DragItem: Identifiable, Codable, Transferable {
let id: UUID
let name: String
static var transferRepresentation: some TransferRepresentation {
CodableRepresentation(contentType: .data)
ProxyRepresentation(exporting: \.name) // Fallback as plain text
}
}
// NSFilePromiseProvider for files (AppKit interop)
class FilePromiseProvider: NSFilePromiseProvider {
override func fileType() -> String { UTType.png.identifier }
override func operationQueue() -> OperationQueue { .main }
override func provideFile(at url: URL) throws {
let data = generateFileData()
try data.write(to: url)
}
}
macOS-Specific Patterns Summary
Feature
API
Notes
Menu Bar Extra
MenuBarExtra
SwiftUI-native in macOS 13+
Settings Window
Settings scene
Replaces NSPreferencesWindow
Sidebar
NavigationSplitView
3-column layout
Toolbar
.toolbar modifier
Placement: .primaryAction, .navigation
Drag & Drop
Transferable
SwiftUI-native; NSFilePromiseProvider for files
Entitlements
.entitlements file
Required for sandboxed apps
Window styling
.windowStyle
.hiddenTitleBar, .plain
Keyboard shortcuts
.keyboardShortcut
Global via CommandMenu
---
# tvOS Platform Guide
https://nagarjuna2997.github.io/ios-agent-skill/guides/platforms-tvos.html
← Back to all frameworks & guides
All articles →Platforms · Reference guidetvOS Platform GuideRepository guidance for tvOS. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
Focus Engine and Focusable Views
Understanding the Focus System
Focus Sections and Custom Navigation
Focus-Aware Styling
TVUIKit Components
Lockup Views (UIKit)
Monogram and Caption Button
Top Shelf Extensions
Static Top Shelf
Inset Top Shelf
Siri Remote Handling
Gesture Recognition
Game Controller Support
Media Playback on TV
Multi-User Support
tvOS Design Guidelines
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Build focusable content
↓2
Define focus behavior
↓3
Handle remote input
↓4
Test playback and navigation
02 / ArchitectureResponsibility boundariesBoundary 1
Focus systemBoundary 2
Content viewsBoundary 3
Remote actionsConnected responsibilities, not a required class hierarchy or an execution trace.
Focus Engine and Focusable Views
Understanding the Focus System
tvOS uses a focus-based navigation model. The Siri Remote trackpad moves focus between views; pressing the trackpad selects the focused item.
struct ContentGridView: View {
@FocusState private var focusedItem: String?
let items = ["Movies", "Shows", "Music", "Podcasts"]
var body: some View {
HStack(spacing: 40) {
ForEach(items, id: \.self) { item in
CardView(title: item)
.focusable()
.focused($focusedItem, equals: item)
.scaleEffect(focusedItem == item ? 1.1 : 1.0)
.shadow(radius: focusedItem == item ? 20 : 5)
.animation(.spring(duration: 0.3), value: focusedItem)
}
}
.padding(60)
.defaultFocus($focusedItem, "Movies")
}
}
Focus Sections and Custom Navigation
struct CustomFocusView: View {
@FocusState private var section: Section?
enum Section: Hashable {
case sidebar, content, detail
}
var body: some View {
HStack {
// Sidebar
VStack {
ForEach(menuItems) { item in
MenuButton(item: item)
}
}
.focusSection()
.focused($section, equals: .sidebar)
// Content area
LazyVGrid(columns: columns) {
ForEach(contentItems) { item in
ContentCard(item: item)
}
}
.focusSection()
.focused($section, equals: .content)
}
.focusScope(namespace)
.onMoveCommand { direction in
handleDirectionalInput(direction)
}
}
func handleDirectionalInput(_ direction: MoveCommandDirection) {
switch direction {
case .left: section = .sidebar
case .right: section = .content
default: break
}
}
}
Focus-Aware Styling
struct FocusableCard: View {
let title: String
let imageURL: URL
@Environment(\.isFocused) var isFocused
var body: some View {
VStack {
AsyncImage(url: imageURL) { image in
image.resizable().aspectRatio(contentMode: .fill)
} placeholder: {
Color.gray
}
.frame(width: 300, height: 170)
.clipShape(RoundedRectangle(cornerRadius: 12))
.shadow(color: .black.opacity(isFocused ? 0.4 : 0.1),
radius: isFocused ? 20 : 5,
y: isFocused ? 10 : 2)
Text(title)
.font(isFocused ? .headline : .subheadline)
.foregroundStyle(isFocused ? .primary : .secondary)
}
.scaleEffect(isFocused ? 1.05 : 1.0)
.animation(.easeInOut(duration: 0.2), value: isFocused)
}
}
TVUIKit Components
Lockup Views (UIKit)
import TVUIKit
class PosterViewController: UIViewController {
func createLockupView() -> TVLockupView {
let lockup = TVLockupView()
// Content image
let imageView = UIImageView(image: UIImage(named: "poster"))
imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = true
imageView.frame = CGRect(x: 0, y: 0, width: 240, height: 360)
lockup.contentView.addSubview(imageView)
// Header
let headerLabel = UILabel()
headerLabel.text = "NEW"
headerLabel.font = .systemFont(ofSize: 16, weight: .bold)
lockup.headerView = headerLabel
// Footer
let footerLabel = UILabel()
footerLabel.text = "Movie Title"
footerLabel.font = .systemFont(ofSize: 20)
lockup.footerView = footerLabel
lockup.contentSize = CGSize(width: 240, height: 360)
return lockup
}
}
Monogram and Caption Button
// Monogram view for user profiles
let monogram = TVMonogramView()
monogram.title = "John Doe"
monogram.subtitle = "Family Member"
monogram.image = UIImage(named: "profile")
// Caption button
let captionButton = TVCaptionButtonView()
captionButton.contentImage = UIImage(systemName: "play.fill")
captionButton.title = "Play"
captionButton.subtitle = "From Beginning"
Top Shelf Extensions
Static Top Shelf
import TVServices
class ContentProvider: TVTopShelfContentProvider {
override func loadTopShelfContent() async -> TVTopShelfContent? {
// Sectioned content
var sections: [TVTopShelfItemCollection<TVTopShelfSectionedItem>] = []
let continueWatching = TVTopShelfItemCollection<TVTopShelfSectionedItem>(items: await fetchContinueWatching())
continueWatching.title = "Continue Watching"
let recommended = TVTopShelfItemCollection<TVTopShelfSectionedItem>(items: await fetchRecommended())
recommended.title = "Recommended for You"
sections = [continueWatching, recommended]
return TVTopShelfSectionedContent(sections: sections)
}
private func fetchContinueWatching() async -> [TVTopShelfSectionedItem] {
// Fetch from your data source
return movies.map { movie in
let item = TVTopShelfSectionedItem(identifier: movie.id)
item.title = movie.title
item.setImageURL(movie.posterURL, for: .screenScale1x)
item.setImageURL(movie.posterURL2x, for: .screenScale2x)
item.playAction = TVTopShelfAction(url: URL(string: "myapp://play/\(movie.id)")!)
item.displayAction = TVTopShelfAction(url: URL(string: "myapp://detail/\(movie.id)")!)
return item
}
}
}
Inset Top Shelf
func loadTopShelfContent() async -> TVTopShelfContent? {
let items: [TVTopShelfInsetItem] = await fetchFeatured().map { featured in
let item = TVTopShelfInsetItem(identifier: featured.id)
item.title = featured.title
item.setImageURL(featured.wideImageURL, for: .screenScale1x)
item.setImageURL(featured.wideImageURL2x, for: .screenScale2x)
item.imageShape = .extraWide // 16:9 aspect ratio
item.playAction = TVTopShelfAction(url: URL(string: "myapp://play/\(featured.id)")!)
return item
}
return TVTopShelfInsetContent(items: items)
}
Siri Remote Handling
Gesture Recognition
struct RemoteAwareView: View {
@State private var position = CGPoint(x: 400, y: 300)
var body: some View {
Canvas { context, size in
let rect = CGRect(x: position.x - 25, y: position.y - 25, width: 50, height: 50)
context.fill(Circle().path(in: rect), with: .color(.blue))
}
.onPlayPauseCommand { togglePlayback() }
.onExitCommand { handleBack() }
.onMoveCommand { direction in
withAnimation(.spring) {
switch direction {
case .up: position.y -= 50
case .down: position.y += 50
case .left: position.x -= 50
case .right: position.x += 50
@unknown default: break
}
}
}
}
}
Game Controller Support
import GameController
class RemoteInputManager: ObservableObject {
@Published var isConnected = false
func setupGameController() {
NotificationCenter.default.addObserver(
self, selector: #selector(controllerConnected),
name: .GCControllerDidConnect, object: nil
)
// Siri Remote as game controller
GCController.startWirelessControllerDiscovery()
}
@objc func controllerConnected(_ notification: Notification) {
guard let controller = notification.object as? GCController else { return }
isConnected = true
if let micro = controller.microGamepad {
micro.dpad.valueChangedHandler = { pad, x, y in
// Trackpad touch position (-1...1)
print("Touch: \(x), \(y)")
}
micro.buttonA.pressedChangedHandler = { _, _, pressed in
if pressed { self.handleSelect() }
}
micro.buttonMenu.pressedChangedHandler = { _, _, pressed in
if pressed { self.handleMenu() }
}
}
}
}
Media Playback on TV
import AVKit
struct PlayerView: UIViewControllerRepresentable {
let mediaURL: URL
func makeUIViewController(context: Context) -> AVPlayerViewController {
let controller = AVPlayerViewController()
let player = AVPlayer(url: mediaURL)
controller.player = player
// Configure for TV experience
controller.showsPlaybackControls = true
controller.allowsPictureInPicturePlayback = false
controller.updatesNowPlayingInfoCenter = true
controller.skippingBehavior = .skipItem
controller.requiresLinearPlayback = false
// Metadata
let metadata = AVMutableMetadataItem()
metadata.identifier = .commonIdentifierTitle
metadata.value = "Movie Title" as NSString
player.currentItem?.externalMetadata = [metadata]
// Info panel customization
controller.transportBarCustomMenuItems = [
UIAction(title: "Audio", image: UIImage(systemName: "speaker.wave.3")) { _ in },
UIAction(title: "Subtitles", image: UIImage(systemName: "captions.bubble")) { _ in }
]
player.play()
return controller
}
func updateUIViewController(_ controller: AVPlayerViewController, context: Context) {}
}
// Interstitial content (ads, recaps)
struct ContentWithInterstitials {
func configureInterstitials(for player: AVPlayer) {
let interstitialEvent = AVPlayerInterstitialEvent(
primaryItem: player.currentItem!,
time: CMTime(seconds: 300, preferredTimescale: 1) // At 5 minutes
)
interstitialEvent.templateItems = [
AVPlayerItem(url: URL(string: "https://example.com/ad.mp4")!)
]
let controller = AVPlayerInterstitialEventController(primaryPlayer: player)
controller.events = [interstitialEvent]
}
}
Multi-User Support
import TVUIKit
class UserManager {
func getCurrentUser() async -> TVUserManager.User? {
// tvOS supports multiple user profiles
let userManager = TVUserManager()
return await withCheckedContinuation { continuation in
userManager.presentProfilePreferencesPanel { result in
switch result {
case .success(let user):
continuation.resume(returning: user)
case .failure:
continuation.resume(returning: nil)
@unknown default:
continuation.resume(returning: nil)
}
}
}
}
}
// User-specific data isolation
struct UserProfileView: View {
let userIdentifier: String
var body: some View {
// Load user-specific preferences and watch history
VStack {
Text("Welcome back")
.font(.title)
ContinueWatchingRow(userId: userIdentifier)
RecommendationsRow(userId: userIdentifier)
}
}
}
tvOS Design Guidelines
Aspect
Recommendation
Viewing distance
Design for 10-foot experience (large text, images)
Safe area
Respect 60pt insets on all sides
Focus feedback
Always provide visual feedback for focus changes
Text size
Minimum 31pt for body text
Animations
Use spring animations for focus transitions
Navigation depth
Keep to 3-4 levels maximum
Loading states
Show placeholder content immediately
Background
Use layered images for parallax depth effect
Audio
Always provide descriptive audio option
Top Shelf
Update content regularly for discovery
---
# visionOS Platform Guide
https://nagarjuna2997.github.io/ios-agent-skill/guides/platforms-visionos.html
← Back to all frameworks & guides
All articles →Platforms · Reference guidevisionOS Platform GuideRepository guidance for visionOS. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
WindowGroup, ImmersiveSpace, and Volume
Scene Types
Opening and Dismissing Spaces
RealityView and RealityKit Entities
Custom Components and Systems
Model3D for 3D Model Display
Hand Tracking and Gestures
ARKit Hand Tracking (Advanced)
Eye Tracking
Spatial Audio
SharePlay in Spatial Apps
Ornaments and Attachments
Passthrough and Mixed Reality
visionOS Design Guidelines
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Choose window or space
↓2
Place spatial content
↓3
Handle supported interactions
↓4
Test comfort and transitions
02 / ArchitectureResponsibility boundariesBoundary 1
Window or immersive spaceBoundary 2
Spatial entitiesBoundary 3
Input and comfortConnected responsibilities, not a required class hierarchy or an execution trace.
WindowGroup, ImmersiveSpace, and Volume
Scene Types
@main
struct SpatialApp: App {
@State private var appModel = AppModel()
var body: some Scene {
// Standard 2D window
WindowGroup {
ContentView()
.environment(appModel)
}
// 3D volumetric window
WindowGroup(id: "globe") {
GlobeView()
.environment(appModel)
}
.windowStyle(.volumetric)
.defaultSize(width: 0.5, height: 0.5, depth: 0.5, in: .meters)
// Full immersive space
ImmersiveSpace(id: "solarSystem") {
SolarSystemView()
.environment(appModel)
}
.immersionStyle(selection: $appModel.immersionStyle, in: .mixed, .progressive, .full)
}
}
@Observable
class AppModel {
var immersionStyle: ImmersionStyle = .mixed
var isImmersiveSpaceOpen = false
}
Opening and Dismissing Spaces
struct ContentView: View {
@Environment(\.openWindow) private var openWindow
@Environment(\.dismissWindow) private var dismissWindow
@Environment(\.openImmersiveSpace) private var openImmersiveSpace
@Environment(\.dismissImmersiveSpace) private var dismissImmersiveSpace
@Environment(AppModel.self) private var appModel
var body: some View {
VStack(spacing: 20) {
Button("Open Globe") {
openWindow(id: "globe")
}
Button("Enter Solar System") {
Task {
let result = await openImmersiveSpace(id: "solarSystem")
switch result {
case .opened: appModel.isImmersiveSpaceOpen = true
case .error: print("Failed to open immersive space")
case .userCancelled: break
@unknown default: break
}
}
}
if appModel.isImmersiveSpaceOpen {
Button("Exit Immersive") {
Task {
await dismissImmersiveSpace()
appModel.isImmersiveSpaceOpen = false
}
}
}
}
}
}
RealityView and RealityKit Entities
import RealityKit
struct ImmersiveSceneView: View {
@State private var earthEntity: Entity?
var body: some View {
RealityView { content, attachments in
// Load USDZ model
if let earth = try? await Entity(named: "Earth", in: realityKitContentBundle) {
earth.position = [0, 1.5, -2]
earth.scale = [0.5, 0.5, 0.5]
// Add rotation animation
let rotation = FromToByAnimation<Transform>(
from: .init(rotation: simd_quatf(angle: 0, axis: [0, 1, 0])),
to: .init(rotation: simd_quatf(angle: .pi * 2, axis: [0, 1, 0])),
duration: 30,
bindTarget: .transform
)
let resource = try! AnimationResource.generate(with: rotation)
earth.playAnimation(resource, transitionDuration: 0, startsPaused: false)
content.add(earth)
earthEntity = earth
}
// Add lighting
let light = PointLight()
light.light.intensity = 50000
light.position = [2, 3, 0]
content.add(light)
// Add SwiftUI attachment
if let panel = attachments.entity(for: "infoPanel") {
panel.position = [0.5, 1.5, -1.5]
content.add(panel)
}
} update: { content, attachments in
// Update entities when state changes
} attachments: {
Attachment(id: "infoPanel") {
InfoPanelView()
.frame(width: 400, height: 300)
.glassBackgroundEffect()
}
}
}
}
Custom Components and Systems
import RealityKit
// Custom component
struct SpinComponent: Component {
var speed: Float = 1.0
var axis: SIMD3<Float> = [0, 1, 0]
}
// System to process spinning entities
struct SpinSystem: System {
static let query = EntityQuery(where: .has(SpinComponent.self))
init(scene: RealityKit.Scene) {}
func update(context: SceneUpdateContext) {
for entity in context.entities(matching: Self.query, updatingSystemWhen: .rendering) {
guard let spin = entity.components[SpinComponent.self] else { continue }
let angle = spin.speed * Float(context.deltaTime)
entity.transform.rotation *= simd_quatf(angle: angle, axis: spin.axis)
}
}
}
// Register at app launch
struct SpatialApp: App {
init() {
SpinComponent.registerComponent()
SpinSystem.registerSystem()
}
}
Model3D for 3D Model Display
import RealityKit
struct ModelShowcaseView: View {
@State private var selectedModel = "Shoe"
var body: some View {
VStack {
// Simple model display
Model3D(named: selectedModel, bundle: realityKitContentBundle) { model in
model
.resizable()
.aspectRatio(contentMode: .fit)
.frame(depth: 200)
} placeholder: {
ProgressView()
}
.frame(width: 300, height: 300)
.dragRotation(pitchLimit: .degrees(45))
// Model picker
Picker("Model", selection: $selectedModel) {
Text("Shoe").tag("Shoe")
Text("Chair").tag("Chair")
Text("Globe").tag("Globe")
}
.pickerStyle(.segmented)
}
}
}
// Model from URL
struct RemoteModelView: View {
var body: some View {
Model3D(url: URL(string: "https://example.com/model.usdz")!) { phase in
switch phase {
case .empty:
ProgressView()
case .success(let model):
model.resizable().aspectRatio(contentMode: .fit)
case .failure(let error):
Text("Failed: \(error.localizedDescription)")
@unknown default:
EmptyView()
}
}
}
}
Hand Tracking and Gestures
import RealityKit
// Standard gestures on entities
struct InteractiveSceneView: View {
@State private var selectedEntity: Entity?
var body: some View {
RealityView { content in
let sphere = ModelEntity(
mesh: .generateSphere(radius: 0.1),
materials: [SimpleMaterial(color: .blue, isMetallic: true)]
)
sphere.position = [0, 1.5, -1]
sphere.generateCollisionShapes(recursive: false)
sphere.components.set(InputTargetComponent())
sphere.components.set(HoverEffectComponent())
content.add(sphere)
}
.gesture(
TapGesture()
.targetedToAnyEntity()
.onEnded { value in
selectedEntity = value.entity
}
)
.gesture(
DragGesture()
.targetedToAnyEntity()
.onChanged { value in
value.entity.position = value.convert(value.location3D, from: .local, to: value.entity.parent!)
}
)
.gesture(
MagnifyGesture()
.targetedToAnyEntity()
.onChanged { value in
let scale = Float(value.magnification)
value.entity.scale = [scale, scale, scale]
}
)
.gesture(
RotateGesture3D()
.targetedToAnyEntity()
.onChanged { value in
let rotation = value.rotation
value.entity.orientation = simd_quatf(rotation)
}
)
}
}
ARKit Hand Tracking (Advanced)
import ARKit
@Observable
class HandTrackingManager {
let session = ARKitSession()
let handTracking = HandTrackingProvider()
var leftHandPosition: SIMD3<Float>?
var rightHandPosition: SIMD3<Float>?
func startTracking() async {
guard HandTrackingProvider.isSupported else { return }
try? await session.run([handTracking])
for await update in handTracking.anchorUpdates {
let anchor = update.anchor
guard anchor.isTracked else { continue }
let indexTip = anchor.handSkeleton?.joint(.indexFingerTip)
guard let tipTransform = indexTip, tipTransform.isTracked else { continue }
let position = (anchor.originFromAnchorTransform * tipTransform.anchorFromJointTransform).columns.3
let pos = SIMD3<Float>(position.x, position.y, position.z)
switch anchor.chirality {
case .left: leftHandPosition = pos
case .right: rightHandPosition = pos
}
}
}
}
Eye Tracking
// Eye tracking requires user permission and entitlement
// com.apple.developer.arkit.eye-tracking
struct EyeTrackingView: View {
@State private var hoveredItem: String?
var body: some View {
HStack(spacing: 40) {
ForEach(["Photos", "Videos", "Music"], id: \.self) { item in
Text(item)
.font(.title)
.padding(30)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(hoveredItem == item ? .blue.opacity(0.3) : .clear)
)
.hoverEffect(.highlight) // System hover effect
.onHover { isHovering in
hoveredItem = isHovering ? item : nil
}
}
}
}
}
// Custom hover effects
struct CustomHoverView: View {
var body: some View {
Text("Look at me")
.padding()
.hoverEffect { effect, isActive, _ in
effect
.scaleEffect(isActive ? 1.1 : 1.0)
.animation(.spring(duration: 0.3), value: isActive)
}
}
}
Spatial Audio
import RealityKit
struct SpatialAudioScene: View {
var body: some View {
RealityView { content in
// Create audio source entity
let audioEntity = Entity()
audioEntity.position = [2, 1.5, -3]
// Load and configure spatial audio
let resource = try! AudioFileResource.load(
named: "ambience.wav",
configuration: .init(
loadingStrategy: .preload,
shouldLoop: true
)
)
let audioController = audioEntity.prepareAudio(resource)
audioController.gain = -10 // dB
audioController.play()
// Spatial audio is automatic — position in 3D space determines directionality
content.add(audioEntity)
// Ambient audio (non-spatial, everywhere)
let ambientEntity = Entity()
ambientEntity.spatialAudio = SpatialAudioComponent(directivity: .beam(focus: 0))
content.add(ambientEntity)
}
}
}
SharePlay in Spatial Apps
import GroupActivities
struct WatchTogetherActivity: GroupActivity {
static let activityIdentifier = "com.app.watchtogether"
var metadata: GroupActivityMetadata {
var meta = GroupActivityMetadata()
meta.title = "Watch Together"
meta.type = .watchTogether
meta.supportsContinuationOnTV = true
return meta
}
}
@Observable
class SharePlayManager {
var session: GroupSession<WatchTogetherActivity>?
func startSharing() async {
let activity = WatchTogetherActivity()
let result = await activity.prepareForActivation()
switch result {
case .activationPreferred:
_ = try? await activity.activate()
case .activationDisabled:
break
default: break
}
}
func configureSession() async {
for await session in WatchTogetherActivity.sessions() {
self.session = session
session.join()
// Spatial Persona template
let template = SpatialTemplate(
elements: [
.seat(position: .app.offsetBy(x: -0.5)),
.seat(position: .app.offsetBy(x: 0.5))
]
)
session.spatialTemplatePreference = .init(template)
// Receive messages
for await message in session.messenger.messages(of: SyncMessage.self) {
handleMessage(message)
}
}
}
}
Ornaments and Attachments
struct OrnamentedWindow: View {
@State private var showControls = true
var body: some View {
VStack {
Text("Main Content")
.font(.largeTitle)
}
.frame(width: 600, height: 400)
.ornament(
visibility: showControls ? .visible : .hidden,
attachmentAnchor: .scene(.bottom)
) {
HStack(spacing: 20) {
Button(action: {}) {
Label("Previous", systemImage: "backward.fill")
}
Button(action: {}) {
Label("Play", systemImage: "play.fill")
}
Button(action: {}) {
Label("Next", systemImage: "forward.fill")
}
}
.padding()
.glassBackgroundEffect()
}
.ornament(attachmentAnchor: .scene(.trailing)) {
VStack {
Button(action: {}) { Image(systemName: "heart") }
Button(action: {}) { Image(systemName: "square.and.arrow.up") }
Button(action: {}) { Image(systemName: "info.circle") }
}
.padding()
.glassBackgroundEffect()
}
}
}
Passthrough and Mixed Reality
struct MixedRealityView: View {
@State private var showPassthrough = true
var body: some View {
RealityView { content in
// Content appears in the user's real environment
let anchor = AnchorEntity(.plane(.horizontal, classification: .table, minimumBounds: [0.3, 0.3]))
let box = ModelEntity(
mesh: .generateBox(size: 0.2, cornerRadius: 0.02),
materials: [SimpleMaterial(color: .blue, isMetallic: true)]
)
box.position.y = 0.1
box.generateCollisionShapes(recursive: false)
box.components.set(InputTargetComponent())
anchor.addChild(box)
content.add(anchor)
// Occlusion — virtual objects hidden behind real objects
let occlusionMaterial = OcclusionMaterial()
let floor = ModelEntity(mesh: .generatePlane(width: 5, depth: 5), materials: [occlusionMaterial])
content.add(floor)
}
.upperLimbVisibility(showPassthrough ? .automatic : .hidden)
}
}
// World sensing
struct WorldSensingView: View {
var body: some View {
RealityView { content in
// Requires WorldSensing entitlement
let arSession = ARKitSession()
let worldTracking = WorldTrackingProvider()
let planeDetection = PlaneDetectionProvider()
try? await arSession.run([worldTracking, planeDetection])
for await update in planeDetection.anchorUpdates {
let plane = update.anchor
// Place content on detected surfaces
}
}
}
}
visionOS Design Guidelines
Aspect
Recommendation
Window placement
Let system place windows; user repositions
Glass material
Use .glassBackgroundEffect() for window backgrounds
Depth
Use subtle depth; avoid extreme z-positioning
Ergonomics
Place content in comfortable viewing range (1-3m)
Eye comfort
Avoid rapid movement; use gentle animations
Hover effects
Always provide .hoverEffect for interactive elements
Ornaments
Use for controls related to window content
Immersion
Start with .mixed; let user choose deeper immersion
Hand gestures
Support standard tap, drag, magnify, rotate
Spatial audio
Position audio sources to match visual positions
---
# watchOS Platform Guide
https://nagarjuna2997.github.io/ios-agent-skill/guides/platforms-watchos.html
← Back to all frameworks & guides
All articles →Platforms · Reference guidewatchOS Platform GuideRepository guidance for watchOS. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
WatchKit App Structure
SwiftUI App Lifecycle (watchOS 7+)
Navigation Patterns
Complications (WidgetKit on watchOS)
Watch Connectivity (WCSession)
HealthKit Workouts
Digital Crown
Always-On Display
Background App Refresh
watchOS Design Guidelines
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Define a short watch task
↓2
Coordinate local and phone data
↓3
Handle background transitions
↓4
Test accessible watch UI
02 / ArchitectureResponsibility boundariesBoundary 1
Watch interfaceBoundary 2
Watch servicesBoundary 3
Phone connectivityConnected responsibilities, not a required class hierarchy or an execution trace.
WatchKit App Structure
SwiftUI App Lifecycle (watchOS 7+)
@main
struct MyWatchApp: App {
@WKApplicationDelegateAdaptor(AppDelegate.self) var delegate
var body: some Scene {
WindowGroup {
NavigationStack {
ContentView()
}
}
}
}
class AppDelegate: NSObject, WKApplicationDelegate {
func applicationDidFinishLaunching() {
// Setup HealthKit, WCSession, etc.
WatchConnectivityManager.shared.activate()
}
func applicationDidBecomeActive() { }
func applicationWillResignActive() { }
func handle(_ backgroundTasks: Set<WKRefreshBackgroundTask>) {
for task in backgroundTasks {
switch task {
case let refreshTask as WKApplicationRefreshBackgroundTask:
scheduleNextRefresh()
refreshTask.setTaskCompletedWithSnapshot(false)
case let snapshotTask as WKSnapshotRefreshBackgroundTask:
snapshotTask.setTaskCompleted(restoredDefaultState: true, estimatedSnapshotExpiration: .distantFuture, userInfo: nil)
case let urlTask as WKURLSessionRefreshBackgroundTask:
urlTask.setTaskCompletedWithSnapshot(false)
default:
task.setTaskCompletedWithSnapshot(false)
}
}
}
}
Navigation Patterns
struct ContentView: View {
var body: some View {
NavigationStack {
List {
NavigationLink("Workout") { WorkoutView() }
NavigationLink("Activity") { ActivityView() }
NavigationLink("Settings") { SettingsView() }
}
.navigationTitle("My App")
}
}
}
// Tab-based layout
struct TabContentView: View {
var body: some View {
TabView {
DashboardView()
WorkoutListView()
SettingsView()
}
.tabViewStyle(.verticalPage)
}
}
Complications (WidgetKit on watchOS)
import WidgetKit
import SwiftUI
struct StepsWidget: Widget {
let kind = "StepsWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: StepsProvider()) { entry in
StepsWidgetView(entry: entry)
}
.configurationDisplayName("Steps")
.description("Track your daily steps.")
.supportedFamilies([
.accessoryCircular,
.accessoryRectangular,
.accessoryInline,
.accessoryCorner
])
}
}
struct StepsEntry: TimelineEntry {
let date: Date
let steps: Int
let goal: Int
}
struct StepsProvider: TimelineProvider {
func placeholder(in context: Context) -> StepsEntry {
StepsEntry(date: .now, steps: 5000, goal: 10000)
}
func getSnapshot(in context: Context, completion: @escaping (StepsEntry) -> Void) {
completion(StepsEntry(date: .now, steps: 7500, goal: 10000))
}
func getTimeline(in context: Context, completion: @escaping (Timeline<StepsEntry>) -> Void) {
Task {
let steps = await HealthKitManager.shared.fetchTodaySteps()
let entry = StepsEntry(date: .now, steps: Int(steps), goal: 10000)
let nextUpdate = Calendar.current.date(byAdding: .minute, value: 15, to: .now)!
let timeline = Timeline(entries: [entry], policy: .after(nextUpdate))
completion(timeline)
}
}
}
struct StepsWidgetView: View {
let entry: StepsEntry
@Environment(\.widgetFamily) var family
var body: some View {
switch family {
case .accessoryCircular:
Gauge(value: Double(entry.steps), in: 0...Double(entry.goal)) {
Text("\(entry.steps)")
}
.gaugeStyle(.accessoryCircularCapacity)
case .accessoryRectangular:
VStack(alignment: .leading) {
Text("Steps")
.font(.headline)
.widgetAccentable()
Text("\(entry.steps) / \(entry.goal)")
.font(.caption)
ProgressView(value: Double(entry.steps), total: Double(entry.goal))
}
case .accessoryInline:
Text("Steps: \(entry.steps)")
default:
Text("\(entry.steps)")
}
}
}
Watch Connectivity (WCSession)
import WatchConnectivity
class WatchConnectivityManager: NSObject, ObservableObject, WCSessionDelegate {
static let shared = WatchConnectivityManager()
@Published var receivedMessage: [String: Any] = [:]
func activate() {
guard WCSession.isSupported() else { return }
WCSession.default.delegate = self
WCSession.default.activate()
}
// MARK: - Send data (phone <-> watch)
/// Immediate messaging (both apps must be reachable)
func sendMessage(_ message: [String: Any]) {
guard WCSession.default.isReachable else { return }
WCSession.default.sendMessage(message, replyHandler: { reply in
print("Reply: \(reply)")
}, errorHandler: { error in
print("Error: \(error)")
})
}
/// Background transfer - application context (latest state)
func updateContext(_ context: [String: Any]) {
try? WCSession.default.updateApplicationContext(context)
}
/// Background transfer - user info queue (guaranteed delivery)
func transferUserInfo(_ info: [String: Any]) {
WCSession.default.transferUserInfo(info)
}
/// File transfer
func transferFile(_ url: URL, metadata: [String: Any]? = nil) {
WCSession.default.transferFile(url, metadata: metadata)
}
// MARK: - WCSessionDelegate
func session(_ session: WCSession, activationDidCompleteWith state: WCSessionActivationState, error: Error?) {
print("WCSession activated: \(state.rawValue)")
}
func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
DispatchQueue.main.async { self.receivedMessage = message }
}
func session(_ session: WCSession, didReceiveApplicationContext context: [String: Any]) {
DispatchQueue.main.async { self.receivedMessage = context }
}
// iOS only
#if os(iOS)
func sessionDidBecomeInactive(_ session: WCSession) { }
func sessionDidDeactivate(_ session: WCSession) {
WCSession.default.activate()
}
#endif
}
HealthKit Workouts
import HealthKit
@Observable
class WorkoutManager: NSObject, HKWorkoutSessionDelegate, HKLiveWorkoutBuilderDelegate {
let store = HKHealthStore()
var session: HKWorkoutSession?
var builder: HKLiveWorkoutBuilder?
var heartRate: Double = 0
var activeCalories: Double = 0
var elapsedTime: TimeInterval = 0
var isActive = false
func startWorkout(type: HKWorkoutActivityType) async throws {
let config = HKWorkoutConfiguration()
config.activityType = type
config.locationType = .outdoor
session = try HKWorkoutSession(healthStore: store, configuration: config)
builder = session?.associatedWorkoutBuilder()
session?.delegate = self
builder?.delegate = self
builder?.dataSource = HKLiveWorkoutDataSource(healthStore: store, workoutConfiguration: config)
let start = Date()
session?.startActivity(with: start)
try await builder?.beginCollection(at: start)
isActive = true
}
func pause() { session?.pause() }
func resume() { session?.resume() }
func endWorkout() async throws {
session?.end()
try await builder?.endCollection(at: .now)
try await builder?.finishWorkout()
isActive = false
}
// MARK: - HKWorkoutSessionDelegate
func workoutSession(_ session: HKWorkoutSession, didChangeTo toState: HKWorkoutSessionState, from fromState: HKWorkoutSessionState, date: Date) {
DispatchQueue.main.async {
self.isActive = toState == .running
}
}
func workoutSession(_ session: HKWorkoutSession, didFailWithError error: Error) { }
// MARK: - HKLiveWorkoutBuilderDelegate
func workoutBuilderDidCollectEvent(_ workoutBuilder: HKLiveWorkoutBuilder) { }
func workoutBuilder(_ workoutBuilder: HKLiveWorkoutBuilder, didCollectDataOf collectedTypes: Set<HKSampleType>) {
for type in collectedTypes {
guard let quantityType = type as? HKQuantityType else { continue }
let stats = workoutBuilder.statistics(for: quantityType)
DispatchQueue.main.async {
switch quantityType {
case HKQuantityType(.heartRate):
self.heartRate = stats?.mostRecentQuantity()?.doubleValue(for: .count().unitDivided(by: .minute())) ?? 0
case HKQuantityType(.activeEnergyBurned):
self.activeCalories = stats?.sumQuantity()?.doubleValue(for: .kilocalorie()) ?? 0
default: break
}
}
}
}
}
Digital Crown
struct CrownScrollView: View {
@State private var crownValue: Double = 0
@State private var isCrownIdle = true
var body: some View {
VStack {
Text("Value: \(crownValue, specifier: "%.1f")")
.font(.largeTitle)
Gauge(value: crownValue, in: 0...100) {
Text("Level")
}
.gaugeStyle(.accessoryLinearCapacity)
}
.focusable()
.digitalCrownRotation(
$crownValue,
from: 0,
through: 100,
by: 1,
sensitivity: .medium,
isContinuous: false,
isHapticFeedbackEnabled: true
)
.onChange(of: crownValue) {
isCrownIdle = false
}
}
}
// Crown with detents
struct DetentCrownView: View {
@State private var selectedIndex: Int = 0
let options = ["Small", "Medium", "Large", "Extra Large"]
var body: some View {
Text(options[selectedIndex])
.font(.title3)
.focusable()
.digitalCrownRotation(
detent: $selectedIndex,
from: 0,
through: options.count - 1,
by: 1,
sensitivity: .low,
isContinuous: false,
isHapticFeedbackEnabled: true
) { event in
// Detent feedback
}
}
}
Always-On Display
struct WorkoutActiveView: View {
@Environment(\.isLuminanceReduced) var isLuminanceReduced
let heartRate: Double
let duration: TimeInterval
var body: some View {
VStack {
if isLuminanceReduced {
// Simplified always-on display
TimelineView(.periodic(from: .now, by: 1)) { context in
Text(duration.formatted(.time(pattern: .hourMinuteSecond)))
.font(.title2)
}
Text("\(Int(heartRate)) BPM")
.foregroundStyle(.red)
} else {
// Full interactive display
TimelineView(.periodic(from: .now, by: 1)) { context in
Text(duration.formatted(.time(pattern: .hourMinuteSecond)))
.font(.system(size: 40, weight: .bold, design: .rounded))
}
HStack {
Image(systemName: "heart.fill")
.foregroundStyle(.red)
Text("\(Int(heartRate)) BPM")
}
.font(.title3)
}
}
}
}
Background App Refresh
// Schedule refresh
func scheduleNextRefresh() {
WKExtension.shared().scheduleBackgroundRefresh(
withPreferredDate: Date(timeIntervalSinceNow: 15 * 60),
userInfo: nil
) { error in
if let error { print("Scheduling failed: \(error)") }
}
}
// Handle in AppDelegate.handle(_:) — see App Structure section above
// Background URLSession
func scheduleBackgroundDownload() {
let config = URLSessionConfiguration.background(withIdentifier: "com.app.watch.background")
config.isDiscretionary = false
config.sessionSendsLaunchEvents = true
let session = URLSession(configuration: config, delegate: self, delegateQueue: nil)
let task = session.downloadTask(with: URL(string: "https://api.example.com/data")!)
task.resume()
}
watchOS Design Guidelines
Aspect
Recommendation
Glance-ability
Show key info at a glance; minimize scrolling
Interactions
Keep to 2-3 taps maximum per task
Text input
Prefer voice, scribble, or preset options
Complications
Always provide at least one complication family
Haptics
Use WKInterfaceDevice.default().play(.success) for feedback
Layout
Use vertical stacks; avoid horizontal scrolling
Always-on
Reduce luminance, hide seconds, dim colors
---
# SwiftUI Animations
https://nagarjuna2997.github.io/ios-agent-skill/guides/swiftui-animations.html
← Back to all frameworks & guides
All articles →Design and Motion · Reference guideSwiftUI AnimationsRepository guidance for SwiftUI Animation. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
Implicit Animations (.animation modifier)
Explicit Animations (withAnimation)
Animation Types
Built-in Animations
Animation Modifiers
Transitions
Custom Transitions
matchedGeometryEffect
PhaseAnimator (iOS 17+)
KeyframeAnimator (iOS 17+)
.contentTransition() (iOS 16+)
.sensoryFeedback() (iOS 17+)
Symbol Effects (iOS 17+)
Transaction and Animation Completion
Transaction
Animation Completion (iOS 17+)
Practical Animation Patterns
Staggered List Animation
Loading Shimmer
Breathing / Pulsing Effect
Performance Tips
iOS 17/18 Advanced Visual Effects
visualEffect Modifier (iOS 17+)
Complete Parallax Scroll Example
Position-Based Rotation and Scale
scrollTransition (iOS 17+)
Complete Example: Cards That Scale Up as They Enter
Horizontal Scroll with Rotation
MeshGradient (iOS 18+)
Complete Animated Mesh Gradient Background
Mesh Gradient with Color Cycling
TextRenderer Protocol (iOS 18+)
TextRenderer Protocol Basics
Complete Custom Text Animation: Per-Character Fade-In
Typing Effect with TextRenderer
Wave Text Animation
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Identify state transition
↓2
Choose animation semantics
↓3
Apply a scoped animation
↓4
Respect reduced motion
02 / ArchitectureResponsibility boundariesBoundary 1
State changeBoundary 2
Animation transactionBoundary 3
View transitionConnected responsibilities, not a required class hierarchy or an execution trace.
Complete reference for implicit and explicit animations, transitions, matched geometry, phase animators, keyframe animators, and haptics.
Implicit Animations (.animation modifier)
Attach an animation to a view that triggers whenever a tracked value changes.
struct ImplicitExample: View {
@State private var isExpanded = false
var body: some View {
VStack {
RoundedRectangle(cornerRadius: isExpanded ? 20 : 50)
.fill(.blue)
.frame(
width: isExpanded ? 300 : 100,
height: isExpanded ? 200 : 100
)
.animation(.easeInOut(duration: 0.4), value: isExpanded)
Button("Toggle") { isExpanded.toggle() }
}
}
}
Important:
Always use the
value:
parameter version. The parameterless
.animation(.easeInOut)
is deprecated and applies to all state changes, causing unexpected behavior.
Explicit Animations (withAnimation)
Wrap state changes in
withAnimation
to animate all views affected by those changes.
struct ExplicitExample: View {
@State private var offset: CGFloat = 0
@State private var opacity: Double = 1
@State private var scale: CGFloat = 1
var body: some View {
Circle()
.fill(.blue)
.frame(width: 80, height: 80)
.offset(y: offset)
.opacity(opacity)
.scaleEffect(scale)
Button("Animate") {
withAnimation(.spring(duration: 0.6, bounce: 0.3)) {
offset = offset == 0 ? -100 : 0
opacity = opacity == 1 ? 0.5 : 1
scale = scale == 1 ? 1.5 : 1
}
}
}
}
// Async version
Button("Animate and Continue") {
Task {
await withAnimation(.easeInOut(duration: 0.5)) {
showContent = true
}.value
// This runs after animation completes
loadData()
}
}
Animation Types
Built-in Animations
// Timing curves
.animation(.linear(duration: 0.3), value: trigger)
.animation(.easeIn(duration: 0.3), value: trigger)
.animation(.easeOut(duration: 0.3), value: trigger)
.animation(.easeInOut(duration: 0.3), value: trigger)
// Spring animations (iOS 17+ simplified)
.animation(.spring, value: trigger) // Default spring
.animation(.bouncy, value: trigger) // High bounce
.animation(.bouncy(duration: 0.5, extraBounce: 0.2), value: trigger)
.animation(.snappy, value: trigger) // Quick and snappy
.animation(.snappy(duration: 0.3, extraBounce: 0.1), value: trigger)
.animation(.smooth, value: trigger) // Smooth, no bounce
.animation(.smooth(duration: 0.4), value: trigger)
// Spring with full control
.animation(.spring(response: 0.5, dampingFraction: 0.7, blendDuration: 0), value: trigger)
.animation(.spring(duration: 0.5, bounce: 0.3), value: trigger)
// Interactive spring (no overshoot)
.animation(.interactiveSpring(response: 0.3, dampingFraction: 0.8), value: trigger)
// Custom timing curve
.animation(.timingCurve(0.2, 0.8, 0.2, 1.0, duration: 0.5), value: trigger)
Animation Modifiers
// Delay
.animation(.easeInOut(duration: 0.5).delay(0.2), value: trigger)
// Repeat
.animation(.easeInOut(duration: 1).repeatForever(autoreverses: true), value: trigger)
.animation(.linear(duration: 2).repeatCount(3, autoreverses: false), value: trigger)
// Speed
.animation(.spring.speed(2), value: trigger)
// Combine
.animation(
.spring(duration: 0.6, bounce: 0.3)
.delay(0.1)
.speed(1.2),
value: trigger
)
Transitions
Transitions define how a view appears and disappears when inserted/removed from the hierarchy.
struct TransitionExample: View {
@State private var showDetail = false
var body: some View {
VStack {
if showDetail {
DetailCard()
.transition(.opacity) // Fade
.transition(.slide) // Slide from leading
.transition(.scale) // Scale from center
.transition(.scale(scale: 0.5, anchor: .bottom))
.transition(.move(edge: .bottom)) // Slide from edge
.transition(.push(from: .bottom)) // Push (iOS 16+)
.transition(.offset(x: 0, y: 200))
// Combined
.transition(.opacity.combined(with: .scale))
.transition(.move(edge: .bottom).combined(with: .opacity))
// Asymmetric (different for insert vs remove)
.transition(.asymmetric(
insertion: .push(from: .trailing),
removal: .push(from: .leading)
))
}
Button("Toggle") {
withAnimation(.spring(duration: 0.5, bounce: 0.3)) {
showDetail.toggle()
}
}
}
}
}
Custom Transitions
struct SlideAndFade: Transition {
func body(content: Content, phase: TransitionPhase) -> some View {
content
.opacity(phase.isIdentity ? 1 : 0)
.offset(y: phase.isIdentity ? 0 : 30)
.scaleEffect(phase.isIdentity ? 1 : 0.95)
}
}
extension AnyTransition {
static var slideAndFade: AnyTransition {
.modifier(
active: SlideAndFadeModifier(opacity: 0, offset: 30),
identity: SlideAndFadeModifier(opacity: 1, offset: 0)
)
}
}
struct SlideAndFadeModifier: ViewModifier {
let opacity: Double
let offset: CGFloat
func body(content: Content) -> some View {
content
.opacity(opacity)
.offset(y: offset)
}
}
matchedGeometryEffect
Creates hero animations between views that share an identity.
struct HeroAnimation: View {
@Namespace private var animation
@State private var isExpanded = false
@State private var selectedItem: Item?
var body: some View {
ZStack {
if let item = selectedItem {
// Expanded view
VStack {
Image(item.imageName)
.resizable()
.aspectRatio(contentMode: .fill)
.matchedGeometryEffect(id: "image-\(item.id)", in: animation)
.frame(height: 300)
.clipShape(RoundedRectangle(cornerRadius: 20))
Text(item.title)
.font(.title)
.matchedGeometryEffect(id: "title-\(item.id)", in: animation)
Text(item.description)
.padding()
Spacer()
}
.background(.background)
.onTapGesture {
withAnimation(.spring(duration: 0.5, bounce: 0.2)) {
selectedItem = nil
}
}
} else {
// Grid view
ScrollView {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 150))]) {
ForEach(items) { item in
VStack {
Image(item.imageName)
.resizable()
.aspectRatio(contentMode: .fill)
.matchedGeometryEffect(id: "image-\(item.id)", in: animation)
.frame(height: 150)
.clipShape(RoundedRectangle(cornerRadius: 12))
Text(item.title)
.font(.headline)
.matchedGeometryEffect(id: "title-\(item.id)", in: animation)
}
.onTapGesture {
withAnimation(.spring(duration: 0.5, bounce: 0.2)) {
selectedItem = item
}
}
}
}
.padding()
}
}
}
}
}
Key rules:
- Both source and destination must be visible at transition time (use
ZStack
and conditional rendering).
- Use the same
id
and
Namespace
across both states.
- Only one view per
id
should be in the hierarchy at a time (use
isSource: false
if needed).
PhaseAnimator (iOS 17+)
Cycles through a sequence of phases, applying different modifiers at each phase.
// Continuous animation
PhaseAnimator([false, true]) { phase in
Image(systemName: "heart.fill")
.font(.largeTitle)
.foregroundStyle(.red)
.scaleEffect(phase ? 1.2 : 1.0)
.opacity(phase ? 1.0 : 0.7)
} animation: { phase in
phase ? .easeIn(duration: 0.3) : .easeOut(duration: 0.5)
}
// Multi-phase animation
enum PulsePhase: CaseIterable {
case initial, grow, shrink, fade
var scale: CGFloat {
switch self {
case .initial: 1.0
case .grow: 1.3
case .shrink: 0.9
case .fade: 1.0
}
}
var opacity: Double {
switch self {
case .initial: 1.0
case .grow: 0.8
case .shrink: 0.6
case .fade: 1.0
}
}
}
PhaseAnimator(PulsePhase.allCases) { phase in
Circle()
.fill(.blue)
.frame(width: 100, height: 100)
.scaleEffect(phase.scale)
.opacity(phase.opacity)
} animation: { phase in
switch phase {
case .initial: .spring(duration: 0.3)
case .grow: .easeOut(duration: 0.4)
case .shrink: .easeIn(duration: 0.2)
case .fade: .easeInOut(duration: 0.3)
}
}
// Trigger-based (animates once when trigger changes)
@State private var triggerCount = 0
PhaseAnimator([0.0, 1.0], trigger: triggerCount) { scale in
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 60))
.scaleEffect(scale == 0 ? 0.5 : 1.0)
.opacity(scale)
} animation: { _ in
.spring(duration: 0.5, bounce: 0.4)
}
Button("Complete") { triggerCount += 1 }
KeyframeAnimator (iOS 17+)
Define complex multi-property animations with precise timing using keyframe tracks.
struct BounceValues {
var scale: CGFloat = 1.0
var yOffset: CGFloat = 0
var rotation: Angle = .zero
}
KeyframeAnimator(initialValue: BounceValues(), trigger: bounceCount) { values in
Image(systemName: "star.fill")
.font(.system(size: 60))
.foregroundStyle(.yellow)
.scaleEffect(values.scale)
.offset(y: values.yOffset)
.rotationEffect(values.rotation)
} keyframes: { _ in
KeyframeTrack(\.scale) {
SpringKeyframe(1.5, duration: 0.2)
SpringKeyframe(0.8, duration: 0.15)
SpringKeyframe(1.0, duration: 0.3)
}
KeyframeTrack(\.yOffset) {
LinearKeyframe(-50, duration: 0.15)
SpringKeyframe(0, duration: 0.4, spring: .bouncy)
}
KeyframeTrack(\.rotation) {
LinearKeyframe(.degrees(0), duration: 0.1)
CubicKeyframe(.degrees(15), duration: 0.15)
CubicKeyframe(.degrees(-10), duration: 0.15)
SpringKeyframe(.degrees(0), duration: 0.3)
}
}
Keyframe types:
-
LinearKeyframe
-- constant speed between keyframes.
-
SpringKeyframe
-- spring dynamics to reach the value.
-
CubicKeyframe
-- cubic Bezier curve interpolation.
-
MoveKeyframe
-- instant jump to value (no interpolation).
.contentTransition() (iOS 16+)
Animate text and numeric changes.
@State private var count = 0
Text("\(count)")
.font(.largeTitle)
.contentTransition(.numericText(countsDown: false))
// The text morphs between numeric values
Button("Increment") {
withAnimation(.snappy) {
count += 1
}
}
// Identity transition for text changes
Text(statusMessage)
.contentTransition(.interpolate) // Smooth morphing between text states
.sensoryFeedback() (iOS 17+)
Trigger haptic feedback tied to state changes.
Button("Success") {
showSuccess = true
}
.sensoryFeedback(.success, trigger: showSuccess)
Toggle("Setting", isOn: $isEnabled)
.sensoryFeedback(.selection, trigger: isEnabled)
// Feedback types
.sensoryFeedback(.impact, trigger: value) // Physical tap
.sensoryFeedback(.impact(weight: .heavy), trigger: value)
.sensoryFeedback(.impact(intensity: 0.8), trigger: value)
.sensoryFeedback(.selection, trigger: value) // Light selection tick
.sensoryFeedback(.success, trigger: value) // Success pattern
.sensoryFeedback(.warning, trigger: value) // Warning pattern
.sensoryFeedback(.error, trigger: value) // Error pattern
.sensoryFeedback(.increase, trigger: value) // Value increasing
.sensoryFeedback(.decrease, trigger: value) // Value decreasing
// Conditional feedback
.sensoryFeedback(.success, trigger: taskComplete) { oldValue, newValue in
newValue == true // Only trigger when becoming true
}
Symbol Effects (iOS 17+)
Built-in animations for SF Symbols.
Image(systemName: "wifi")
.symbolEffect(.variableColor.iterative)
Image(systemName: "bell")
.symbolEffect(.bounce, value: notificationCount)
Image(systemName: "arrow.down.circle")
.symbolEffect(.pulse, isActive: isDownloading)
Image(systemName: "checkmark.circle")
.symbolEffect(.appear, isActive: showCheck)
.symbolEffect(.disappear, isActive: hideCheck)
// Replace symbol with animation
Image(systemName: isPlaying ? "pause.fill" : "play.fill")
.contentTransition(.symbolEffect(.replace))
Transaction and Animation Completion
Transaction
Override animations for specific state changes.
// Disable animation for specific change
var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction) {
showPanel = false
}
// Custom transaction
var transaction = Transaction(animation: .spring(duration: 0.3))
withTransaction(transaction) {
selectedTab = .home
}
// Transaction modifier on a view
Text("No animation here")
.transaction { transaction in
transaction.animation = nil // Suppress animations for this view
}
Animation Completion (iOS 17+)
withAnimation(.easeInOut(duration: 0.5)) {
isVisible = true
} completion: {
// Runs when animation finishes
loadNextStep()
}
Practical Animation Patterns
Staggered List Animation
struct StaggeredList: View {
@State private var items: [Item] = []
@State private var visibleItems: Set<UUID> = []
var body: some View {
ScrollView {
LazyVStack(spacing: 12) {
ForEach(Array(items.enumerated()), id: \.element.id) { index, item in
ItemRow(item: item)
.opacity(visibleItems.contains(item.id) ? 1 : 0)
.offset(y: visibleItems.contains(item.id) ? 0 : 20)
.onAppear {
withAnimation(.spring(duration: 0.4).delay(Double(index) * 0.05)) {
visibleItems.insert(item.id)
}
}
}
}
.padding()
}
}
}
Loading Shimmer
struct ShimmerView: View {
@State private var phase: CGFloat = -1
var body: some View {
RoundedRectangle(cornerRadius: 8)
.fill(.gray.opacity(0.3))
.overlay {
RoundedRectangle(cornerRadius: 8)
.fill(
LinearGradient(
colors: [.clear, .white.opacity(0.4), .clear],
startPoint: .init(x: phase, y: 0.5),
endPoint: .init(x: phase + 1, y: 0.5)
)
)
}
.onAppear {
withAnimation(.linear(duration: 1.5).repeatForever(autoreverses: false)) {
phase = 2
}
}
}
}
Breathing / Pulsing Effect
struct PulsingDot: View {
@State private var isPulsing = false
var body: some View {
Circle()
.fill(.green)
.frame(width: 12, height: 12)
.overlay {
Circle()
.stroke(.green, lineWidth: 2)
.scaleEffect(isPulsing ? 2.5 : 1)
.opacity(isPulsing ? 0 : 1)
}
.onAppear {
withAnimation(.easeOut(duration: 1.5).repeatForever(autoreverses: false)) {
isPulsing = true
}
}
}
}
Performance Tips
Prefer
withAnimation
over
.animation(value:)
when only some state changes should animate.
Use
.drawingGroup()
on complex animated views to rasterize into a single Metal layer.
Avoid animating
GeometryReader
size changes -- they cause expensive relayouts.
Use
Animation.spring
over custom spring parameters when possible -- the system optimizes it.
Profile with Instruments (Core Animation template) to verify 60fps.
Reduce use of
blur
and
shadow
modifiers during active animations.
iOS 17/18 Advanced Visual Effects
visualEffect Modifier (iOS 17+)
The
.visualEffect
modifier lets you read a view's geometry proxy and apply visual transforms without triggering layout passes. Unlike
GeometryReader
, it never changes the size or position of the view in the layout -- it only applies render-time effects like scale, offset, rotation, opacity, and blur.
// Basic usage -- read size and position for visual-only transforms
Rectangle()
.fill(.blue)
.frame(width: 200, height: 200)
.visualEffect { content, proxy in
content
.offset(y: proxy.frame(in: .global).minY * 0.1)
.scaleEffect(proxy.size.width / 300)
}
When to use visualEffect instead of GeometryReader:
- Parallax scrolling effects
- Scale or rotation based on scroll position
- Blur or opacity that depends on a view's position
- Any visual tweak that should not alter layout
Complete Parallax Scroll Example
struct ParallaxScrollView: View {
let images = ["photo1", "photo2", "photo3", "photo4", "photo5"]
var body: some View {
ScrollView {
LazyVStack(spacing: 16) {
ForEach(images, id: \.self) { imageName in
ParallaxCard(imageName: imageName)
}
}
.padding()
}
}
}
struct ParallaxCard: View {
let imageName: String
var body: some View {
RoundedRectangle(cornerRadius: 20)
.fill(.gray.opacity(0.2))
.frame(height: 250)
.overlay {
Image(imageName)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(height: 300) // Slightly taller than container
.visualEffect { content, proxy in
let frame = proxy.frame(in: .scrollView(axis: .vertical))
let distance = min(0, frame.minY)
return content
.offset(y: -distance * 0.4)
}
}
.clipShape(RoundedRectangle(cornerRadius: 20))
.shadow(color: .black.opacity(0.15), radius: 10, y: 5)
}
}
Position-Based Rotation and Scale
struct VisualEffectGallery: View {
let colors: [Color] = [.red, .orange, .yellow, .green, .blue, .purple]
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 16) {
ForEach(colors, id: \.self) { color in
RoundedRectangle(cornerRadius: 16)
.fill(color.gradient)
.frame(width: 200, height: 280)
.visualEffect { content, proxy in
let frame = proxy.frame(in: .scrollView(axis: .horizontal))
let midX = frame.midX
let screenMidX = UIScreen.main.bounds.width / 2
let distance = abs(midX - screenMidX)
let maxDistance: CGFloat = 300
let normalizedDistance = min(distance / maxDistance, 1.0)
return content
.scaleEffect(1.0 - normalizedDistance * 0.15)
.rotation3DEffect(
.degrees(Double(midX - screenMidX) / 10),
axis: (x: 0, y: 1, z: 0)
)
.opacity(1.0 - Double(normalizedDistance) * 0.3)
}
}
}
.padding(.horizontal, 80)
}
.scrollTargetBehavior(.viewAligned)
}
}
scrollTransition (iOS 17+)
The
.scrollTransition
modifier animates views as they enter and leave the visible area of a
ScrollView
. It provides a
phase
value that indicates where the view is relative to the viewport.
Phase values:
-
.topLeading
-- the view is above or to the leading side of the viewport (about to enter from top/leading).
-
.identity
-- the view is fully within the visible area.
-
.bottomTrailing
-- the view is below or to the trailing side of the viewport (about to exit bottom/trailing).
The closure receives a VisualEffect, not a View.
This is the single most
important fact about the modifier and it is easy to miss, because the parameter
is conventionally named
content
and every example uses it like a view.
VisualEffect
supports only effects that can be applied to already-rendered
content on the GPU:
Supported inside scrollTransition
Not supported
.opacity(_:)
.shadow(...)
.scaleEffect(_:anchor:)
.background(...) / .overlay(...)
.rotationEffect(_:anchor:)
.blur(radius:) on some OS versions
.rotation3DEffect(_:axis:anchor:perspective:)
.foregroundStyle(...)
.offset(x:y:)
.clipShape(...) / .mask(...)
.brightness, .saturation, .contrast, .grayscale, .hueRotation
any layout modifier
.blendMode(_:)
anything that changes the view's identity or size
Reaching for
.shadow
inside the closure — the obvious way to make scrolling
cards read as receding — produces one of Swift's worst diagnostics:
// WRONG — .shadow is not a VisualEffect member.
.scrollTransition { content, phase in
content
.opacity(phase.isIdentity ? 1 : 0.4)
.shadow(radius: phase.isIdentity ? 20 : 4)
}
// error: the compiler is unable to type-check this expression
// in reasonable time; try breaking up the expression into
// distinct sub-expressions
That message points at expression complexity, which is not the problem. The
problem is a missing overload, and no amount of breaking up the expression will
fix it.
// RIGHT — the shadow lives outside the transition, driven by the same phase
// through a plain modifier on the view itself.
RoundedRectangle(cornerRadius: 16)
.fill(.blue.gradient)
.shadow(color: .black.opacity(0.15), radius: 12, y: 6)
.scrollTransition { content, phase in
content
.opacity(phase.isIdentity ? 1 : 0.4)
.scaleEffect(phase.isIdentity ? 1 : 0.92)
}
If a shadow genuinely has to change with scroll position, drive it from
GeometryReader
or
onScrollGeometryChange
and apply it as a normal modifier —
it will cost more, which is precisely why
VisualEffect
excludes it.
// Basic fade and scale on scroll
ScrollView {
LazyVStack(spacing: 16) {
ForEach(0..<20) { index in
RoundedRectangle(cornerRadius: 16)
.fill(.blue.gradient)
.frame(height: 120)
.overlay {
Text("Card \(index)")
.font(.title2)
.fontWeight(.bold)
.foregroundStyle(.white)
}
.scrollTransition { content, phase in
content
.opacity(phase.isIdentity ? 1.0 : 0.3)
.scaleEffect(phase.isIdentity ? 1.0 : 0.85)
}
}
}
.padding()
}
Complete Example: Cards That Scale Up as They Enter
struct ScrollTransitionDemo: View {
struct CardItem: Identifiable {
let id = UUID()
let title: String
let subtitle: String
let color: Color
}
let items: [CardItem] = [
CardItem(title: "Design", subtitle: "Create beautiful interfaces", color: .blue),
CardItem(title: "Develop", subtitle: "Build with SwiftUI", color: .purple),
CardItem(title: "Test", subtitle: "Ensure quality everywhere", color: .orange),
CardItem(title: "Deploy", subtitle: "Ship to the App Store", color: .green),
CardItem(title: "Monitor", subtitle: "Track performance", color: .red),
CardItem(title: "Iterate", subtitle: "Improve continuously", color: .teal),
CardItem(title: "Scale", subtitle: "Grow your user base", color: .indigo),
CardItem(title: "Optimize", subtitle: "Fine-tune performance", color: .pink),
]
var body: some View {
ScrollView {
LazyVStack(spacing: 20) {
ForEach(items) { item in
HStack(spacing: 16) {
Circle()
.fill(item.color.gradient)
.frame(width: 56, height: 56)
.overlay {
Image(systemName: "star.fill")
.foregroundStyle(.white)
.font(.title3)
}
VStack(alignment: .leading, spacing: 4) {
Text(item.title)
.font(.headline)
Text(item.subtitle)
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.foregroundStyle(.tertiary)
}
.padding()
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16))
.scrollTransition(.animated(.spring(duration: 0.4))) { content, phase in
content
.opacity(phase.isIdentity ? 1.0 : 0.0)
.scaleEffect(phase.isIdentity ? 1.0 : 0.75)
.offset(y: phase == .bottomTrailing ? 30 : phase == .topLeading ? -30 : 0)
.blur(radius: phase.isIdentity ? 0 : 2)
}
}
}
.padding()
}
}
}
Horizontal Scroll with Rotation
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 12) {
ForEach(0..<10) { index in
RoundedRectangle(cornerRadius: 20)
.fill(Color(hue: Double(index) / 10, saturation: 0.7, brightness: 0.9).gradient)
.frame(width: 200, height: 280)
.scrollTransition(.interactive) { content, phase in
content
.rotation3DEffect(
.degrees(phase.value * 25),
axis: (x: 0, y: 1, z: 0)
)
.scaleEffect(phase.isIdentity ? 1.0 : 0.9)
}
}
}
.padding(.horizontal)
}
.scrollTargetBehavior(.viewAligned)
MeshGradient (iOS 18+)
MeshGradient
creates a two-dimensional gradient defined by a grid of control points, each with an associated color. The colors blend smoothly across the mesh, creating organic, flowing gradients far more complex than linear or radial gradients.
// Static mesh gradient
MeshGradient(
width: 3,
height: 3,
points: [
[0.0, 0.0], [0.5, 0.0], [1.0, 0.0],
[0.0, 0.5], [0.5, 0.5], [1.0, 0.5],
[0.0, 1.0], [0.5, 1.0], [1.0, 1.0]
],
colors: [
.red, .purple, .indigo,
.orange, .pink, .blue,
.yellow, .mint, .teal
]
)
.frame(width: 300, height: 300)
.clipShape(RoundedRectangle(cornerRadius: 24))
Complete Animated Mesh Gradient Background
struct AnimatedMeshBackground: View {
@State private var animationPhase: CGFloat = 0.0
var body: some View {
TimelineView(.animation) { context in
let time = context.date.timeIntervalSinceReferenceDate
MeshGradient(
width: 3,
height: 3,
points: [
[0.0, 0.0],
[Float(0.5 + 0.3 * cos(time * 0.7)), 0.0],
[1.0, 0.0],
[0.0, Float(0.5 + 0.2 * sin(time * 0.5))],
[Float(0.5 + 0.2 * sin(time * 0.8)), Float(0.5 + 0.2 * cos(time * 0.6))],
[1.0, Float(0.5 + 0.3 * sin(time * 0.4))],
[0.0, 1.0],
[Float(0.5 + 0.2 * sin(time * 0.9)), 1.0],
[1.0, 1.0]
],
colors: [
.red, .purple, .indigo,
.orange, .pink, .blue,
.yellow, .mint, .teal
]
)
.ignoresSafeArea()
}
}
}
// Usage as a background
struct MeshGradientScreen: View {
var body: some View {
ZStack {
AnimatedMeshBackground()
VStack(spacing: 16) {
Text("Welcome")
.font(.largeTitle)
.fontWeight(.bold)
.foregroundStyle(.white)
Text("Beautiful animated gradients")
.font(.title3)
.foregroundStyle(.white.opacity(0.8))
}
}
}
}
Mesh Gradient with Color Cycling
struct ColorCyclingMesh: View {
let baseColors: [Color] = [
.red, .orange, .yellow,
.green, .mint, .teal,
.blue, .indigo, .purple
]
var body: some View {
TimelineView(.animation) { context in
let time = context.date.timeIntervalSinceReferenceDate
let shift = Int(time * 2) % baseColors.count
MeshGradient(
width: 3,
height: 3,
points: [
[0.0, 0.0], [0.5, 0.0], [1.0, 0.0],
[0.0, 0.5], [0.5, 0.5], [1.0, 0.5],
[0.0, 1.0], [0.5, 1.0], [1.0, 1.0]
],
colors: (0..<9).map { index in
baseColors[(index + shift) % baseColors.count]
}
)
.ignoresSafeArea()
}
}
}
TextRenderer Protocol (iOS 18+)
The
TextRenderer
protocol lets you customize how each glyph or line of text is drawn. You conform to
TextRenderer
and implement
draw(layout:in:)
, where you receive the resolved text layout and a
GraphicsContext
. This enables per-character animations like typing effects, wave motion, and fade-in sequences.
TextRenderer Protocol Basics
import SwiftUI
struct WaveTextRenderer: TextRenderer {
var timeOffset: Double
var animatableData: Double {
get { timeOffset }
set { timeOffset = newValue }
}
func draw(layout: Text.Layout, in context: inout GraphicsContext) {
for line in layout {
for run in line {
for (index, glyph) in run.enumerated() {
var copy = context
let yOffset = sin(Double(index) * 0.5 + timeOffset) * 5
copy.translateBy(x: 0, y: yOffset)
copy.draw(glyph)
}
}
}
}
}
Complete Custom Text Animation: Per-Character Fade-In
import SwiftUI
struct FadeInTextRenderer: TextRenderer {
var progress: Double
var animatableData: Double {
get { progress }
set { progress = newValue }
}
func draw(layout: Text.Layout, in context: inout GraphicsContext) {
var characterIndex = 0
var totalCharacters = 0
// Count total characters
for line in layout {
for run in line {
for _ in run {
totalCharacters += 1
}
}
}
// Draw each character with staggered opacity
for line in layout {
for run in line {
for glyph in run {
let threshold = Double(characterIndex) / Double(max(totalCharacters - 1, 1))
let characterProgress = max(0, min(1, (progress - threshold) * Double(totalCharacters) / 3.0))
var copy = context
copy.opacity = characterProgress
let yShift = (1.0 - characterProgress) * 10
copy.translateBy(x: 0, y: yShift)
copy.draw(glyph)
characterIndex += 1
}
}
}
}
}
struct FadeInTextDemo: View {
@State private var progress: Double = 0.0
var body: some View {
VStack(spacing: 40) {
Text("Hello, SwiftUI!")
.font(.largeTitle)
.fontWeight(.bold)
.textRenderer(FadeInTextRenderer(progress: progress))
Button("Animate") {
progress = 0
withAnimation(.easeInOut(duration: 1.5)) {
progress = 1.0
}
}
.buttonStyle(.borderedProminent)
}
.padding()
}
}
Typing Effect with TextRenderer
struct TypingTextRenderer: TextRenderer {
var visibleCount: Int
var animatableData: Double {
get { Double(visibleCount) }
set { visibleCount = Int(newValue) }
}
func draw(layout: Text.Layout, in context: inout GraphicsContext) {
var characterIndex = 0
for line in layout {
for run in line {
for glyph in run {
if characterIndex < visibleCount {
context.draw(glyph)
}
characterIndex += 1
}
}
}
}
}
struct TypingEffectDemo: View {
@State private var visibleCount = 0
private let message = "Welcome to the future of text rendering."
var body: some View {
VStack(spacing: 30) {
Text(message)
.font(.title2)
.fontWeight(.medium)
.textRenderer(TypingTextRenderer(visibleCount: visibleCount))
.frame(maxWidth: .infinity, alignment: .leading)
Button("Start Typing") {
visibleCount = 0
withAnimation(.linear(duration: 2.0)) {
visibleCount = message.count
}
}
.buttonStyle(.borderedProminent)
}
.padding()
}
}
Wave Text Animation
struct WaveTextDemo: View {
@State private var wavePhase: Double = 0
var body: some View {
Text("SwiftUI Waves")
.font(.largeTitle)
.fontWeight(.heavy)
.textRenderer(WaveTextRenderer(timeOffset: wavePhase))
.onAppear {
withAnimation(.linear(duration: 2).repeatForever(autoreverses: false)) {
wavePhase = .pi * 2
}
}
}
}
Key rules for TextRenderer:
- The
animatableData
property is required if you want SwiftUI to interpolate your renderer over time.
-
Text.Layout
provides an iterable hierarchy: layout -> lines -> runs -> glyphs.
- Use
GraphicsContext.draw(_:)
to render individual glyphs. You can modify the context (translate, rotate, set opacity) before each draw call.
- TextRenderer only works with
Text
views. It does not apply to
TextField
,
Label
, or other text-containing views.
---
# Routing, Deep Links, and State Restoration
https://nagarjuna2997.github.io/ios-agent-skill/guides/swiftui-deep-linking-and-routing.html
← Back to all frameworks & guides
All articles →Swiftui · Reference guideRouting, Deep Links, and State RestorationRepository guidance for Routing, Deep Links, and State Restoration. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
1. Typed Routes
2. The Router
Wiring it once, at the root
Views send intent
Anti-patterns
3. Deep Links
Parse into routes, then apply
Applying
Links that arrive before the app is ready
Configuration
Testing the parser
4. State Restoration
Persisting per scene
5. Multiple Stacks (Tabs)
6. Router vs Coordinator
Checklist
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Parse incoming URL
↓2
Validate a typed route
↓3
Update navigation state
↓4
Restore or reject safely
02 / ArchitectureResponsibility boundariesBoundary 1
URL parserBoundary 2
Typed routerBoundary 3
Navigation stackConnected responsibilities, not a required class hierarchy or an execution trace.
Load this when:
adding navigation to more than two screens, handling a URL
scheme or universal link, restoring navigation state across launches, or
reviewing anything that mutates a
NavigationPath
.
docs/swiftui/navigation.md
covers the navigation
APIs
.
patterns/coordinator.md
covers the coordinator
pattern
.
This document covers the piece that breaks in production: making a single typed
route model the only way navigation state changes, so a deep link, a button tap,
and a restored session all go through the same code.
1. Typed Routes
Model every destination as a value. Strings and
Any
in a
NavigationPath
are
how you get silent no-op links.
// Navigation/Route.swift
enum Route: Hashable, Codable, Sendable {
case productDetail(id: Product.ID)
case category(Product.Category)
case orderHistory
case orderDetail(id: Order.ID)
case settings
case profile(userID: User.ID)
}
// Modal presentation is a different axis — model it separately.
enum Sheet: Identifiable, Hashable {
case checkout(cart: [CartItem])
case editProfile
case filter
var id: Self { self }
}
enum FullScreenRoute: Identifiable, Hashable {
case onboarding
case paywall(trigger: String)
var id: Self { self }
}
Codable
conformance is what makes state restoration possible (§4). Add it now
even if you do not restore yet — retrofitting it later means touching every case.
2. The Router
One
@MainActor @Observable
object owns navigation state. Views send it intent;
they never mutate a path directly.
// Navigation/Router.swift
@MainActor
@Observable
final class Router {
var path = NavigationPath()
var sheet: Sheet?
var fullScreen: FullScreenRoute?
// MARK: Stack
func push(_ route: Route) {
path.append(route)
}
func pop() {
guard !path.isEmpty else { return }
path.removeLast()
}
func popToRoot() {
path.removeLast(path.count)
}
/// Replaces the entire stack — used by deep links so a link never
/// accumulates screens on top of wherever the user happened to be.
func replaceStack(with routes: [Route]) {
var newPath = NavigationPath()
for route in routes { newPath.append(route) }
path = newPath
}
// MARK: Modals
func present(_ sheet: Sheet) { self.sheet = sheet }
func present(_ route: FullScreenRoute) { fullScreen = route }
func dismissModal() { sheet = nil; fullScreen = nil }
}
Wiring it once, at the root
struct RootView: View {
@State private var router = Router()
var body: some View {
NavigationStack(path: $router.path) {
ProductListView()
.navigationDestination(for: Route.self) { route in
destination(for: route)
}
}
.environment(router)
.sheet(item: $router.sheet) { sheet in
switch sheet {
case .checkout(let cart): CheckoutView(cart: cart)
case .editProfile: EditProfileView()
case .filter: FilterView()
}
}
.fullScreenCover(item: $router.fullScreen) { route in
switch route {
case .onboarding: OnboardingView()
case .paywall(let trigger): PaywallView(trigger: trigger)
}
}
}
// One exhaustive switch. The compiler catches every route you forget to handle.
@ViewBuilder
private func destination(for route: Route) -> some View {
switch route {
case .productDetail(let id): ProductDetailView(id: id)
case .category(let category): CategoryView(category: category)
case .orderHistory: OrderHistoryView()
case .orderDetail(let id): OrderDetailView(id: id)
case .settings: SettingsView()
case .profile(let userID): ProfileView(userID: userID)
}
}
}
Views send intent
struct ProductRow: View {
@Environment(Router.self) private var router
let product: Product
var body: some View {
Button {
router.push(.productDetail(id: product.id))
} label: {
ProductRowContent(product: product)
}
}
}
NavigationLink(value:)
is equally fine for a plain push and keeps the row
accessible for free. Use the router when the destination depends on logic
(auth checks, A/B branching, analytics side effects).
Anti-patterns
// WRONG — a screen that owns its own NavigationStack cannot be pushed
// onto another one. You get a nested stack and a doubled navigation bar.
struct ProductDetailView: View {
var body: some View { NavigationStack { … } } // only the ROOT has a stack
}
// WRONG — deprecated, and it silently breaks programmatic navigation.
NavigationView { … }
NavigationLink(destination: DetailView(), isActive: $isActive) { … }
// WRONG — untyped path. A mismatched type is a silent no-op, not an error.
path.append("product-\(id)")
// WRONG — every view reaching into the path directly. Now nothing can
// enforce an invariant like "checkout requires a signed-in user".
@Environment(Router.self) var router
router.path.append(Route.checkout) // use router.push(_:)
// WRONG — navigation state on a screen's view model. Two screens now
// disagree about what is on the stack.
@Observable final class ProductListViewModel { var path = NavigationPath() }
3. Deep Links
Parse into routes, then apply
Separate
parsing
(pure, testable, no UI) from
applying
(mutates the
router). This is what makes deep links unit-testable without launching the app.
// Navigation/DeepLink.swift
struct DeepLink: Equatable {
var tab: AppTab
var routes: [Route]
var sheet: Sheet?
}
enum DeepLinkParser {
/// Pure function: URL in, intent out. No side effects, no router.
static func parse(_ url: URL) -> DeepLink? {
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true) else {
return nil
}
switch (components.scheme, components.host) {
// Custom scheme: myshop://product/1234
case ("myshop", let host?):
return parsePath(host: host, segments: pathSegments(components), query: components)
// Universal link: https://shop.example.com/product/1234
case ("https", "shop.example.com"):
var segments = pathSegments(components)
guard !segments.isEmpty else { return DeepLink(tab: .home, routes: []) }
let host = segments.removeFirst()
return parsePath(host: host, segments: segments, query: components)
default:
return nil
}
}
private static func pathSegments(_ components: URLComponents) -> [String] {
components.path.split(separator: "/").map(String.init)
}
private static func parsePath(
host: String,
segments: [String],
query components: URLComponents
) -> DeepLink? {
switch host {
case "product":
guard let raw = segments.first, let id = Product.ID(uuidString: raw) else { return nil }
return DeepLink(tab: .home, routes: [.productDetail(id: id)])
case "category":
guard let raw = segments.first,
let category = Product.Category(rawValue: raw) else { return nil }
return DeepLink(tab: .home, routes: [.category(category)])
case "orders":
// /orders -> the list
// /orders/<uuid> -> the list, then the detail pushed on top,
// so Back goes somewhere sensible.
guard let raw = segments.first else {
return DeepLink(tab: .orders, routes: [.orderHistory])
}
guard let id = Order.ID(uuidString: raw) else { return nil }
return DeepLink(tab: .orders, routes: [.orderHistory, .orderDetail(id: id)])
case "checkout":
return DeepLink(tab: .home, routes: [], sheet: .checkout(cart: []))
default:
return nil // unknown -> ignore, never crash
}
}
}
Two details that matter:
A deep link builds the whole stack, not just the leaf.
Landing on an order
detail with an empty back stack strands the user.
An unrecognised URL returns nil.
It must never crash and never navigate
somewhere arbitrary. Log it and stay put.
Applying
extension Router {
func handle(_ url: URL) {
guard let link = DeepLinkParser.parse(url) else {
Logger.navigation.warning("Unhandled deep link: \(url, privacy: .public)")
return
}
apply(link)
}
func apply(_ link: DeepLink) {
dismissModal() // never open a link behind a sheet
selectedTab = link.tab
replaceStack(with: link.routes)
if let sheet = link.sheet { present(sheet) }
}
}
@main
struct ShopApp: App {
@State private var router = Router()
var body: some Scene {
WindowGroup {
RootView()
.environment(router)
.onOpenURL { router.handle($0) }
// Universal links arrive as a user activity, not onOpenURL.
.onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in
guard let url = activity.webpageURL else { return }
router.handle(url)
}
}
}
}
Links that arrive before the app is ready
A link can land during launch, before sign-in resolves. Queue it rather than
dropping it:
@MainActor
@Observable
final class Router {
private var pendingLink: DeepLink?
var isReady = false {
didSet { if isReady { flushPendingLink() } }
}
func apply(_ link: DeepLink) {
guard isReady else { pendingLink = link; return }
// … as above
}
private func flushPendingLink() {
guard let link = pendingLink else { return }
pendingLink = nil
apply(link)
}
}
Set
isReady = true
once the session is loaded and the root UI is on screen.
The same queue handles links that require authentication: present the login
sheet, keep
pendingLink
, flush it after a successful sign-in.
Configuration
<!-- Info.plist — custom scheme -->
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>com.example.myshop</string>
<key>CFBundleURLSchemes</key>
<array><string>myshop</string></array>
</dict>
</array>
Universal links additionally need the
Associated Domains
capability
(
applinks:shop.example.com
) and an
apple-app-site-association
file served
from
https://shop.example.com/.well-known/
over HTTPS with no redirects.
Prefer universal links: they work when the app is not installed, and they cannot
be hijacked by another app registering the same scheme.
Testing the parser
Because parsing is pure, this needs no simulator:
@Suite("DeepLinkParser")
struct DeepLinkParserTests {
private func url(_ string: String) -> URL { URL(string: string)! }
@Test("product link builds a single-screen stack")
func productLink() throws {
let id = UUID()
let link = try #require(DeepLinkParser.parse(url("myshop://product/\(id)")))
#expect(link.tab == .home)
#expect(link.routes == [.productDetail(id: id)])
}
@Test("order detail keeps the list underneath it")
func orderDetailStack() throws {
let id = UUID()
let link = try #require(DeepLinkParser.parse(url("myshop://orders/\(id)")))
#expect(link.routes == [.orderHistory, .orderDetail(id: id)])
}
@Test("universal link parses the same as the custom scheme")
func universalLink() throws {
let id = UUID()
let custom = DeepLinkParser.parse(url("myshop://product/\(id)"))
let web = DeepLinkParser.parse(url("https://shop.example.com/product/\(id)"))
#expect(custom == web)
}
@Test("malformed links are ignored, not fatal", arguments: [
"myshop://product/not-a-uuid",
"myshop://nonsense",
"https://evil.example.com/product/123",
"myshop://"
])
func malformed(_ string: String) {
#expect(DeepLinkParser.parse(url(string)) == nil)
}
}
4. State Restoration
NavigationPath
has a
CodableRepresentation
that works when
every
route
in it is
Codable
and
Hashable
. That is the payoff for §1.
extension Router {
/// nil when the path holds a non-Codable value — fail quietly, never crash.
var encodedPath: Data? {
guard let representation = path.codable else { return nil }
return try? JSONEncoder().encode(representation)
}
func restorePath(from data: Data) {
guard let representation = try? JSONDecoder()
.decode(NavigationPath.CodableRepresentation.self, from: data)
else { return }
path = NavigationPath(representation)
}
}
Persisting per scene
struct RootView: View {
@Environment(Router.self) private var router
@SceneStorage("navigation.path") private var storedPath: Data?
var body: some View {
NavigationStack(path: Bindable(router).path) {
ProductListView()
.navigationDestination(for: Route.self) { destination(for: $0) }
}
.task {
if let storedPath { router.restorePath(from: storedPath) }
}
.onChange(of: router.path) {
storedPath = router.encodedPath
}
}
}
@SceneStorage
is the right tool: it is per-window (correct on iPad and
visionOS, where two windows have different stacks) and the system clears it when
the user explicitly closes the scene.
Restore defensively.
A restored route may point at a product that has since
been deleted or a screen behind a subscription that has lapsed. Destination
views must handle a missing entity with a
ContentUnavailableView
, not a force
unwrap. Validate before restoring anything sensitive:
func restorePath(from data: Data, isSignedIn: Bool) {
guard isSignedIn else { return } // never restore into a gated screen
// … decode as above
}
5. Multiple Stacks (Tabs)
Each tab owns its own path. A single shared
NavigationPath
across tabs will
push the wrong screen into the wrong tab.
enum AppTab: String, Hashable, CaseIterable, Codable {
case home, search, orders, profile
}
@MainActor
@Observable
final class Router {
var selectedTab: AppTab = .home
private var paths: [AppTab: NavigationPath] = [:]
subscript(tab: AppTab) -> NavigationPath {
get { paths[tab] ?? NavigationPath() }
set { paths[tab] = newValue }
}
func push(_ route: Route, in tab: AppTab? = nil) {
let target = tab ?? selectedTab
paths[target, default: NavigationPath()].append(route)
}
/// Tapping the active tab again pops it to root — expected iOS behaviour.
func select(_ tab: AppTab) {
if selectedTab == tab {
paths[tab] = NavigationPath()
} else {
selectedTab = tab
}
}
}
struct AppTabView: View {
@Environment(Router.self) private var router
var body: some View {
@Bindable var router = router
TabView(selection: Binding(
get: { router.selectedTab },
set: { router.select($0) } // routes through the pop-to-root rule
)) {
ForEach(AppTab.allCases, id: \.self) { tab in
NavigationStack(path: Binding(
get: { router[tab] },
set: { router[tab] = $0 }
)) {
rootView(for: tab)
.navigationDestination(for: Route.self) { destination(for: $0) }
}
.tabItem { Label(tab.title, systemImage: tab.systemImage) }
.tag(tab)
}
}
}
}
6. Router vs Coordinator
Use
When
NavigationLink(value:) alone
1–3 screens, no deep links, no conditional destinations
A single Router (this document)
Most apps: typed routes, deep links, restoration
Coordinators (patterns/coordinator.md)
Independent flows with their own lifecycle — onboarding, auth, checkout — especially when a team owns each flow as a module
Do not start with coordinators. Start with a
Router
, and split a flow out only
when its navigation rules stop fitting in one exhaustive switch.
Checklist
[ ] Exactly one
NavigationStack
per tab, owned by the root — never inside a
pushed screen.
[ ] Routes are a
Hashable, Codable, Sendable
enum; nothing is appended as a
String
.
[ ] Views call
router.push(_:)
; nothing mutates
path
directly.
[ ]
DeepLinkParser.parse
is pure and unit-tested, including malformed input.
[ ] Deep links build a full back stack and dismiss any open modal first.
[ ] An unknown URL logs and no-ops. It never crashes and never navigates blind.
[ ] Universal links handled via
onContinueUserActivity
, not just
onOpenURL
.
[ ] Links arriving before the app is ready are queued, not dropped.
[ ] Restored destinations tolerate deleted entities and revoked access.
[ ]
Router
is
@MainActor @Observable final class
.
---
# SwiftUI Gestures
https://nagarjuna2997.github.io/ios-agent-skill/guides/swiftui-gestures.html
← Back to all frameworks & guides
All articles →Swiftui · Reference guideSwiftUI GesturesRepository guidance for SwiftUI Gestures. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
TapGesture
Shortcut Modifier
Explicit TapGesture
SpatialTapGesture (iOS 16+)
LongPressGesture
Shortcut Modifier
Explicit LongPressGesture
DragGesture
DragGesture Value Properties
Swipe-to-Dismiss Card
Drag with Velocity (iOS 17+)
MagnifyGesture (Pinch to Zoom)
RotateGesture
Gesture Composition
.simultaneously
.sequenced
.exclusively
@GestureState
Complex GestureState
Gesture Modifiers and Priority
.highPriorityGesture
.simultaneousGesture
Gesture mask
Custom Gesture Modifiers
Gesture with Animation Integration
Interactive Spring-Back
Pull-to-Refresh with Custom Gesture
Practical Patterns
Dismiss Gesture for Modal
Coordinate Multiple Gestures
Tips and Best Practices
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Choose intended gesture
↓2
Track transient gesture state
↓3
Commit the resulting action
↓4
Resolve competing gestures
02 / ArchitectureResponsibility boundariesBoundary 1
Input recognizersBoundary 2
Transient stateBoundary 3
Committed model stateConnected responsibilities, not a required class hierarchy or an execution trace.
Complete reference for tap, long press, drag, magnify, rotate gestures, composition, gesture state, and integration with animations.
TapGesture
Shortcut Modifier
// Single tap
Text("Tap me")
.onTapGesture {
print("Tapped")
}
// Double tap
Image("photo")
.onTapGesture(count: 2) {
isZoomed.toggle()
}
// Triple tap
Text("Secret")
.onTapGesture(count: 3) {
showDebugMenu = true
}
Explicit TapGesture
let doubleTap = TapGesture(count: 2)
.onEnded {
withAnimation(.spring(duration: 0.3, bounce: 0.4)) {
isFavorited.toggle()
}
}
Image(systemName: isFavorited ? "heart.fill" : "heart")
.font(.largeTitle)
.foregroundStyle(isFavorited ? .red : .gray)
.gesture(doubleTap)
SpatialTapGesture (iOS 16+)
Provides the location of the tap.
let spatialTap = SpatialTapGesture()
.onEnded { value in
let location = value.location // CGPoint
addAnnotation(at: location)
}
Canvas { context, size in
for annotation in annotations {
context.fill(
Circle().path(in: CGRect(origin: annotation.point, size: CGSize(width: 20, height: 20))),
with: .color(.blue)
)
}
}
.gesture(spatialTap)
LongPressGesture
Shortcut Modifier
Text("Long press me")
.onLongPressGesture(minimumDuration: 0.5) {
showContextActions = true
}
// With pressing state
Text("Hold")
.onLongPressGesture(minimumDuration: 1.0) {
// Completed
performAction()
} onPressingChanged: { isPressing in
// Fires immediately when press starts/stops
withAnimation {
isHighlighted = isPressing
}
}
Explicit LongPressGesture
struct LongPressButton: View {
@GestureState private var isDetectingLongPress = false
@State private var completedLongPress = false
var longPress: some Gesture {
LongPressGesture(minimumDuration: 1.0)
.updating($isDetectingLongPress) { currentState, gestureState, transaction in
gestureState = currentState
transaction.animation = .easeIn(duration: 0.2)
}
.onEnded { finished in
completedLongPress = finished
}
}
var body: some View {
Circle()
.fill(isDetectingLongPress ? .red : .blue)
.frame(width: 80, height: 80)
.scaleEffect(isDetectingLongPress ? 1.2 : 1.0)
.gesture(longPress)
}
}
DragGesture
The most versatile gesture for building interactive UIs.
struct DraggableCard: View {
@State private var offset = CGSize.zero
var body: some View {
RoundedRectangle(cornerRadius: 16)
.fill(.blue)
.frame(width: 200, height: 120)
.offset(offset)
.gesture(
DragGesture()
.onChanged { value in
offset = value.translation
}
.onEnded { value in
withAnimation(.spring) {
offset = .zero
}
}
)
}
}
DragGesture Value Properties
DragGesture(minimumDistance: 10, coordinateSpace: .local)
.onChanged { value in
// Current drag state
let translation = value.translation // CGSize (total offset from start)
let location = value.location // CGPoint (current finger position)
let startLocation = value.startLocation // CGPoint (where drag began)
let predictedEndLocation = value.predictedEndLocation // CGPoint
let predictedEndTranslation = value.predictedEndTranslation // CGSize
let velocity = value.velocity // CGSize (iOS 17+, points per second)
}
Swipe-to-Dismiss Card
struct SwipeCard: View {
@State private var offset = CGSize.zero
@State private var opacity: Double = 1
let onDismiss: () -> Void
var body: some View {
CardContent()
.offset(x: offset.width)
.rotationEffect(.degrees(Double(offset.width / 20)))
.opacity(opacity)
.gesture(
DragGesture()
.onChanged { value in
offset = value.translation
opacity = 1 - abs(Double(value.translation.width / 300))
}
.onEnded { value in
if abs(value.translation.width) > 150 {
// Dismiss
withAnimation(.easeOut(duration: 0.3)) {
offset.width = value.translation.width > 0 ? 500 : -500
opacity = 0
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
onDismiss()
}
} else {
// Spring back
withAnimation(.spring(duration: 0.4, bounce: 0.3)) {
offset = .zero
opacity = 1
}
}
}
)
}
}
Drag with Velocity (iOS 17+)
DragGesture()
.onEnded { value in
let velocity = value.velocity
let speed = sqrt(velocity.width * velocity.width + velocity.height * velocity.height)
if speed > 500 {
// Fast swipe -- project final position
withAnimation(.spring(duration: 0.4)) {
position = value.predictedEndTranslation
}
} else {
// Slow drag -- snap back
withAnimation(.spring) {
position = .zero
}
}
}
MagnifyGesture (Pinch to Zoom)
Renamed from
MagnificationGesture
in iOS 17.
struct ZoomableImage: View {
@State private var currentScale: CGFloat = 1.0
@GestureState private var gestureScale: CGFloat = 1.0
var magnification: some Gesture {
MagnifyGesture()
.updating($gestureScale) { value, gestureState, _ in
gestureState = value.magnification
}
.onEnded { value in
currentScale *= value.magnification
currentScale = min(max(currentScale, 0.5), 5.0) // Clamp
}
}
var body: some View {
Image("landscape")
.resizable()
.aspectRatio(contentMode: .fit)
.scaleEffect(currentScale * gestureScale)
.gesture(magnification)
.onTapGesture(count: 2) {
withAnimation(.spring) {
currentScale = 1.0
}
}
}
}
RotateGesture
Renamed from
RotationGesture
in iOS 17.
struct RotatableView: View {
@State private var currentAngle: Angle = .zero
@GestureState private var gestureAngle: Angle = .zero
var rotation: some Gesture {
RotateGesture()
.updating($gestureAngle) { value, gestureState, _ in
gestureState = value.rotation
}
.onEnded { value in
currentAngle += value.rotation
}
}
var body: some View {
Image(systemName: "arrow.up")
.font(.system(size: 60))
.rotationEffect(currentAngle + gestureAngle)
.gesture(rotation)
}
}
Gesture Composition
.simultaneously
Both gestures are recognized at the same time.
struct ZoomAndRotate: View {
@State private var scale: CGFloat = 1.0
@State private var angle: Angle = .zero
@GestureState private var gestureScale: CGFloat = 1.0
@GestureState private var gestureAngle: Angle = .zero
var body: some View {
let magnify = MagnifyGesture()
.updating($gestureScale) { value, state, _ in
state = value.magnification
}
.onEnded { value in
scale *= value.magnification
}
let rotate = RotateGesture()
.updating($gestureAngle) { value, state, _ in
state = value.rotation
}
.onEnded { value in
angle += value.rotation
}
Image("photo")
.resizable()
.aspectRatio(contentMode: .fit)
.scaleEffect(scale * gestureScale)
.rotationEffect(angle + gestureAngle)
.gesture(magnify.simultaneously(with: rotate))
}
}
.sequenced
Second gesture only activates after the first succeeds.
// Long press then drag
struct LongPressDrag: View {
@State private var offset = CGSize.zero
@GestureState private var isLongPressing = false
var body: some View {
let longPressThenDrag = LongPressGesture(minimumDuration: 0.5)
.sequenced(before: DragGesture())
.updating($isLongPressing) { value, state, _ in
switch value {
case .first(true):
state = true // Long press active
case .second(true, let drag):
state = true // Dragging
if let drag {
// Can't use @GestureState for offset here
// but we can use it for visual feedback
}
default:
state = false
}
}
.onEnded { value in
guard case .second(true, let drag?) = value else { return }
offset = drag.translation
}
Circle()
.fill(isLongPressing ? .red : .blue)
.frame(width: 80, height: 80)
.offset(offset)
.scaleEffect(isLongPressing ? 1.2 : 1.0)
.animation(.spring, value: isLongPressing)
.gesture(longPressThenDrag)
}
}
.exclusively
Only one gesture is recognized; the first to start wins.
let tap = TapGesture()
.onEnded { handleTap() }
let longPress = LongPressGesture(minimumDuration: 0.5)
.onEnded { _ in handleLongPress() }
// Long press takes priority; tap recognized only if long press fails
view.gesture(longPress.exclusively(before: tap))
@GestureState
A property wrapper that automatically resets to its initial value when the gesture ends. Ideal for transient visual feedback during a gesture.
struct PressableButton: View {
@GestureState private var isPressed = false
var body: some View {
Text("Press Me")
.padding()
.background(isPressed ? .blue.opacity(0.8) : .blue)
.foregroundStyle(.white)
.clipShape(Capsule())
.scaleEffect(isPressed ? 0.95 : 1.0)
.animation(.spring(duration: 0.2), value: isPressed)
.gesture(
LongPressGesture(minimumDuration: .infinity)
.updating($isPressed) { currentState, gestureState, _ in
gestureState = true
}
)
}
}
Key difference from @State:
@GestureState
resets automatically when the gesture ends. With
@State
you must manually reset in
.onEnded
.
Complex GestureState
struct DragState {
var translation: CGSize = .zero
var isDragging: Bool = false
}
@GestureState private var dragState = DragState()
DragGesture()
.updating($dragState) { value, state, _ in
state = DragState(
translation: value.translation,
isDragging: true
)
}
Gesture Modifiers and Priority
.highPriorityGesture
Takes priority over child view gestures.
VStack {
Button("Child Button") { childAction() }
}
.highPriorityGesture(
TapGesture().onEnded { parentAction() }
)
// Parent tap overrides the child button
.simultaneousGesture
Recognized alongside child view gestures.
ScrollView {
Content()
}
.simultaneousGesture(
DragGesture().onChanged { value in
// Track drag without blocking ScrollView scrolling
trackDragPosition(value.location)
}
)
Gesture mask
// Only apply gesture to specific subviews
.gesture(dragGesture, including: .subviews) // .all, .gesture, .subviews, .none
Custom Gesture Modifiers
Encapsulate reusable gesture logic.
struct DraggableModifier: ViewModifier {
@State private var offset = CGSize.zero
let axis: Axis?
let onDragEnd: ((CGSize) -> Void)?
func body(content: Content) -> some View {
content
.offset(constrainedOffset)
.gesture(
DragGesture()
.onChanged { value in
offset = value.translation
}
.onEnded { value in
onDragEnd?(value.translation)
withAnimation(.spring) {
offset = .zero
}
}
)
}
var constrainedOffset: CGSize {
switch axis {
case .horizontal:
CGSize(width: offset.width, height: 0)
case .vertical:
CGSize(width: 0, height: offset.height)
case nil:
offset
}
}
}
extension View {
func draggable(axis: Axis? = nil, onDragEnd: ((CGSize) -> Void)? = nil) -> some View {
modifier(DraggableModifier(axis: axis, onDragEnd: onDragEnd))
}
}
// Usage
Card().draggable(axis: .horizontal) { translation in
if translation.width > 100 { markAsRead() }
}
Gesture with Animation Integration
Interactive Spring-Back
struct InteractiveCard: View {
@State private var offset = CGSize.zero
@State private var isDragging = false
var body: some View {
RoundedRectangle(cornerRadius: 20)
.fill(.blue.gradient)
.frame(width: 280, height: 180)
.shadow(
color: .black.opacity(isDragging ? 0.3 : 0.1),
radius: isDragging ? 20 : 8,
y: isDragging ? 10 : 4
)
.offset(offset)
.scaleEffect(isDragging ? 1.05 : 1.0)
.rotationEffect(.degrees(Double(offset.width / 20)))
.gesture(
DragGesture()
.onChanged { value in
withAnimation(.interactiveSpring) {
offset = value.translation
isDragging = true
}
}
.onEnded { value in
withAnimation(.spring(duration: 0.5, bounce: 0.4)) {
offset = .zero
isDragging = false
}
}
)
}
}
Pull-to-Refresh with Custom Gesture
struct PullToRefresh: View {
@State private var pullOffset: CGFloat = 0
@State private var isRefreshing = false
let threshold: CGFloat = 80
var body: some View {
VStack(spacing: 0) {
// Pull indicator
ZStack {
if isRefreshing {
ProgressView()
} else {
Image(systemName: "arrow.down")
.rotationEffect(.degrees(pullOffset > threshold ? 180 : 0))
.animation(.spring, value: pullOffset > threshold)
}
}
.frame(height: max(0, pullOffset))
.opacity(min(1, pullOffset / threshold))
// Content
ScrollView {
LazyVStack {
ForEach(items) { item in
ItemRow(item: item)
}
}
}
}
.gesture(
DragGesture()
.onChanged { value in
if value.translation.height > 0 && !isRefreshing {
pullOffset = value.translation.height * 0.5 // Resistance
}
}
.onEnded { value in
if pullOffset > threshold {
withAnimation(.spring) { pullOffset = threshold }
isRefreshing = true
Task {
await refresh()
withAnimation(.spring) {
isRefreshing = false
pullOffset = 0
}
}
} else {
withAnimation(.spring) { pullOffset = 0 }
}
}
)
}
}
Practical Patterns
Dismiss Gesture for Modal
struct DismissableSheet: View {
@State private var dragOffset: CGFloat = 0
@Environment(\.dismiss) private var dismiss
var body: some View {
VStack {
Capsule()
.fill(.secondary)
.frame(width: 40, height: 5)
.padding(.top, 8)
SheetContent()
}
.offset(y: max(0, dragOffset))
.gesture(
DragGesture()
.onChanged { value in
dragOffset = value.translation.height
}
.onEnded { value in
if value.translation.height > 150 || value.velocity.height > 500 {
dismiss()
} else {
withAnimation(.spring(duration: 0.3)) {
dragOffset = 0
}
}
}
)
}
}
Coordinate Multiple Gestures
struct PhotoViewer: View {
@State private var scale: CGFloat = 1
@State private var offset = CGSize.zero
@State private var angle: Angle = .zero
var body: some View {
Image("photo")
.resizable()
.aspectRatio(contentMode: .fit)
.scaleEffect(scale)
.offset(offset)
.rotationEffect(angle)
.gesture(
MagnifyGesture()
.onChanged { value in
scale = value.magnification
}
.simultaneously(with:
RotateGesture()
.onChanged { value in
angle = value.rotation
}
)
.simultaneously(with:
DragGesture()
.onChanged { value in
offset = value.translation
}
)
)
.onTapGesture(count: 2) {
withAnimation(.spring) {
scale = 1
offset = .zero
angle = .zero
}
}
}
}
Tips and Best Practices
Use
@GestureState
for transient gesture feedback -- it resets automatically and is more efficient than
@State
.
Always add
withAnimation
in
.onEnded
for smooth spring-back effects.
Use
.simultaneousGesture
when you need to observe gestures without blocking child interactions (e.g., tracking scroll position).
Set
minimumDistance
on
DragGesture
to avoid conflicting with scroll gestures (default is 10pt).
Combine
.sensoryFeedback()
with gestures for polished interactions.
Test gestures on real devices -- simulators do not support multi-touch or haptics.
For complex gesture interactions, profile with Instruments to ensure the main thread stays responsive.
---
# SwiftUI iOS 27 Interactions
https://nagarjuna2997.github.io/ios-agent-skill/guides/swiftui-ios-27-interactions.html
← Back to all frameworks & guides
All articles →Swiftui · Reference guideSwiftUI iOS 27 InteractionsRepository guidance for SwiftUI iOS 27 Interactions. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
Overview
1. Reordering in Lists, Stacks, Grids, and Custom Layouts
2. Swipe Actions Outside List
3. Adaptive Toolbars
4. Testing
5. Review Checklist
Released API review (2026-09-16)
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Identify interaction requirement
↓2
Check SDK declarations
↓3
Implement supported behavior
↓4
Verify fallback and accessibility
02 / ArchitectureResponsibility boundariesBoundary 1
Interaction intentBoundary 2
Availability boundaryBoundary 3
Adaptive controlsConnected responsibilities, not a required class hierarchy or an execution trace.
Overview
SwiftUI's iOS 27 generation adds interaction APIs for custom containers that previously required
List
or hand-rolled drag/swipe behavior: reorderable content, swipe actions in arbitrary row containers, and richer toolbar adaptation.
iOS 27 is released. Guard new APIs with availability; this guide’s iOS 27 examples still require compilation with Xcode 27.
Release verification
.
1. Reordering in Lists, Stacks, Grids, and Custom Layouts
Use
reorderable()
on the
ForEach
that produces reorderable views, then place a
reorderContainer
on the enclosing container.
import SwiftUI
@available(iOS 27.0, macOS 27.0, *)
struct PhotoGrid: View {
@State private var photos: [Photo] = []
var body: some View {
LazyVGrid(columns: [.init(.adaptive(minimum: 120))]) {
ForEach(photos) { photo in
PhotoTile(photo: photo)
}
.reorderable()
}
.reorderContainer(for: Photo.self) { difference in
apply(difference)
}
}
private func apply(_ difference: ReorderDifference<Photo.ID, ReorderableSingleCollectionIdentifier>) {
// Update the source of truth from the system-provided difference.
}
}
Rules:
Item identifiers must be stable,
Hashable
, and
Sendable
.
Update the data source in the
move
closure; do not rely on view order alone.
Disable reordering while saves or sync merges are in flight.
For multiple sections, use
reorderable(collectionID:)
.
2. Swipe Actions Outside
List
Use the new
swipeActions
overload when you need presentation state, and add
swipeActionsContainer()
to the scroll/container view.
@available(iOS 27.0, *)
struct MessagesView: View {
@State private var swipedMessageID: Message.ID?
let messages: [Message]
var body: some View {
ScrollView {
LazyVStack {
ForEach(messages) { message in
MessageRow(message: message)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(role: .destructive) {
delete(message)
} label: {
Label("Delete", systemImage: "trash")
}
} onPresentationChanged: { isPresented in
swipedMessageID = isPresented ? message.id : nil
}
}
}
}
.swipeActionsContainer()
}
}
List
already coordinates swipe actions. Use
swipeActionsContainer()
for
ScrollView
, stacks, grids, and custom row layouts so only one row is open and scrolling/tapping dismisses actions.
3. Adaptive Toolbars
The iOS 27 toolbar direction is adaptive: content should survive compact widths, overflow, user customization, and platform differences.
Review toolbars for:
semantic placement instead of hardcoded positions
stable labels and SF Symbols
priority for actions that must remain visible
overflow behavior for secondary actions
pinned trailing items only when the task requires persistent access
keyboard and pointer alternatives on iPad/macOS
Avoid toolbar-only workflows. Every critical action still needs an accessible path when it moves to overflow.
4. Testing
Test these interaction surfaces on:
compact iPhone portrait
iPhone landscape
iPad split view
iPad Stage Manager / resizable windows
Dynamic Type accessibility sizes
VoiceOver
pointer/keyboard input
For reordering, include sync and persistence tests. For swipe actions, include dismissal, full-swipe, destructive confirmation, and VoiceOver action alternatives.
5. Review Checklist
[ ] iOS 27 APIs have availability guards
[ ] Reorder identifiers are stable,
Hashable
, and
Sendable
[ ] Reorder closure updates the source of truth
[ ] Reordering disables during conflicting saves/sync
[ ] Custom swipe rows use
swipeActionsContainer()
[ ] Destructive swipe actions have undo or confirmation where needed
[ ] Toolbar actions remain reachable in compact/overflow states
[ ] Interaction tests cover iPad resizability and accessibility
See also:
docs/swiftui/views-and-controls.md
,
docs/swiftui/layout.md
,
docs/tooling/device-hub.md
.
Released API review (2026-09-16)
Apple’s SwiftUI updates
document
reorderable()
with
reorderContainer(for:isEnabled:move:)
, and URL-based documents using
ReadableDocument
,
WritableDocument
, and
URLDocumentConfiguration
. Keep the actual availability guards; older document protocols and container approaches do not become deprecated merely because these additions exist.
The static SwiftUI reviewer’s regression tests accept these new names. No speculative deprecation list was added. Prefetching and widget styling require API-specific review and runtime tests; a lexical scan cannot establish their performance or Siri behavior.
---
# SwiftUI Layout System
https://nagarjuna2997.github.io/ios-agent-skill/guides/swiftui-layout.html
← Back to all frameworks & guides
All articles →Swiftui · Reference guideSwiftUI Layout SystemRepository guidance for SwiftUI Layout System. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
VStack, HStack, ZStack
VStack (Vertical)
HStack (Horizontal)
ZStack (Overlay)
Spacer and Divider
Grid and GridRow (iOS 16+)
ViewThatFits (iOS 16+)
GeometryReader and GeometryProxy
Reading Scroll Position
LazyVGrid and LazyHGrid
Grid Item Types
LazyVGrid Examples
LazyHGrid
Frame, Padding, Offset
.frame()
.padding()
.offset()
Safe Area
safeAreaInset (iOS 15+)
safeAreaPadding (iOS 17+)
Ignoring Safe Area
Custom Layout Protocol (iOS 16+)
Animated Layout Transitions
ScrollView and ScrollViewReader
ScrollViewReader
scrollPosition (iOS 17+)
containerRelativeFrame (iOS 17+)
scrollTransition (iOS 17+)
ContentUnavailableView (iOS 17+)
Layout Tips and Patterns
Proportional Layout
Adaptive Layout Based on Size Class
Alignment Guides
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Identify content constraints
↓2
Choose a layout container
↓3
Adapt to available space
↓4
Test text and window resizing
02 / ArchitectureResponsibility boundariesBoundary 1
Parent proposalBoundary 2
Child measurementsBoundary 3
Positioned contentConnected responsibilities, not a required class hierarchy or an execution trace.
Complete reference for stacks, grids, geometry, custom layouts, scroll views, and spatial positioning.
VStack, HStack, ZStack
The fundamental building blocks of SwiftUI layout.
VStack (Vertical)
VStack(alignment: .leading, spacing: 12) {
Text("Title").font(.headline)
Text("Subtitle").font(.subheadline)
Text("Body text goes here").font(.body)
}
Alignment options:
.leading
,
.center
(default),
.trailing
,
.listRowSeparatorLeading
,
.listRowSeparatorTrailing
HStack (Horizontal)
HStack(alignment: .firstTextBaseline, spacing: 8) {
Image(systemName: "star.fill")
Text("4.8")
.font(.title)
Text("(128 reviews)")
.font(.caption)
.foregroundStyle(.secondary)
}
Alignment options:
.top
,
.center
(default),
.bottom
,
.firstTextBaseline
,
.lastTextBaseline
ZStack (Overlay)
ZStack(alignment: .bottomTrailing) {
Image("photo")
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 200, height: 200)
Text("NEW")
.font(.caption)
.padding(6)
.background(.red)
.foregroundStyle(.white)
.clipShape(Capsule())
.padding(8)
}
.clipShape(RoundedRectangle(cornerRadius: 12))
Alignment:
Any combination of horizontal and vertical --
.topLeading
,
.top
,
.topTrailing
,
.leading
,
.center
,
.trailing
,
.bottomLeading
,
.bottom
,
.bottomTrailing
.
Spacer and Divider
// Spacer pushes content apart
HStack {
Text("Leading")
Spacer() // Fills all available space
Text("Trailing")
}
HStack {
Text("Item")
Spacer(minLength: 20) // Minimum 20pt gap
Text("Value")
}
// Divider -- thin line separator
VStack {
Text("Section 1")
Divider()
.background(.blue) // Custom color
Text("Section 2")
}
// Horizontal divider in HStack
HStack {
Text("Left")
Divider()
.frame(height: 30)
Text("Right")
}
Grid and GridRow (iOS 16+)
Fixed layout grid with aligned columns -- unlike LazyVGrid, renders all content immediately.
Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 12) {
GridRow {
Text("Name")
.gridColumnAlignment(.trailing)
Text("John Doe")
}
GridRow {
Text("Email")
Text("john@example.com")
}
GridRow {
Text("Bio")
Text("A longer piece of text that spans the available width")
}
Divider()
.gridCellUnsizedAxes(.horizontal) // Span full width
GridRow {
Color.clear
.gridCellColumns(2) // Span multiple columns
.frame(height: 1)
}
GridRow {
Text("Actions")
HStack {
Button("Edit") { }
Button("Delete", role: .destructive) { }
}
}
}
ViewThatFits (iOS 16+)
Picks the first child view that fits the available space.
ViewThatFits(in: .horizontal) {
// First choice: full layout
HStack {
Image(systemName: "star.fill")
Text("Add to Favorites")
Spacer()
Text("128 people favorited this")
}
// Second choice: compact layout
HStack {
Image(systemName: "star.fill")
Text("Favorite")
Spacer()
Text("128")
}
// Third choice: icon only
Image(systemName: "star.fill")
}
Commonly used for adaptive layouts that work across iPhone SE to iPad.
GeometryReader and GeometryProxy
Reads the size and position of the parent container.
GeometryReader { proxy in
let width = proxy.size.width
let height = proxy.size.height
VStack {
Rectangle()
.fill(.blue)
.frame(width: width * 0.8, height: height * 0.3)
Text("Width: \(Int(width)), Height: \(Int(height))")
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
Reading Scroll Position
ScrollView {
GeometryReader { proxy in
Color.clear.preference(
key: ScrollOffsetKey.self,
value: proxy.frame(in: .named("scroll")).minY
)
}
.frame(height: 0)
LazyVStack { /* content */ }
}
.coordinateSpace(name: "scroll")
.onPreferenceChange(ScrollOffsetKey.self) { offset in
headerOpacity = min(1, max(0, -offset / 100))
}
struct ScrollOffsetKey: PreferenceKey {
static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
}
Warning:
GeometryReader
is greedy -- it takes all proposed space. Wrap it carefully or use it inside
.background
/
.overlay
to avoid layout issues.
// Better pattern: read size without affecting layout
Text("Content")
.background {
GeometryReader { proxy in
Color.clear
.onAppear { contentSize = proxy.size }
.onChange(of: proxy.size) { _, newSize in
contentSize = newSize
}
}
}
LazyVGrid and LazyHGrid
Grid Item Types
// Fixed: exact width
GridItem(.fixed(100))
// Flexible: fills available space within range
GridItem(.flexible(minimum: 80, maximum: 200))
// Adaptive: fits as many as possible within range
GridItem(.adaptive(minimum: 100, maximum: 150))
LazyVGrid Examples
// Two equal columns
let twoColumns = [
GridItem(.flexible()),
GridItem(.flexible())
]
// Three fixed columns
let threeFixed = [
GridItem(.fixed(100)),
GridItem(.fixed(100)),
GridItem(.fixed(100))
]
// Adaptive (responsive)
let adaptive = [
GridItem(.adaptive(minimum: 120))
]
ScrollView {
LazyVGrid(columns: adaptive, spacing: 16) {
ForEach(items) { item in
VStack {
AsyncImage(url: item.imageURL) { image in
image.resizable().aspectRatio(contentMode: .fill)
} placeholder: {
Color.gray.opacity(0.3)
}
.frame(height: 150)
.clipShape(RoundedRectangle(cornerRadius: 8))
Text(item.title)
.font(.caption)
.lineLimit(1)
}
}
}
.padding()
}
LazyHGrid
let rows = [
GridItem(.fixed(80)),
GridItem(.fixed(80))
]
ScrollView(.horizontal) {
LazyHGrid(rows: rows, spacing: 12) {
ForEach(items) { item in
ItemCell(item: item)
}
}
.padding()
}
Frame, Padding, Offset
.frame()
// Exact size
Text("Fixed").frame(width: 200, height: 50)
// Flexible constraints
Text("Flexible")
.frame(minWidth: 100, maxWidth: 300, minHeight: 44)
// Fill available width
Text("Full Width")
.frame(maxWidth: .infinity, alignment: .leading)
// Fill available space
Color.blue
.frame(maxWidth: .infinity, maxHeight: .infinity)
// Ideal size (proposed when parent uses .fixedSize)
Text("Ideal").frame(idealWidth: 200, idealHeight: 100)
// fixedSize prevents truncation
Text("This long text will not be truncated")
.fixedSize(horizontal: true, vertical: false)
.padding()
Text("Padded")
.padding() // Default on all edges (~16pt)
.padding(20) // Custom amount all edges
.padding(.horizontal, 16) // Specific edge set
.padding(.top, 8) // Single edge
.padding(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
.offset()
Moves the view visually without changing its layout position.
Circle()
.fill(.blue)
.frame(width: 50, height: 50)
.offset(x: 20, y: -10) // Moves right 20, up 10
// Common pattern: notification badge
ZStack(alignment: .topTrailing) {
Image(systemName: "bell")
.font(.title)
Text("3")
.font(.caption2)
.padding(4)
.background(.red)
.foregroundStyle(.white)
.clipShape(Circle())
.offset(x: 8, y: -8)
}
Safe Area
safeAreaInset (iOS 15+)
Adds content in the safe area without overlapping.
ScrollView {
LazyVStack {
ForEach(messages) { message in
MessageRow(message: message)
}
}
}
.safeAreaInset(edge: .bottom) {
HStack {
TextField("Message", text: $newMessage)
.textFieldStyle(.roundedBorder)
Button("Send", systemImage: "arrow.up.circle.fill") {
sendMessage()
}
}
.padding()
.background(.bar)
}
safeAreaPadding (iOS 17+)
Adds padding within the safe area.
ScrollView(.horizontal) {
LazyHStack {
ForEach(items) { item in
ItemCard(item: item)
}
}
}
.safeAreaPadding(.horizontal, 16) // Content scrolls edge-to-edge but starts padded
Ignoring Safe Area
Color.blue
.ignoresSafeArea() // All edges
.ignoresSafeArea(.keyboard) // Only keyboard
.ignoresSafeArea(.container, edges: .bottom) // Only bottom
Custom Layout Protocol (iOS 16+)
Create completely custom layout logic.
struct RadialLayout: Layout {
var radius: CGFloat
var startAngle: Angle = .zero
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
let maxSize = subviews.reduce(CGSize.zero) { currentMax, subview in
let size = subview.sizeThatFits(.unspecified)
return CGSize(
width: max(currentMax.width, size.width),
height: max(currentMax.height, size.height)
)
}
return CGSize(
width: (radius + maxSize.width) * 2,
height: (radius + maxSize.height) * 2
)
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
let angleStep = Angle.degrees(360.0 / Double(subviews.count))
for (index, subview) in subviews.enumerated() {
let angle = startAngle + angleStep * Double(index)
let x = bounds.midX + radius * cos(angle.radians)
let y = bounds.midY + radius * sin(angle.radians)
subview.place(
at: CGPoint(x: x, y: y),
anchor: .center,
proposal: .unspecified
)
}
}
}
// Usage
RadialLayout(radius: 100) {
ForEach(0..<8) { i in
Circle()
.fill(Color(hue: Double(i) / 8, saturation: 0.8, brightness: 0.9))
.frame(width: 40, height: 40)
}
}
Animated Layout Transitions
struct ContentView: View {
@State private var useRadial = false
var body: some View {
let layout = useRadial ? AnyLayout(RadialLayout(radius: 120)) : AnyLayout(HStackLayout())
layout {
ForEach(items) { item in
ItemView(item: item)
}
}
.animation(.spring, value: useRadial)
Button("Toggle Layout") { useRadial.toggle() }
}
}
ScrollView and ScrollViewReader
ScrollViewReader
Programmatic scrolling to specific views.
ScrollViewReader { proxy in
ScrollView {
LazyVStack {
ForEach(messages) { message in
MessageRow(message: message)
.id(message.id)
}
}
}
.onChange(of: messages.count) { _, _ in
withAnimation {
proxy.scrollTo(messages.last?.id, anchor: .bottom)
}
}
}
scrollPosition (iOS 17+)
@State private var scrollPosition: String?
ScrollView {
LazyVStack {
ForEach(items) { item in
ItemCard(item: item)
.id(item.id)
}
}
.scrollTargetLayout()
}
.scrollPosition(id: $scrollPosition)
.onChange(of: scrollPosition) { _, id in
print("Visible item: \(id ?? "none")")
}
containerRelativeFrame (iOS 17+)
Size views relative to the scroll container.
ScrollView(.horizontal) {
LazyHStack(spacing: 16) {
ForEach(items) { item in
ItemCard(item: item)
.containerRelativeFrame(.horizontal, count: 1, spacing: 16)
// Full width card, one at a time
}
}
.scrollTargetLayout()
}
.scrollTargetBehavior(.paging) // .paging or .viewAligned
.scrollIndicators(.hidden)
scrollTransition (iOS 17+)
Apply effects as views enter/exit the scroll viewport.
ScrollView {
LazyVStack(spacing: 16) {
ForEach(items) { item in
ItemCard(item: item)
.scrollTransition { content, phase in
content
.opacity(phase.isIdentity ? 1 : 0.3)
.scaleEffect(phase.isIdentity ? 1 : 0.8)
.blur(radius: phase.isIdentity ? 0 : 2)
}
}
}
}
ContentUnavailableView (iOS 17+)
Standard empty state view.
// Built-in search empty state
ContentUnavailableView.search
// Search with query
ContentUnavailableView.search(text: searchText)
// Custom
ContentUnavailableView {
Label("No Favorites", systemImage: "heart.slash")
} description: {
Text("Items you favorite will appear here.")
} actions: {
Button("Browse Items") { showBrowse = true }
.buttonStyle(.borderedProminent)
}
Layout Tips and Patterns
Proportional Layout
GeometryReader { proxy in
HStack(spacing: 0) {
LeftPanel()
.frame(width: proxy.size.width * 0.3)
RightPanel()
.frame(width: proxy.size.width * 0.7)
}
}
Adaptive Layout Based on Size Class
struct AdaptiveView: View {
@Environment(\.horizontalSizeClass) private var sizeClass
var body: some View {
if sizeClass == .compact {
VStack { content }
} else {
HStack { content }
}
}
@ViewBuilder
var content: some View {
SidebarView()
DetailView()
}
}
Alignment Guides
HStack(alignment: .customCenter) {
Text("Label")
.alignmentGuide(.customCenter) { d in d[VerticalAlignment.center] }
Circle()
.fill(.blue)
.frame(width: 10, height: 10)
.alignmentGuide(.customCenter) { d in d[VerticalAlignment.center] }
}
extension VerticalAlignment {
struct CustomCenter: AlignmentID {
static func defaultValue(in context: ViewDimensions) -> CGFloat {
context[VerticalAlignment.center]
}
}
static let customCenter = VerticalAlignment(CustomCenter.self)
}
---
# SwiftUI Navigation
https://nagarjuna2997.github.io/ios-agent-skill/guides/swiftui-navigation.html
← Back to all frameworks & guides
All articles →Swiftui · Reference guideSwiftUI NavigationRepository guidance for SwiftUI Navigation. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
NavigationStack
Value-Based NavigationLink with .navigationDestination
NavigationPath (Programmatic Navigation)
Typed path (homogeneous)
NavigationSplitView
Two-Column
Three-Column
Sheets, Full Screen Covers, and Popovers
.sheet()
Presentation Detents (Bottom Sheet Sizes)
.fullScreenCover()
.popover()
Alerts and Confirmation Dialogs
.alert()
.confirmationDialog()
.inspector() (iOS 17+)
TabView
Basic TabView
TabView with Enum
iOS 18+ Tab API
Deep Linking with URL Handling
Toolbar and Navigation Bar Customization
Dismiss and Navigation Actions
Type-Safe Routing Pattern
iOS 18 Zoom Navigation Transitions
Core Concepts
Basic Usage
Complete Example: Photo Grid with Zoom Transition
Using Zoom Transitions with Lists
Customizing the Transition Source Appearance
When to Use Zoom Transitions vs matchedGeometryEffect
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Choose navigation structure
↓2
Represent selection as data
↓3
Present destination
↓4
Handle dismissal and restoration
02 / ArchitectureResponsibility boundariesBoundary 1
Navigation stateBoundary 2
Destination mappingBoundary 3
Presented viewsConnected responsibilities, not a required class hierarchy or an execution trace.
Complete reference for NavigationStack, NavigationSplitView, sheets, alerts, tabs, deep linking, and programmatic navigation.
NavigationStack
The primary navigation container (iOS 16+). Replaces the deprecated
NavigationView
.
struct ContentView: View {
var body: some View {
NavigationStack {
List(items) { item in
NavigationLink(item.title) {
DetailView(item: item)
}
}
.navigationTitle("Items")
.navigationBarTitleDisplayMode(.large) // .inline, .large, .automatic
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button("Add", systemImage: "plus") { addItem() }
}
}
}
}
}
Value-Based NavigationLink with .navigationDestination
The preferred pattern -- decouples the link from its destination.
struct ContentView: View {
var body: some View {
NavigationStack {
List {
NavigationLink("Show Profile", value: Route.profile("user-123"))
NavigationLink("Settings", value: Route.settings)
ForEach(items) { item in
NavigationLink(value: item) {
ItemRow(item: item)
}
}
}
.navigationDestination(for: Item.self) { item in
ItemDetailView(item: item)
}
.navigationDestination(for: Route.self) { route in
switch route {
case .profile(let id):
ProfileView(userId: id)
case .settings:
SettingsView()
}
}
}
}
}
enum Route: Hashable {
case profile(String)
case settings
case detail(Item)
}
NavigationPath (Programmatic Navigation)
A type-erased path that supports heterogeneous value types.
@Observable
class Router {
var path = NavigationPath()
func goToProfile(_ id: String) {
path.append(Route.profile(id))
}
func goToDetail(_ item: Item) {
path.append(item)
}
func popToRoot() {
path = NavigationPath()
}
func pop() {
if !path.isEmpty {
path.removeLast()
}
}
}
struct AppView: View {
@State private var router = Router()
var body: some View {
NavigationStack(path: $router.path) {
HomeView()
.navigationDestination(for: Route.self) { route in
routeView(for: route)
}
.navigationDestination(for: Item.self) { item in
ItemDetailView(item: item)
}
}
.environment(router)
}
@ViewBuilder
func routeView(for route: Route) -> some View {
switch route {
case .profile(let id): ProfileView(userId: id)
case .settings: SettingsView()
case .detail(let item): ItemDetailView(item: item)
}
}
}
// Deep push from anywhere
struct SomeChildView: View {
@Environment(Router.self) private var router
var body: some View {
Button("Go to Profile") {
router.goToProfile("user-456")
}
}
}
Typed path (homogeneous)
@State private var path: [Item] = []
NavigationStack(path: $path) {
List(items) { item in
NavigationLink(value: item) { Text(item.title) }
}
.navigationDestination(for: Item.self) { item in
DetailView(item: item)
}
}
NavigationSplitView
Multi-column navigation for iPad and Mac. Falls back to stack on iPhone.
Two-Column
struct TwoColumnView: View {
@State private var selectedItem: Item?
var body: some View {
NavigationSplitView {
// Sidebar
List(items, selection: $selectedItem) { item in
NavigationLink(value: item) {
Text(item.title)
}
}
.navigationTitle("Items")
} detail: {
if let item = selectedItem {
ItemDetailView(item: item)
} else {
ContentUnavailableView("No Selection",
systemImage: "doc.text",
description: Text("Select an item from the sidebar"))
}
}
}
}
Three-Column
struct ThreeColumnView: View {
@State private var selectedCategory: Category?
@State private var selectedItem: Item?
var body: some View {
NavigationSplitView {
// Sidebar
List(categories, selection: $selectedCategory) { cat in
Label(cat.name, systemImage: cat.icon)
}
.navigationTitle("Categories")
} content: {
// Content list
if let category = selectedCategory {
List(category.items, selection: $selectedItem) { item in
Text(item.title)
}
.navigationTitle(category.name)
}
} detail: {
// Detail
if let item = selectedItem {
ItemDetailView(item: item)
} else {
ContentUnavailableView.search
}
}
.navigationSplitViewStyle(.balanced) // .balanced, .prominentDetail, .automatic
.navigationSplitViewColumnWidth(min: 200, ideal: 250, max: 350)
}
}
Sheets, Full Screen Covers, and Popovers
.sheet()
@State private var showSettings = false
@State private var selectedItem: Item?
var body: some View {
VStack {
Button("Settings") { showSettings = true }
ForEach(items) { item in
Button(item.title) { selectedItem = item }
}
}
// Boolean-driven
.sheet(isPresented: $showSettings) {
SettingsView()
.presentationDetents([.medium, .large]) // iOS 16+
.presentationDragIndicator(.visible)
.presentationCornerRadius(24) // iOS 16.4+
.presentationBackground(.ultraThinMaterial) // iOS 16.4+
.interactiveDismissDisabled() // Prevent swipe dismiss
}
// Item-driven (auto-dismisses when nil)
.sheet(item: $selectedItem) { item in
ItemDetailView(item: item)
}
}
Presentation Detents (Bottom Sheet Sizes)
.sheet(isPresented: $showSheet) {
SheetContent()
.presentationDetents([.height(200), .medium, .large])
.presentationDetents([.fraction(0.25), .medium])
// Custom detent
.presentationDetents([.custom(MyDetent.self)])
}
struct MyDetent: CustomPresentationDetent {
static func height(in context: Context) -> CGFloat? {
max(context.maxDetentValue * 0.3, 300)
}
}
.fullScreenCover()
@State private var showOnboarding = false
.fullScreenCover(isPresented: $showOnboarding) {
OnboardingView()
}
.popover()
@State private var showPopover = false
Button("Info") { showPopover = true }
.popover(isPresented: $showPopover, arrowEdge: .top) {
VStack {
Text("Helpful info")
Text("More details here")
}
.padding()
.frame(minWidth: 200)
}
Alerts and Confirmation Dialogs
.alert()
@State private var showAlert = false
@State private var error: AppError?
// Boolean-driven
.alert("Delete Item?", isPresented: $showAlert) {
Button("Delete", role: .destructive) { deleteItem() }
Button("Cancel", role: .cancel) { }
} message: {
Text("This action cannot be undone.")
}
// Error-driven with item binding
.alert("Error", isPresented: .constant(error != nil), presenting: error) { _ in
Button("Retry") { retry() }
Button("OK", role: .cancel) { error = nil }
} message: { error in
Text(error.localizedDescription)
}
.confirmationDialog()
Action sheet style on iPhone, popover on iPad.
@State private var showDialog = false
.confirmationDialog("Sort By", isPresented: $showDialog, titleVisibility: .visible) {
Button("Name") { sort = .name }
Button("Date") { sort = .date }
Button("Size") { sort = .size }
Button("Cancel", role: .cancel) { }
} message: {
Text("Choose how to sort your items")
}
.inspector() (iOS 17+)
A trailing column overlay for supplementary content.
@State private var showInspector = false
NavigationStack {
ContentView()
.toolbar {
Button("Inspector", systemImage: "info.circle") {
showInspector.toggle()
}
}
.inspector(isPresented: $showInspector) {
InspectorView()
.inspectorColumnWidth(min: 200, ideal: 300, max: 400)
}
}
TabView
Basic TabView
struct MainTabView: View {
@State private var selectedTab = 0
var body: some View {
TabView(selection: $selectedTab) {
HomeView()
.tabItem {
Label("Home", systemImage: "house")
}
.tag(0)
SearchView()
.tabItem {
Label("Search", systemImage: "magnifyingglass")
}
.tag(1)
ProfileView()
.tabItem {
Label("Profile", systemImage: "person")
}
.tag(2)
.badge(3) // Notification badge
}
}
}
TabView with Enum
enum AppTab: String, CaseIterable {
case home, search, favorites, profile
var title: String { rawValue.capitalized }
var icon: String {
switch self {
case .home: "house"
case .search: "magnifyingglass"
case .favorites: "heart"
case .profile: "person"
}
}
}
struct MainView: View {
@State private var selectedTab: AppTab = .home
var body: some View {
TabView(selection: $selectedTab) {
ForEach(AppTab.allCases, id: \.self) { tab in
NavigationStack {
tabContent(for: tab)
}
.tabItem { Label(tab.title, systemImage: tab.icon) }
.tag(tab)
}
}
}
}
iOS 18+ Tab API
TabView {
Tab("Home", systemImage: "house") {
HomeView()
}
Tab("Search", systemImage: "magnifyingglass") {
SearchView()
}
TabSection("Library") {
Tab("Favorites", systemImage: "heart") {
FavoritesView()
}
Tab("Downloads", systemImage: "arrow.down.circle") {
DownloadsView()
}
}
Tab("Profile", systemImage: "person") {
ProfileView()
}
}
.tabViewStyle(.sidebarAdaptable) // Sidebar on iPad, tab bar on iPhone
Deep Linking with URL Handling
@main
struct MyApp: App {
@State private var router = Router()
var body: some Scene {
WindowGroup {
RootView()
.environment(router)
.onOpenURL { url in
router.handle(url)
}
}
}
}
@Observable
class Router {
var path = NavigationPath()
var selectedTab: AppTab = .home
func handle(_ url: URL) {
// myapp://items/123
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true),
let host = components.host else { return }
path = NavigationPath() // Reset
switch host {
case "items":
selectedTab = .home
if let id = components.path.split(separator: "/").first.map(String.init) {
path.append(Route.detail(id))
}
case "profile":
selectedTab = .profile
case "settings":
selectedTab = .home
path.append(Route.settings)
default:
break
}
}
}
Info.plist URL scheme:
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>myapp</string>
</array>
</dict>
</array>
Toolbar and Navigation Bar Customization
.toolbar {
// Placement options
ToolbarItem(placement: .topBarLeading) {
Button("Edit") { }
}
ToolbarItem(placement: .topBarTrailing) {
Menu("Sort", systemImage: "arrow.up.arrow.down") {
Button("Name") { }
Button("Date") { }
}
}
ToolbarItem(placement: .bottomBar) {
Text("\(items.count) items")
}
ToolbarItem(placement: .primaryAction) {
Button("Add", systemImage: "plus") { }
}
// Group multiple items
ToolbarItemGroup(placement: .topBarTrailing) {
Button("Filter", systemImage: "line.3.horizontal.decrease.circle") { }
Button("Add", systemImage: "plus") { }
}
}
.toolbarBackground(.visible, for: .navigationBar)
.toolbarBackground(.ultraThinMaterial, for: .navigationBar)
.toolbarColorScheme(.dark, for: .navigationBar)
.toolbarTitleDisplayMode(.inline)
// Searchable
.searchable(text: $searchText, prompt: "Search items")
.searchSuggestions {
ForEach(suggestions) { suggestion in
Text(suggestion.title)
.searchCompletion(suggestion.title)
}
}
.searchScopes($scope) {
Text("All").tag(SearchScope.all)
Text("Recent").tag(SearchScope.recent)
}
Dismiss and Navigation Actions
struct SheetView: View {
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
Form { /* content */ }
.navigationTitle("New Item")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") {
save()
dismiss()
}
}
}
}
}
}
Type-Safe Routing Pattern
enum Route: Hashable {
case itemList(Category)
case itemDetail(Item)
case profile(userId: String)
case settings
case settingsDetail(SettingsSection)
}
@Observable
class Router {
var path = NavigationPath()
var sheet: Sheet?
var alert: AlertItem?
enum Sheet: Identifiable {
case newItem
case editItem(Item)
var id: String {
switch self {
case .newItem: "newItem"
case .editItem(let item): "edit-\(item.id)"
}
}
}
func push(_ route: Route) { path.append(route) }
func pop() { if !path.isEmpty { path.removeLast() } }
func popToRoot() { path = NavigationPath() }
func present(_ sheet: Sheet) { self.sheet = sheet }
}
struct RootView: View {
@State private var router = Router()
var body: some View {
NavigationStack(path: $router.path) {
HomeScreen()
.navigationDestination(for: Route.self) { route in
destination(for: route)
}
}
.sheet(item: $router.sheet) { sheet in
sheetContent(for: sheet)
}
.environment(router)
}
@ViewBuilder
func destination(for route: Route) -> some View {
switch route {
case .itemList(let category): ItemListView(category: category)
case .itemDetail(let item): ItemDetailView(item: item)
case .profile(let id): ProfileView(userId: id)
case .settings: SettingsView()
case .settingsDetail(let section): SettingsDetailView(section: section)
}
}
@ViewBuilder
func sheetContent(for sheet: Router.Sheet) -> some View {
switch sheet {
case .newItem: NewItemView()
case .editItem(let item): EditItemView(item: item)
}
}
}
iOS 18 Zoom Navigation Transitions
iOS 18 introduces the
NavigationTransition
protocol and built-in zoom transitions that create fluid, context-preserving animations between source and destination views. This replaces many custom
matchedGeometryEffect
patterns for navigation-based hero animations.
Core Concepts
The zoom transition system has three parts:
.navigationTransition(.zoom(sourceID:in:))
-- applied to the destination view to define how it animates in.
.matchedTransitionSource(id:in:)
-- applied to the source view (a grid cell, list row, etc.) to mark the origin of the zoom.
@Namespace
-- a shared namespace that links the source and destination together.
Unlike
matchedGeometryEffect
, which requires manual
ZStack
layering and conditional rendering, zoom navigation transitions work directly with
NavigationStack
and
NavigationLink
. The system handles the interpolation between source and destination frames automatically.
Basic Usage
struct PhotoGridView: View {
@Namespace private var zoomTransition
let photos: [Photo]
var body: some View {
NavigationStack {
ScrollView {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 100), spacing: 2)], spacing: 2) {
ForEach(photos) { photo in
NavigationLink(value: photo) {
AsyncImage(url: photo.thumbnailURL) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
} placeholder: {
Color.gray.opacity(0.3)
}
.frame(minHeight: 100)
.clipped()
}
.matchedTransitionSource(id: photo.id, in: zoomTransition)
}
}
}
.navigationTitle("Photos")
.navigationDestination(for: Photo.self) { photo in
PhotoDetailView(photo: photo)
.navigationTransition(.zoom(sourceID: photo.id, in: zoomTransition))
}
}
}
}
Complete Example: Photo Grid with Zoom Transition
import SwiftUI
struct Photo: Identifiable, Hashable {
let id: UUID
let imageName: String
let title: String
let date: Date
}
struct PhotoGalleryView: View {
@Namespace private var zoomNamespace
let photos: [Photo] = [
Photo(id: UUID(), imageName: "photo1", title: "Sunset", date: .now),
Photo(id: UUID(), imageName: "photo2", title: "Mountain", date: .now),
Photo(id: UUID(), imageName: "photo3", title: "Ocean", date: .now),
Photo(id: UUID(), imageName: "photo4", title: "Forest", date: .now),
Photo(id: UUID(), imageName: "photo5", title: "City", date: .now),
Photo(id: UUID(), imageName: "photo6", title: "Desert", date: .now),
]
private let columns = [
GridItem(.adaptive(minimum: 120), spacing: 4)
]
var body: some View {
NavigationStack {
ScrollView {
LazyVGrid(columns: columns, spacing: 4) {
ForEach(photos) { photo in
NavigationLink(value: photo) {
PhotoThumbnail(photo: photo)
}
.buttonStyle(.plain)
.matchedTransitionSource(id: photo.id, in: zoomNamespace)
}
}
.padding(4)
}
.navigationTitle("Gallery")
.navigationDestination(for: Photo.self) { photo in
PhotoDetailView(photo: photo)
.navigationTransition(.zoom(sourceID: photo.id, in: zoomNamespace))
}
}
}
}
struct PhotoThumbnail: View {
let photo: Photo
var body: some View {
Image(photo.imageName)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(minHeight: 120)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
struct PhotoDetailView: View {
let photo: Photo
var body: some View {
ScrollView {
VStack(spacing: 16) {
Image(photo.imageName)
.resizable()
.aspectRatio(contentMode: .fit)
VStack(alignment: .leading, spacing: 8) {
Text(photo.title)
.font(.title)
.fontWeight(.bold)
Text(photo.date, style: .date)
.font(.subheadline)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal)
Spacer()
}
}
.navigationTitle(photo.title)
.navigationBarTitleDisplayMode(.inline)
}
}
Using Zoom Transitions with Lists
Zoom transitions also work with
List
rows:
struct ItemListView: View {
@Namespace private var listZoom
let items: [Item]
var body: some View {
NavigationStack {
List(items) { item in
NavigationLink(value: item) {
HStack {
Image(systemName: item.icon)
.font(.title2)
.frame(width: 44, height: 44)
.background(.blue.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 10))
VStack(alignment: .leading) {
Text(item.title)
.font(.headline)
Text(item.subtitle)
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
}
.matchedTransitionSource(id: item.id, in: listZoom)
}
.navigationTitle("Items")
.navigationDestination(for: Item.self) { item in
ItemDetailView(item: item)
.navigationTransition(.zoom(sourceID: item.id, in: listZoom))
}
}
}
}
Customizing the Transition Source Appearance
You can style the matched transition source with a clip shape and additional modifiers:
NavigationLink(value: photo) {
PhotoThumbnail(photo: photo)
}
.matchedTransitionSource(id: photo.id, in: zoomNamespace) { source in
source
.clipShape(RoundedRectangle(cornerRadius: 16))
.shadow(radius: 4)
}
When to Use Zoom Transitions vs matchedGeometryEffect
Scenario
Use
NavigationStack push/pop with grid or list
Zoom transition -- built-in, automatic, works with NavigationLink
Custom overlay expansions (ZStack-based)
matchedGeometryEffect -- full control over both states
Tab switches with shared elements
matchedGeometryEffect -- zoom transitions only work within NavigationStack
Sheet or full-screen cover presentations
matchedGeometryEffect -- zoom transitions are navigation-only
iOS 17 and earlier support required
matchedGeometryEffect -- zoom transitions require iOS 18+
Simple navigation hero animations
Zoom transition -- far less boilerplate, fewer edge cases
Key differences:
-
Zoom transitions
are managed entirely by the navigation system. You do not need
ZStack
, conditional rendering, or manual animation triggers.
-
matchedGeometryEffect
requires you to manage both source and destination visibility yourself and wrap state changes in
withAnimation
.
- Zoom transitions only work with
NavigationStack
push/pop. They do not work with sheets, full-screen covers, or custom presentation controllers.
- Zoom transitions gracefully degrade on older OS versions (the standard push animation plays instead).
---
# SwiftUI State and Data Flow
https://nagarjuna2997.github.io/ios-agent-skill/guides/swiftui-state-and-data-flow.html
← Back to all frameworks & guides
All articles →Swiftui · Reference guideSwiftUI State and Data FlowRepository guidance for SwiftUI State and Data Flow. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
@State
@Binding
@Observable (iOS 17+, Observation Framework)
@Observable and Actor Isolation
Where the @MainActor goes
Observable models that are not view models
Observation Traps in Child Views
Trap 1: reads outside body are never tracked
Trap 2: passing the whole model down over-invalidates the child
Trap 3: the wrong wrapper in the child detaches it from the parent
Trap 4: mutating observable state during body
Trap 5: a collection element replaced wholesale invalidates every row
Async Boundaries in Views
.task over Task { } in onAppear
State written after an await may be stale
Overlapping calls need an explicit in-flight task
@Bindable (iOS 17+)
@ObservableObject and @Published (Legacy, pre-iOS 17)
@StateObject vs @ObservedObject
@Environment
Custom EnvironmentKey
Environment with @Observable (iOS 17+)
@EnvironmentObject (Legacy)
@AppStorage and @SceneStorage
@AppStorage
@SceneStorage
@Query (SwiftData Integration)
Data Flow Patterns and Best Practices
When to Use Which Property Wrapper
Recommended Architecture (iOS 17+)
State Hoisting Pattern
Action Closure Pattern
Avoiding Common Pitfalls
Property Wrapper Decision Checklist
Anti-Patterns Summary
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Choose a state owner
↓2
Pass values or bindings
↓3
Mutate through one owner
↓4
Observe dependent views
02 / ArchitectureResponsibility boundariesBoundary 1
State ownerBoundary 2
Binding or environmentBoundary 3
Dependent viewConnected responsibilities, not a required class hierarchy or an execution trace.
Complete reference for property wrappers, the Observation framework, and data flow patterns.
@State
Owns mutable state local to a view. SwiftUI manages storage; the view re-renders when the value changes.
struct CounterView: View {
@State private var count = 0
@State private var items: [String] = []
var body: some View {
VStack {
Text("Count: \(count)")
Button("Increment") { count += 1 }
Button("Add Item") { items.append("Item \(items.count)") }
}
}
}
Rules:
- Always mark
@State
properties
private
.
- Do not initialize
@State
from an initializer parameter when the view may be recreated -- use
@Binding
or a model instead.
- Works with value types (structs, enums, primitives) and, on iOS 17+, with
@Observable
classes.
@Binding
A two-way reference to state owned by a parent view. Does not own the data.
struct ToggleRow: View {
let title: String
@Binding var isOn: Bool
var body: some View {
Toggle(title, isOn: $isOn)
}
}
struct SettingsView: View {
@State private var wifiEnabled = true
var body: some View {
ToggleRow(title: "Wi-Fi", isOn: $wifiEnabled)
}
}
Constant binding (for previews or tests):
ToggleRow(title: "Preview", isOn: .constant(true))
Custom binding:
let binding = Binding<Bool>(
get: { preferences.isDarkMode },
set: { preferences.isDarkMode = $0 }
)
Toggle("Dark Mode", isOn: binding)
@Observable (iOS 17+, Observation Framework)
The Observation framework is the default for all new code.
ObservableObject
+
@Published
is legacy: it is coarser (any published change invalidates every
observing view), it needs a wrapper on every property, and it forces
@StateObject
/
@ObservedObject
distinctions that
@State
handles on its own.
Use it only when you must support iOS 16 or earlier, or when integrating with an
existing Combine pipeline — and label those as legacy where they appear.
Every sample in this document uses
@Observable
with
@MainActor
on any type
the UI renders. That pairing is not stylistic: see
@Observable and Actor
Isolation
below for why the annotation is required rather than optional.
import Observation
@Observable
class UserProfile {
var name = ""
var email = ""
var avatarURL: URL?
// Computed properties are automatically tracked
var isComplete: Bool {
!name.isEmpty && !email.isEmpty
}
}
struct ProfileView: View {
// Just use @State for owned observable objects
@State private var profile = UserProfile()
var body: some View {
Form {
TextField("Name", text: $profile.name)
TextField("Email", text: $profile.email)
if profile.isComplete {
Text("Profile complete!")
}
}
}
}
// Pass to child views as plain parameters -- no wrapper needed
struct ProfileHeader: View {
var profile: UserProfile // Automatically tracks changes
var body: some View {
Text(profile.name)
}
}
Key advantages over ObservableObject:
- Fine-grained tracking: only properties actually read by a view trigger re-renders.
- No need for
@Published
on every property.
- Child views do not need
@ObservedObject
-- just accept as a regular parameter.
- Works with
@State
for ownership.
@Observable and Actor Isolation
@Observable
is
not
an isolation annotation. It generates observation
plumbing and nothing else. An
@Observable
class with no other annotation is
nonisolated
, which means any task on any thread may mutate it while SwiftUI is
reading it during layout.
// WRONG -- nonisolated observable state mutated from a background task.
@Observable
final class SearchModel {
var results: [Result] = []
func search(_ query: String) {
Task.detached {
let found = await api.search(query)
self.results = found // races with SwiftUI's read of `results`
}
}
}
// RIGHT -- isolate the whole type to the main actor.
@MainActor
@Observable
final class SearchModel {
private(set) var results: [Result] = []
private let api: any SearchService
init(api: any SearchService) { self.api = api }
func search(_ query: String) async {
results = await api.search(query) // resumes on the main actor
}
}
Rule: any @Observable type that a view renders is @MainActor.
Under
Swift 6 language mode the compiler enforces this; under Swift 5 mode it is a
silent data race.
Isolating the type costs nothing at runtime for UI work.
await api.search(query)
still runs its network work off the main actor -- the main actor is occupied only
while your own statements execute, not across the suspension.
Where the
@MainActor
goes
// Preferred -- one annotation, whole type isolated, no accidental gaps.
@MainActor @Observable final class ViewModel { … }
// Fragile -- per-member isolation leaves `items` nonisolated and mutable
// from anywhere, which is exactly the race you were trying to prevent.
@Observable final class ViewModel {
var items: [Item] = []
@MainActor func reload() async { … }
}
Mark the type
final
as well:
@Observable
on a non-final class allows a
subclass to add unobserved stored properties, and it costs a dynamic dispatch on
every access.
Observable models that are not view models
A shared
@Observable
model touched from background work should be an
actor
that publishes to a
@MainActor
projection, not a nonisolated observable:
actor SyncEngine {
func pendingCount() async -> Int { … }
}
@MainActor
@Observable
final class SyncStatusModel {
private(set) var pending = 0
private let engine: SyncEngine
init(engine: SyncEngine) { self.engine = engine }
func refresh() async {
pending = await engine.pendingCount()
}
}
Observation Traps in Child Views
@Observable
tracks the properties a view reads
during body evaluation
.
Everything below follows from that one sentence.
Trap 1: reads outside
body
are never tracked
// WRONG -- `status` is read in onAppear, so no dependency is registered and
// the label never updates when status changes.
struct StatusBadge: View {
let monitor: ConnectionMonitor // @Observable
@State private var label = ""
var body: some View {
Text(label)
.onAppear { label = monitor.status.description }
}
}
// RIGHT -- read it in body.
struct StatusBadge: View {
let monitor: ConnectionMonitor
var body: some View {
Text(monitor.status.description)
}
}
The same applies to reads inside
Task { }
closures, gesture handlers, and any
helper method not called from
body
. If a view must react to a property it does
not render, observe it explicitly:
.onChange(of: monitor.status) { _, newValue in
analytics.record(newValue)
}
Trap 2: passing the whole model down over-invalidates the child
// WRONG -- CartBadge re-renders on EVERY change to the store, including
// unrelated properties like isLoading or searchText.
struct CartBadge: View {
let store: AppStore
var body: some View { Text("\(store.cart.count)") }
}
// RIGHT -- pass only the value the child renders.
struct CartBadge: View {
let count: Int
var body: some View { Text("\(count)") }
}
Fine-grained tracking works at the
property
level, not the
object
level: a
child that reads
store.cart
is invalidated by any write to
store.cart
, but a
child handed
count: Int
is invalidated only when that number actually changes.
Trap 3: the wrong wrapper in the child detaches it from the parent
// WRONG -- @State in a child captures the object ONCE. If the parent later
// hands down a different instance, the child keeps rendering the old one.
struct DetailView: View {
@State private var model: ItemModel
init(model: ItemModel) { _model = State(initialValue: model) }
}
// RIGHT -- borrow it. Plain `let` for read-only, @Bindable when you need `$`.
struct DetailView: View {
let model: ItemModel // read-only
}
struct EditView: View {
@Bindable var model: ItemModel // needs TextField bindings
}
In the child you need to…
Use
Read properties only
let model: Model
Create $ bindings
@Bindable var model: Model
Own and create the object
@State private var model = Model()
Read it from far up the tree
@Environment(Model.self) private var model
Trap 4: mutating observable state during
body
Writing to observed state while SwiftUI is evaluating
body
causes
"Modifying state during view update" warnings and undefined update behaviour.
// WRONG
var body: some View {
if model.items.isEmpty { model.loadPlaceholder() } // mutation during update
return List(model.items) { … }
}
// RIGHT -- move it into a lifecycle modifier.
var body: some View {
List(model.items) { … }
.task { await model.loadIfNeeded() }
}
Trap 5: a collection element replaced wholesale invalidates every row
// If `items` is [Item] (value type), replacing the array re-evaluates every row.
// For large lists where individual elements change frequently, make the element
// an @Observable reference type so only the changed row re-renders.
@Observable final class RowModel: Identifiable {
let id: UUID
var isFavorite: Bool
init(id: UUID, isFavorite: Bool) { self.id = id; self.isFavorite = isFavorite }
}
struct FeedView: View {
let rows: [RowModel]
var body: some View {
List(rows) { row in
RowView(row: row) // only the toggled row re-renders
}
}
}
This is a trade-off, not a rule: value-type elements are simpler and correct by
default. Reach for observable elements only when profiling shows list-wide
invalidation is the bottleneck.
Async Boundaries in Views
.task
over
Task { }
in
onAppear
// WRONG -- the task outlives the view. It keeps running after the screen is
// popped and can write to a model whose UI no longer exists.
.onAppear { Task { await model.load() } }
// RIGHT -- .task is bound to the view's lifetime and cancelled on disappear.
.task { await model.load() }
// RIGHT -- .task(id:) cancels and restarts when the id changes.
.task(id: selectedID) { await model.loadDetail(selectedID) }
Because
.task
cancels, every async method it calls must treat
CancellationError
as a deliberate no-op rather than a user-facing failure:
func load() async {
do {
items = try await service.fetch()
} catch is CancellationError {
return // superseded or dismissed
} catch {
errorMessage = error.localizedDescription
}
}
State written after an await may be stale
Actor isolation guarantees exclusivity
between
suspension points, not across
them. Anything you captured before an
await
may be out of date after it.
// WRONG -- `index` was computed before the await; the array may have been
// reloaded, filtered, or reordered in the meantime.
func toggle(at index: Int) async {
let item = items[index]
try? await service.save(item)
items[index].isSynced = true // may write to the wrong row -- or crash
}
// RIGHT -- re-resolve by identity after the await.
func toggle(id: UUID) async {
guard let item = items.first(where: { $0.id == id }) else { return }
try? await service.save(item)
guard let index = items.firstIndex(where: { $0.id == id }) else { return }
items[index].isSynced = true
}
Overlapping calls need an explicit in-flight task
@MainActor
@Observable
final class FeedModel {
private(set) var posts: [Post] = []
private var loadTask: Task<Void, Never>?
func load() async {
loadTask?.cancel() // supersede the previous load
let task = Task { await performLoad() }
loadTask = task
await task.value
}
private func performLoad() async {
do {
let fetched = try await service.posts()
try Task.checkCancellation() // don't publish a superseded result
posts = fetched
} catch {
// handle
}
}
}
Without this, a slow first request can land
after
a fast second one and
overwrite fresh data with stale data -- a bug that only reproduces on bad
networks.
@Bindable (iOS 17+)
Creates bindings to properties of an
@Observable
object that is not owned via
@State
.
struct EditProfileView: View {
@Bindable var profile: UserProfile // passed in, not owned
var body: some View {
Form {
TextField("Name", text: $profile.name)
TextField("Email", text: $profile.email)
}
}
}
// Parent
struct ParentView: View {
@State private var profile = UserProfile()
var body: some View {
EditProfileView(profile: profile)
}
}
Use
@Bindable
when you receive an
@Observable
object as a parameter and need
$
binding syntax.
@ObservableObject and @Published (Legacy, pre-iOS 17)
class SettingsStore: ObservableObject {
@Published var fontSize: Double = 14
@Published var isDarkMode = false
@Published var username = ""
}
@StateObject vs @ObservedObject
struct ParentView: View {
// @StateObject: OWNS the object. Created once, survives re-renders.
@StateObject private var store = SettingsStore()
var body: some View {
ChildView(store: store)
}
}
struct ChildView: View {
// @ObservedObject: BORROWS the object. Does not own it.
@ObservedObject var store: SettingsStore
var body: some View {
Text("Font: \(store.fontSize)")
}
}
Critical rule:
Use
@StateObject
for creation,
@ObservedObject
for injection. Using
@ObservedObject
for creation causes the object to be recreated on every parent re-render.
@Environment
Reads values from the SwiftUI environment.
struct DetailView: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.colorScheme) private var colorScheme
@Environment(\.horizontalSizeClass) private var sizeClass
@Environment(\.dynamicTypeSize) private var typeSize
@Environment(\.locale) private var locale
@Environment(\.calendar) private var calendar
@Environment(\.openURL) private var openURL
@Environment(\.isSearching) private var isSearching
@Environment(\.editMode) private var editMode
var body: some View {
VStack {
Text(colorScheme == .dark ? "Dark Mode" : "Light Mode")
Button("Done") { dismiss() }
Button("Open Link") {
openURL(URL(string: "https://apple.com")!)
}
}
}
}
Custom EnvironmentKey
// 1. Define the key
struct ThemeKey: EnvironmentKey {
static let defaultValue = AppTheme.standard
}
// 2. Extend EnvironmentValues
extension EnvironmentValues {
var theme: AppTheme {
get { self[ThemeKey.self] }
set { self[ThemeKey.self] = newValue }
}
}
// 3. Set in parent
ContentView()
.environment(\.theme, AppTheme.premium)
// 4. Read in child
struct ThemedButton: View {
@Environment(\.theme) private var theme
var body: some View {
Button("Action") { }
.tint(theme.accentColor)
}
}
Environment with @Observable (iOS 17+)
@Observable
class AppSettings {
var accentColor: Color = .blue
var fontSize: Double = 16
}
// Inject via environment
ContentView()
.environment(AppSettings())
// Read in any descendant
struct ChildView: View {
@Environment(AppSettings.self) private var settings
var body: some View {
// For bindings, use @Bindable locally
@Bindable var settings = settings
Slider(value: $settings.fontSize, in: 12...24)
}
}
@EnvironmentObject (Legacy)
Injects an
ObservableObject
into the view hierarchy.
class AuthManager: ObservableObject {
@Published var isLoggedIn = false
@Published var currentUser: User?
}
// Inject at root
ContentView()
.environmentObject(AuthManager())
// Read in any descendant
struct ProfileView: View {
@EnvironmentObject var auth: AuthManager
var body: some View {
if let user = auth.currentUser {
Text(user.name)
}
}
}
Warning:
Crashes at runtime if the object is not provided in the hierarchy. Prefer
@Environment
with
@Observable
on iOS 17+.
@AppStorage and @SceneStorage
@AppStorage
Reads and writes to
UserDefaults
. The view updates when the value changes.
struct SettingsView: View {
@AppStorage("username") private var username = "Guest"
@AppStorage("isDarkMode") private var isDarkMode = false
@AppStorage("fontSize") private var fontSize = 14.0
@AppStorage("selectedTab") private var selectedTab = 0
// Custom suite
@AppStorage("token", store: UserDefaults(suiteName: "group.com.app.shared"))
private var token = ""
var body: some View {
Form {
TextField("Username", text: $username)
Toggle("Dark Mode", isOn: $isDarkMode)
Slider(value: $fontSize, in: 10...30)
}
}
}
Supported types:
Bool
,
Int
,
Double
,
String
,
URL
,
Data
, and
RawRepresentable
where
RawValue
is
Int
or
String
.
enum AppTab: String {
case home, search, profile
}
@AppStorage("currentTab") private var currentTab: AppTab = .home
@SceneStorage
Persists state per scene (restored after app relaunch). Ideal for scroll positions, selected tabs, draft text.
struct EditorView: View {
@SceneStorage("draft") private var draft = ""
@SceneStorage("scrollPosition") private var scrollPosition: String?
var body: some View {
TextEditor(text: $draft)
}
}
@Query (SwiftData Integration)
Fetches model objects from SwiftData and keeps the view updated.
import SwiftData
@Model
class Task {
var title: String
var isComplete: Bool
var createdAt: Date
init(title: String, isComplete: Bool = false) {
self.title = title
self.isComplete = isComplete
self.createdAt = .now
}
}
struct TaskListView: View {
@Query(sort: \Task.createdAt, order: .reverse)
private var tasks: [Task]
// With filter
@Query(filter: #Predicate<Task> { !$0.isComplete },
sort: \Task.createdAt)
private var pendingTasks: [Task]
@Environment(\.modelContext) private var context
var body: some View {
List(tasks) { task in
Text(task.title)
}
}
}
// Dynamic queries with init
struct FilteredTaskList: View {
@Query private var tasks: [Task]
init(showComplete: Bool) {
let predicate = #Predicate<Task> { task in
showComplete || !task.isComplete
}
_tasks = Query(filter: predicate, sort: \Task.createdAt)
}
var body: some View {
List(tasks) { task in Text(task.title) }
}
}
Data Flow Patterns and Best Practices
When to Use Which Property Wrapper
Wrapper
Ownership
Use Case
iOS
@State
Owns
Simple value types, local UI state
13+
@State + @Observable
Owns
Observable model created by this view
17+
@Binding
Borrows
Two-way reference to parent state
13+
@Bindable
Borrows
Bindings to Observable object properties
17+
@Environment
Reads
System or custom environment values
13+
@Environment(Type.self)
Reads
Observable objects via environment
17+
@AppStorage
Owns
UserDefaults-backed persistence
14+
@SceneStorage
Owns
Per-scene state restoration
14+
@Query
Reads
SwiftData model queries
17+
@StateObject
Owns
ObservableObject creation (legacy)
14+
@ObservedObject
Borrows
ObservableObject injection (legacy)
13+
@EnvironmentObject
Reads
ObservableObject via environment (legacy)
13+
Recommended Architecture (iOS 17+)
// Model layer -- isolated, final, protocol-injected.
@MainActor
@Observable
final class Store {
private(set) var items: [Item] = []
private(set) var isLoading = false
var errorMessage: String?
private let api: any ItemService
init(api: any ItemService) { self.api = api }
func fetchItems() async {
isLoading = true
defer { isLoading = false }
do {
items = try await api.getItems()
errorMessage = nil
} catch is CancellationError {
return
} catch {
errorMessage = error.localizedDescription
}
}
}
// App entry point
@main
struct MyApp: App {
@State private var store = Store(api: LiveItemService())
var body: some Scene {
WindowGroup {
ContentView()
.environment(store)
}
}
}
// Feature view
struct ItemListView: View {
@Environment(Store.self) private var store
var body: some View {
List(store.items) { item in
ItemRow(item: item)
}
.overlay {
if store.isLoading {
ProgressView()
}
}
.task {
await store.fetchItems()
}
}
}
State Hoisting Pattern
Keep state at the lowest common ancestor that needs it.
// Parent owns the state
struct FilterableList: View {
@State private var searchText = ""
@State private var sortOrder: SortOrder = .name
var body: some View {
VStack {
SearchBar(text: $searchText) // Binding down
SortPicker(selection: $sortOrder) // Binding down
ResultsList(query: searchText, sort: sortOrder) // Values down
}
}
}
Action Closure Pattern
Pass actions down instead of state up.
struct ItemRow: View {
let item: Item
let onDelete: () -> Void
let onToggle: (Bool) -> Void
var body: some View {
HStack {
Text(item.title)
Spacer()
Toggle("", isOn: Binding(
get: { item.isComplete },
set: { onToggle($0) }
))
}
.swipeActions {
Button(role: .destructive) { onDelete() } label: {
Label("Delete", systemImage: "trash")
}
}
}
}
Avoiding Common Pitfalls
// BAD: Creating @StateObject/@State Observable in a child view that gets recreated
struct ParentView: View {
@State private var toggle = false
var body: some View {
VStack {
ChildView() // ChildView recreated when toggle changes
Button("Toggle") { toggle.toggle() }
}
}
}
struct ChildView: View {
// BAD with ObservableObject -- this resets on every parent re-render
@ObservedObject var vm = ViewModel()
// GOOD -- use @StateObject instead
@StateObject private var vm = ViewModel()
}
// On iOS 17+, @State with @Observable handles this correctly:
struct ChildView: View {
@State private var vm = ViewModel() // Survives re-renders
}
Property Wrapper Decision Checklist
Before writing any state declaration, answer these in order:
Does this view create the value, or receive it?
Creates →
@State
.
Receives →
@Binding
(value),
let
(read-only object),
@Bindable
(object you need
$
on), or
@Environment
(from far up the tree).
Is it transient UI state (sheet flags, draft text, focus, scroll offset)?
→
@State
on the view. It does
not
belong on a view model.
Is it screen state derived from data (loaded items, error, isLoading)?
→ a
@MainActor @Observable
model owned by
@State
at the screen root.
Is it app-wide (session, theme, feature flags)?
→
@Observable
injected
with
.environment(_:)
, read with
@Environment(Type.self)
.
Does it need to survive relaunch?
→
@AppStorage
(user preference) or
@SceneStorage
(per-scene restoration). Neither is a place for a model.
Is it persisted model data?
→
@Query
with SwiftData.
Anti-Patterns Summary
// 1. Observable model without isolation.
@Observable final class VM { var items: [Item] = [] } // race
@MainActor @Observable final class VM { … } // correct
// 2. Non-final observable class -- allows unobserved subclass state.
@Observable class VM { } // avoid
@Observable final class VM { } // correct
// 3. View-local transient state stored on the model.
@Observable final class VM { var showSheet = false } // belongs in @State
// 4. @State in a child for an object the parent owns.
@State private var model: Model // detaches
@Bindable var model: Model // correct
// 5. Reading observable properties outside body and expecting updates.
.onAppear { label = model.title } // never re-reads
Text(model.title) // correct
// 6. Mutating observed state during body evaluation.
var body: some View { model.prepare(); return List(…) } // warning + UB
// 7. Unstructured Task in onAppear instead of .task.
.onAppear { Task { await model.load() } } // leaks past dismissal
// 8. Hand-rolled main-thread hops inside an already-isolated type.
DispatchQueue.main.async { self.items = new } // obsolete
await MainActor.run { self.items = new } // redundant
items = new // correct
// 9. @EnvironmentObject / @ObservedObject in new iOS 17+ code.
@EnvironmentObject var auth: AuthManager // legacy, crashes if unset
@Environment(AuthManager.self) private var auth // correct
---
# SwiftUI Views and Controls
https://nagarjuna2997.github.io/ios-agent-skill/guides/swiftui-views-and-controls.html
← Back to all frameworks & guides
All articles →Core UI and Apps · Reference guideSwiftUI Views and ControlsRepository guidance for SwiftUI. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
Text and Labels
Text
Label
Image and AsyncImage
Buttons and Toggles
Button
Toggle
Picker
Slider, Stepper, DatePicker, ColorPicker
Text Input
Lists and Collections
List
ForEach
ScrollView and Lazy Stacks/Grids
Form, Section, GroupBox, DisclosureGroup
Menus, Context Menus, Links
Progress and Gauge
Custom Views and ViewModifier
View Lifecycle
Common Modifiers Reference
Key Patterns
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Choose a semantic control
↓2
Bind it to feature state
↓3
Handle user action
↓4
Verify labels and disabled states
02 / ArchitectureResponsibility boundariesBoundary 1
Control semanticsBoundary 2
Feature stateBoundary 3
Action handlerConnected responsibilities, not a required class hierarchy or an execution trace.
Comprehensive reference for every major SwiftUI view, control, modifier, and lifecycle hook.
Text and Labels
Text
Displays one or more lines of read-only text.
Text("Hello, World!")
.font(.title)
.fontWeight(.bold)
.foregroundStyle(.primary)
.lineLimit(2)
.truncationMode(.tail)
.multilineTextAlignment(.center)
// Markdown support
Text("**Bold** and *italic* and [link](https://apple.com)")
// String interpolation with formatting
Text("Price: \(price, format: .currency(code: "USD"))")
Text("Date: \(date, format: .dateTime.month().day().year())")
Text(timerInterval: Date.now...Date.now.addingTimeInterval(300))
// Concatenation
Text("Hello ").bold() + Text("World").foregroundStyle(.blue)
// AttributedString
var attributed = AttributedString("Styled")
attributed.foregroundColor = .red
attributed.font = .largeTitle
Text(attributed)
Label
Pairs an icon with a title. Adapts rendering based on context (toolbar shows icon only, list shows both).
Label("Favorites", systemImage: "heart.fill")
Label("Custom", image: "myIcon")
Label {
Text("Downloads")
.font(.headline)
} icon: {
Image(systemName: "arrow.down.circle")
.foregroundStyle(.blue)
}
// Label styles
Label("Item", systemImage: "star")
.labelStyle(.titleAndIcon) // .titleOnly, .iconOnly, .automatic
Image and AsyncImage
// SF Symbols
Image(systemName: "star.fill")
.symbolRenderingMode(.multicolor)
.font(.system(size: 40))
.symbolEffect(.bounce, value: isFavorite) // iOS 17+
// Asset catalog
Image("photo")
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 200, height: 200)
.clipShape(RoundedRectangle(cornerRadius: 16))
// AsyncImage (remote images)
AsyncImage(url: URL(string: "https://example.com/photo.jpg")) { phase in
switch phase {
case .empty:
ProgressView()
case .success(let image):
image
.resizable()
.aspectRatio(contentMode: .fill)
case .failure:
Image(systemName: "photo.badge.exclamationmark")
.foregroundStyle(.secondary)
@unknown default:
EmptyView()
}
}
.frame(width: 300, height: 200)
Buttons and Toggles
Button
// Basic
Button("Tap Me") { doSomething() }
// With role
Button("Delete", role: .destructive) { deleteItem() }
Button("Cancel", role: .cancel) { dismiss() }
// Custom label
Button {
performAction()
} label: {
HStack {
Image(systemName: "plus.circle.fill")
Text("Add Item")
}
.font(.headline)
.padding()
.background(.blue)
.foregroundStyle(.white)
.clipShape(Capsule())
}
// Button styles
Button("Bordered") { }
.buttonStyle(.bordered) // .automatic, .plain, .borderless
.tint(.green)
Button("Prominent") { }
.buttonStyle(.borderedProminent)
.controlSize(.large) // .mini, .small, .regular, .large, .extraLarge
// Repeat behavior (iOS 17+)
Button("Increment", repeatBehavior: .enabled) { count += 1 }
Toggle
@State private var isOn = false
Toggle("Airplane Mode", isOn: $isOn)
Toggle(isOn: $isOn) {
Label("Wi-Fi", systemImage: "wifi")
}
.toggleStyle(.switch) // .switch, .button, .checkbox (macOS)
.tint(.orange)
Picker
@State private var selection = "Red"
let colors = ["Red", "Green", "Blue"]
// Inline
Picker("Color", selection: $selection) {
ForEach(colors, id: \.self) { Text($0) }
}
.pickerStyle(.segmented) // .menu, .wheel, .inline, .navigationLink, .palette
// With enum
enum Flavor: String, CaseIterable, Identifiable {
case chocolate, vanilla, strawberry
var id: Self { self }
}
@State private var flavor: Flavor = .chocolate
Picker("Flavor", selection: $flavor) {
ForEach(Flavor.allCases) { flavor in
Text(flavor.rawValue.capitalized).tag(flavor)
}
}
Slider, Stepper, DatePicker, ColorPicker
// Slider
@State private var speed = 50.0
Slider(value: $speed, in: 0...100, step: 5) {
Text("Speed")
} minimumValueLabel: { Text("0") }
maximumValueLabel: { Text("100") }
// Stepper
@State private var quantity = 1
Stepper("Quantity: \(quantity)", value: $quantity, in: 1...99)
Stepper("Custom") { quantity += 5 } onDecrement: { quantity -= 5 }
// DatePicker
@State private var date = Date.now
DatePicker("Date", selection: $date, displayedComponents: [.date, .hourAndMinute])
.datePickerStyle(.graphical) // .compact, .wheel, .graphical
DatePicker("Range", selection: $date, in: Date.now...)
// ColorPicker
@State private var color = Color.blue
ColorPicker("Theme", selection: $color, supportsOpacity: true)
Text Input
// TextField
@State private var name = ""
TextField("Enter name", text: $name)
.textFieldStyle(.roundedBorder)
.textContentType(.name)
.autocorrectionDisabled()
.textInputAutocapitalization(.words)
.submitLabel(.done)
.onSubmit { saveProfile() }
// With format
@State private var amount = 0.0
TextField("Amount", value: $amount, format: .currency(code: "USD"))
.keyboardType(.decimalPad)
// With prompt and axis (iOS 16+)
TextField("Bio", text: $bio, prompt: Text("Tell us about yourself"), axis: .vertical)
.lineLimit(3...6)
// SecureField
@State private var password = ""
SecureField("Password", text: $password)
.textContentType(.password)
// TextEditor (multiline)
@State private var notes = ""
TextEditor(text: $notes)
.frame(minHeight: 100)
.scrollContentBackground(.hidden) // iOS 16+ to customize background
.background(Color(.systemGray6))
.clipShape(RoundedRectangle(cornerRadius: 8))
// Focused state
@FocusState private var isNameFocused: Bool
TextField("Name", text: $name)
.focused($isNameFocused)
Button("Focus") { isNameFocused = true }
Lists and Collections
List
// Static
List {
Text("Row 1")
Text("Row 2")
Text("Row 3")
}
// Dynamic
struct Item: Identifiable {
let id = UUID()
var title: String
}
List(items) { item in
Text(item.title)
}
// Mixed static and dynamic
List {
Section("Favorites") {
ForEach(favorites) { item in
Text(item.title)
}
}
Section("All Items") {
ForEach(allItems) { item in
Text(item.title)
}
}
}
.listStyle(.insetGrouped) // .plain, .grouped, .sidebar, .inset, .insetGrouped
// Swipe actions
List {
ForEach(items) { item in
Text(item.title)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(role: .destructive) { delete(item) } label: {
Label("Delete", systemImage: "trash")
}
Button { pin(item) } label: {
Label("Pin", systemImage: "pin")
}
.tint(.yellow)
}
.swipeActions(edge: .leading) {
Button { archive(item) } label: {
Label("Archive", systemImage: "archivebox")
}
.tint(.blue)
}
}
.onDelete { indexSet in items.remove(atOffsets: indexSet) }
.onMove { from, to in items.move(fromOffsets: from, toOffset: to) }
}
// List customization
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
.listSectionSeparator(.hidden)
ForEach
// With Identifiable
ForEach(items) { item in ItemRow(item: item) }
// With id keypath
ForEach(names, id: \.self) { name in Text(name) }
// With index
ForEach(Array(items.enumerated()), id: \.offset) { index, item in
Text("\(index): \(item.title)")
}
// Range
ForEach(0..<5) { i in Text("Item \(i)") }
ScrollView and Lazy Stacks/Grids
// Vertical scroll
ScrollView {
LazyVStack(spacing: 16) {
ForEach(items) { item in
ItemCard(item: item)
}
}
.padding()
}
// Horizontal scroll
ScrollView(.horizontal, showsIndicators: false) {
LazyHStack(spacing: 12) {
ForEach(items) { item in
ItemCard(item: item)
.containerRelativeFrame(.horizontal, count: 3, spacing: 12)
}
}
.scrollTargetLayout() // iOS 17+
}
.scrollTargetBehavior(.viewAligned) // iOS 17+ snapping
// Grid
let columns = [
GridItem(.flexible()),
GridItem(.flexible()),
GridItem(.flexible())
]
ScrollView {
LazyVGrid(columns: columns, spacing: 16) {
ForEach(items) { item in
ItemCell(item: item)
}
}
}
// Adaptive grid
let adaptiveColumns = [GridItem(.adaptive(minimum: 120, maximum: 200))]
// Pinned headers
ScrollView {
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)
.padding()
.background(.bar)
}
}
}
}
Form, Section, GroupBox, DisclosureGroup
Form {
Section("Profile") {
TextField("Name", text: $name)
DatePicker("Birthday", selection: $birthday, displayedComponents: .date)
Picker("Role", selection: $role) {
ForEach(Role.allCases) { Text($0.rawValue).tag($0) }
}
}
Section {
Toggle("Notifications", isOn: $notificationsEnabled)
Toggle("Sound", isOn: $soundEnabled)
} header: {
Text("Preferences")
} footer: {
Text("Enable notifications to receive updates.")
}
}
// GroupBox
GroupBox("Statistics") {
LabeledContent("Downloads", value: "1,234")
LabeledContent("Rating", value: "4.8")
}
// LabeledContent for key-value display
LabeledContent("Version") {
Text("2.1.0").foregroundStyle(.secondary)
}
// DisclosureGroup
@State private var isExpanded = false
DisclosureGroup("Advanced Settings", isExpanded: $isExpanded) {
Toggle("Debug Mode", isOn: $debugMode)
Slider(value: $cacheSize, in: 0...1000)
}
Menus, Context Menus, Links
// Menu (pull-down button)
Menu("Options") {
Button("Duplicate", systemImage: "doc.on.doc") { duplicate() }
Button("Rename", systemImage: "pencil") { rename() }
Divider()
Button("Delete", systemImage: "trash", role: .destructive) { delete() }
Menu("Sort By") {
Button("Name") { sort(.name) }
Button("Date") { sort(.date) }
Button("Size") { sort(.size) }
}
}
.menuStyle(.borderedProminent)
// Context menu (long press)
Text("Hold me")
.contextMenu {
Button("Copy", systemImage: "doc.on.doc") { copy() }
Button("Share", systemImage: "square.and.arrow.up") { share() }
} preview: {
ItemPreview(item: item) // Custom preview
.frame(width: 300, height: 400)
}
// Link
Link("Visit Apple", destination: URL(string: "https://apple.com")!)
Link(destination: URL(string: "https://apple.com")!) {
Label("Apple", systemImage: "safari")
}
// ShareLink (iOS 16+)
ShareLink(item: URL(string: "https://apple.com")!) {
Label("Share", systemImage: "square.and.arrow.up")
}
Progress and Gauge
// Indeterminate
ProgressView()
ProgressView("Loading...")
// Determinate
ProgressView("Downloading", value: progress, total: 100)
.progressViewStyle(.linear) // .circular, .linear
// Gauge (iOS 16+)
Gauge(value: batteryLevel, in: 0...100) {
Text("Battery")
} currentValueLabel: {
Text("\(Int(batteryLevel))%")
} minimumValueLabel: {
Text("0")
} maximumValueLabel: {
Text("100")
}
.gaugeStyle(.accessoryCircular) // .linearCapacity, .accessoryLinear, .accessoryCircularCapacity
.tint(Gradient(colors: [.red, .yellow, .green]))
Custom Views and ViewModifier
// Custom View
struct ProfileCard: View {
let name: String
let role: String
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(name).font(.headline)
Text(role).font(.subheadline).foregroundStyle(.secondary)
}
.padding()
.background(.ultraThinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
// ViewModifier
struct CardStyle: ViewModifier {
func body(content: Content) -> some View {
content
.padding()
.background(.ultraThinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 12))
.shadow(color: .black.opacity(0.1), radius: 8, y: 4)
}
}
extension View {
func cardStyle() -> some View {
modifier(CardStyle())
}
}
// Usage
Text("Styled").cardStyle()
// ViewModifier with configuration
struct ShimmerModifier: ViewModifier {
let isActive: Bool
@State private var phase: CGFloat = 0
func body(content: Content) -> some View {
content
.overlay {
if isActive {
LinearGradient(
colors: [.clear, .white.opacity(0.3), .clear],
startPoint: .init(x: phase - 0.5, y: 0.5),
endPoint: .init(x: phase + 0.5, y: 0.5)
)
.onAppear {
withAnimation(.linear(duration: 1.5).repeatForever(autoreverses: false)) {
phase = 1.5
}
}
}
}
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
View Lifecycle
struct ContentView: View {
@State private var data: [Item] = []
var body: some View {
List(data) { item in
Text(item.title)
}
// Called when view appears
.onAppear { loadCachedData() }
// Called when view disappears
.onDisappear { saveState() }
// Async task tied to view lifetime (cancelled on disappear)
.task {
data = await fetchItems()
}
// Task with ID (restarts when id changes)
.task(id: selectedCategory) {
data = await fetchItems(for: selectedCategory)
}
// React to value changes (iOS 17+ syntax)
.onChange(of: searchText) { oldValue, newValue in
performSearch(newValue)
}
// React to value changes (iOS 14-16 syntax)
.onChange(of: searchText) { newValue in
performSearch(newValue)
}
// Scene phase
.onChange(of: scenePhase) { _, phase in
if phase == .background { saveData() }
}
// Receive notifications
.onReceive(NotificationCenter.default.publisher(for: .didUpdate)) { _ in
refresh()
}
}
}
Common Modifiers Reference
// Typography
.font(.title) // .largeTitle, .title, .title2, .title3, .headline,
// .subheadline, .body, .callout, .footnote, .caption, .caption2
.font(.system(size: 18, weight: .semibold, design: .rounded))
.fontDesign(.rounded) // .default, .rounded, .serif, .monospaced
.fontWidth(.expanded) // .compressed, .condensed, .standard, .expanded
.bold()
.italic()
.underline()
.strikethrough()
.kerning(2)
.tracking(1)
// Colors and styles
.foregroundStyle(.primary) // Supports ShapeStyle: colors, gradients, hierarchical
.foregroundStyle(.blue, .secondary) // Primary, secondary content
.tint(.blue)
// Layout
.padding() // All edges, default spacing
.padding(.horizontal, 16)
.padding(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
.frame(width: 200, height: 100)
.frame(maxWidth: .infinity, alignment: .leading)
.frame(minHeight: 44)
// Background and overlay
.background(.ultraThinMaterial) // .regularMaterial, .thickMaterial, .bar
.background { RoundedRectangle(cornerRadius: 12).fill(.blue.gradient) }
.background(in: RoundedRectangle(cornerRadius: 12)) // Clips background to shape
.overlay { Badge().offset(x: 10, y: -10) }
.overlay(alignment: .topTrailing) { NotificationBadge() }
.border(.gray, width: 1)
// Shape and clipping
.clipShape(Circle())
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.clipShape(Capsule())
.mask { LinearGradient(colors: [.black, .clear], startPoint: .top, endPoint: .bottom) }
// Shadow
.shadow(color: .black.opacity(0.15), radius: 12, x: 0, y: 4)
// Opacity and visibility
.opacity(0.8)
.hidden() // Hides but preserves layout space
// Interaction
.disabled(isLoading)
.allowsHitTesting(false)
.contentShape(Rectangle()) // Expand tappable area
// Accessibility
.accessibilityLabel("Close button")
.accessibilityHint("Dismisses the dialog")
.accessibilityAddTraits(.isButton)
.accessibilityHidden(true)
// Conditional modifiers (via extension)
extension View {
@ViewBuilder
func `if`<Content: View>(_ condition: Bool, transform: (Self) -> Content) -> some View {
if condition { transform(self) } else { self }
}
}
// Redaction
.redacted(reason: .placeholder)
.privacySensitive() // Redacted in inactive app states
// Environment overrides
.environment(\.colorScheme, .dark)
.dynamicTypeSize(.large ... .accessibility3)
.environment(\.layoutDirection, .rightToLeft)
Key Patterns
Prefer
LazyVStack
/
LazyHStack
inside
ScrollView
for large collections over
List
when you need custom styling.
Use
ViewModifier
to encapsulate reusable styling rather than View extensions with many chained modifiers.
Always provide
.accessibilityLabel
for icon-only buttons.
Use
.task {}
instead of
.onAppear
for async work -- it automatically cancels on disappear.
Prefer
.foregroundStyle
over the deprecated
.foregroundColor
.
Use
.background(in: shape)
over
.background().clipShape(shape)
when possible.
---
# Evaluations -- Testing Intelligence-Powered Features
https://nagarjuna2997.github.io/ios-agent-skill/guides/testing-evaluations.html
← Back to all frameworks & guides
All articles →AI and Machine Learning · Reference guideEvaluations -- Testing Intelligence-Powered FeaturesRepository guidance for Evaluations. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
Overview
1. What an Evaluation Contains
2. Minimal Evaluation Shape
3. Dataset Design
4. Evaluator Types
5. Tool-Calling Evaluations
6. Swift Testing Integration
7. Ship Criteria
8. Review Checklist
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Define success criteria
↓2
Freeze representative cases
↓3
Run the same evaluation
↓4
Inspect failures before shipping
02 / ArchitectureResponsibility boundariesBoundary 1
Evaluation datasetBoundary 2
System under testBoundary 3
Scoring and evidenceConnected responsibilities, not a required class hierarchy or an execution trace.
Overview
The Evaluations framework measures the quality of AI-powered features with executable Swift definitions. Use it to compare prompt strategies, catch regressions, test tool calls, evaluate model-as-judge criteria, and attach results to Swift Testing.
iOS 27 / Xcode 27 are released. This guide has not yet been compiled with Xcode 27; keep evaluation code close to tests and verify exact signatures in that SDK.
1. What an Evaluation Contains
Every useful evaluation has four parts:
Part
Purpose
Dataset
Realistic samples, expected values, edge cases, and adversarial inputs
Subject
The feature under test, not just the raw model
Evaluators
Code-based checks, model-as-judge checks, or tool-call trajectory checks
Aggregation
Summary metrics that decide whether the build can ship
Avoid vague criteria like "good answer." Convert them into measurable claims: "returns valid JSON," "uses only allowed tags," "calls
SearchNotes
before
CreateSummary
," or "scores 4/5 or higher for factual grounding."
2. Minimal Evaluation Shape
import Evaluations
import FoundationModels
import Testing
@available(iOS 27.0, macOS 27.0, *)
struct TaggingEvaluation: Evaluation {
let dataset = ArrayLoader(samples: [
ModelSample(prompt: "A quiet mystery set in a coastal town.", expected: ["mystery"]),
ModelSample(prompt: "A practical guide to sourdough bread.", expected: ["cooking"]),
ModelSample(prompt: "A memoir about training for a marathon.", expected: ["memoir"]),
])
let subject = BookTaggingService()
var evaluators: [Evaluator<[String]>] {
[
Evaluator("contains expected tag") { sample, response in
let passed = sample.expected.allSatisfy(response.contains)
return Metric("expected_tag_present", passed)
},
Evaluator("bounded tag count") { _, response in
Metric("tag_count_valid", (3...8).contains(response.count))
},
]
}
}
The illustrative code below has not been compiled with the released Xcode 27 SDK. Preserve the architecture: samples in, feature response out, metrics aggregated.
3. Dataset Design
Build datasets like product specifications:
Golden paths:
the most common user requests.
Boundary cases:
empty input, max length, ambiguous language, unsupported locale.
Safety cases:
sensitive requests, dangerous recommendations, privacy-sensitive data.
Regression cases:
every fixed production bug becomes a sample.
Tool cases:
expected tool names, call order, and argument constraints.
Keep sample data deterministic and reviewable. Generated synthetic samples are useful for scale, but hand-curated samples should define the release gate.
4. Evaluator Types
Evaluator
Use When
Code-based Evaluator
Correctness has a computable definition: schema, range, exact match, contains, count
ModelJudgeEvaluator
Tone, helpfulness, relevance, or clarity needs rubric scoring
ToolCallEvaluator
The feature is agentic and correctness depends on tool selection/order/arguments
Start with code-based checks. Add model-as-judge only for qualities that code cannot score reliably, and calibrate judge scores against human review.
5. Tool-Calling Evaluations
Tool failures are often silent product failures. Evaluate:
selected tool name
argument names and values
call ordering
whether a tool should not be called
recovery after tool errors
final answer grounding in tool output
let expectation = TrajectoryExpectation([
.tool("search_notes", arguments: [
"query": .contains("project deadline"),
]),
.tool("summarize_results"),
])
let evaluator = ToolCallEvaluator(expectation: expectation)
If a tool has side effects, use test doubles. Evaluation runs must never create real reminders, send messages, spend money, or mutate production data.
6. Swift Testing Integration
Run important evaluations in CI:
@Test(.evaluation(TaggingEvaluation()))
func bookTaggingQuality(context: EvaluationContext<TaggingEvaluation>) async throws {
let result = try await context.result
#expect(result.summary.metric("expected_tag_present").passRate >= 0.95)
}
Attach detailed result tables as artifacts so a failing run tells reviewers which samples regressed.
7. Ship Criteria
Define thresholds before prompt tuning:
required pass rate per metric
maximum latency or token budget
minimum tool-call correctness
allowed fallback rate
model-as-judge threshold and confidence range
reviewed dataset size and coverage categories
When changing prompts, models, tool schemas, or Dynamic Profiles, rerun the full evaluation suite. Do not merge "it looks better" changes without metric movement.
8. Review Checklist
[ ] Dataset includes golden, boundary, safety, and regression samples
[ ] Metrics are measurable and named
[ ] Tool calls have expected trajectories and argument validation
[ ] Model-as-judge rubric is calibrated against human examples
[ ] CI fails on threshold regression
[ ] Evaluation artifacts are attached for diagnosis
[ ] Production side effects are replaced with test doubles
[ ] Prompt/model/tool changes update or rerun evaluations
See also:
docs/frameworks/foundation-models.md
,
docs/tooling/foundation-models-instruments.md
,
docs/testing/mocking-strategy.md
.
---
# App-building loop (preview)
https://nagarjuna2997.github.io/ios-agent-skill/guides/tooling-app-building-loop.html
← Back to all frameworks & guides
All articles →Tooling · Reference guideApp-building loop (preview)Repository guidance for App-building loop (preview). Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
Try the complete reading-list demo
Plan and implement your own app
Evidence and resume
Validation status
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Write acceptance criteria
↓2
Implement a bounded step
↓3
Run checks and capture evidence
↓4
Resume or repair within limits
02 / ArchitectureResponsibility boundariesBoundary 1
Saved planBoundary 2
Build and test runnerBoundary 3
Evidence and progressConnected responsibilities, not a required class hierarchy or an execution trace.
One client first: the local Claude Code CLI plans and edits;
ios-agent-mcp loop
runs your acceptance commands and records progress. This is a CLI workflow alongside the unified MCP server, not an additional MCP tool or a guarantee of autonomous app creation.
Try the complete reading-list demo
Requires Node.js 20+, Python 3, macOS, Xcode with an installed iOS simulator runtime. The Xcode project is checked in; XcodeGen is only needed when changing
project.yml
.
From a checkout:
cd mcp-server
npm ci
npm run build
cd ../samples/ReadingList
node ../../mcp-server/dist/unified.js loop init --project . --brief BRIEF.md --checks checks.json --plan plan.json --attempts 2
node ../../mcp-server/dist/unified.js loop resume --project . --verify-only
node ../../mcp-server/dist/unified.js loop status --project .
The supplied plan makes this verification-only example work without an AI login. Five XCTest tests exercise persistence, search, progress, failed storage, and the UI journey. Six screenshot artifacts cover empty, add, library, detail, no results, and error states. See
the demo
.
Plan and implement your own app
Start with a project, an app brief, and trusted executable acceptance checks. Use the demo JSON files as a schema example. Each criterion maps to existing check IDs; screen names map to screenshot-producing checks. Check commands run directly with argument arrays, without a shell, in the project directory. You must author meaningful tests for your intended behavior; a successful process alone cannot establish that an arbitrary brief is satisfied.
node /path/to/ios-agent-skill/mcp-server/dist/unified.js loop init --project /path/to/app --brief BRIEF.md --checks checks.json --attempts 3
node /path/to/ios-agent-skill/mcp-server/dist/unified.js loop resume --project /path/to/app
Without
--plan
, init asks the installed, authenticated
claude
CLI for a structured plan. Review
.ios-agent/loop/state.json
before resuming. Resume runs checks first, sends failing log tails and the plan to Claude, then reruns checks after edits. Repairs are limited to 1–10 attempts, 12 Claude turns per attempt, and a ten-minute timeout per Claude invocation. Each check has its own timeout (maximum 30 minutes). This bounds attempts and time, not monetary cost. Claude subscription/API usage is governed by your own local CLI configuration.
The repair adapter exposes Read, Glob, Grep, Edit and Write; it disables external MCP connections and does not grant shell commands. Your configured acceptance commands run with your local user permissions. Inspect them before running; this workflow is not a security sandbox. List tests, scripts and project settings in
protectedFiles
so the loop detects changed acceptance files instead of accepting weakened tests. Dependencies must already be installed.
Evidence and resume
Artifacts must use canonical relative paths under
.ios-agent/evidence/
. Symlink evidence paths are rejected. Checks must create fresh, nonempty artifacts; demo verification additionally validates PNG signatures.
State, logs and evidence are stored in
.ios-agent/
. Add that directory to your project's
.gitignore
; logs may contain source code, local paths or command output. The loop does not publish them or copy credentials. Claude receives the brief, configuration and failing log excerpts through your local authenticated CLI.
Successful checks are reused only when source fingerprints and saved evidence/log hashes match. Source changes invalidate cached results. Generated directories such as
.build
,
build
,
DerivedData
,
node_modules
,
.git
,
.ios-agent
and Xcode user/workspace metadata are excluded; do not keep implementation source there.
Interruptions preserve progress and terminate active process groups on macOS/Linux. Resume rechecks interrupted work. Windows process cleanup is limited to the direct child; iOS simulator verification requires macOS.
Frozen verification files cannot change mid-run. To intentionally revise the acceptance contract, archive
.ios-agent/loop
and initialize a new run. Do not edit saved results to claim completion.
Completion means every configured check passed with retained artifacts. Screenshots need human visual inspection; they are not automatic proof of visual quality, full accessibility or App Store readiness.
Validation status
Engine tests cover cached evidence, source changes, frozen checks, retry exhaustion, interruption and a deterministic repair adapter. The demo has real simulator tests. On 2026-09-16, a real authenticated Claude Code repair passed a bounded integration fixture: the initial check failed, Claude edited the source text, and the frozen acceptance check passed on the first repair attempt. This verifies the live repair adapter, not full iOS app generation or model-generated planning. Full end-to-end app-building and the cross-client benchmark remain unverified. No API keys or credentials are included in the repository.
---
# App Description Workflow
https://nagarjuna2997.github.io/ios-agent-skill/guides/tooling-app-description-workflow.html
← Back to all frameworks & guides
All articles →Tooling · Reference guideApp Description WorkflowRepository guidance for App Description Workflow. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
Context
Goal
Input Contract
Output Shape
Color Generation Rules
Prompt Template
User Update Loop
Anti-Patterns
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Collect the app brief
↓2
Extract features and constraints
↓3
Produce a structured plan
↓4
Confirm acceptance criteria
02 / ArchitectureResponsibility boundariesBoundary 1
User descriptionBoundary 2
Planning contractBoundary 3
Implementation tasksConnected responsibilities, not a required class hierarchy or an execution trace.
Context
Use this when the user describes an app in natural language and expects the AI
to turn that description into a complete iOS build plan, implementation prompt,
and visual direction.
This workflow is for prompts and product direction. It does not replace Xcode
project creation, static MCP review, simulator screenshots, or user feedback.
Goal
Given one user description, produce:
A clear app brief.
A complete implementation prompt the coding agent can execute.
A design system derived from the description.
A color palette with accessible foreground choices.
A first-screen plan and navigation model.
A feedback loop so the user can update the result by saying what should
change.
Input Contract
Accept short, messy descriptions. Do not require the user to fill a long form.
Example:
Create a fitness app for beginners. It should track workouts, show progress,
feel energetic, and use blue and green.
If details are missing, infer reasonable defaults and label them as assumptions.
Ask a question only when the missing detail blocks implementation, such as
whether the app needs authentication, payments, health data, or backend sync.
Output Shape
Return the generated prompt in this order:
APP BRIEF
- Product type:
- Target users:
- Primary outcome:
- Core flows:
- Assumptions:
BUILD PROMPT
Build a native iOS app named <Name> using SwiftUI...
FEATURES
- ...
ARCHITECTURE
- ...
DESIGN SYSTEM
- Personality:
- Typography:
- Spacing:
- Components:
COLOR SYSTEM
- Primary:
- Secondary:
- Accent:
- Background:
- Surface:
- Text:
- Success/warning/error:
- Foreground decisions:
SCREENS
- ...
IMPLEMENTATION ORDER
1. ...
VERIFICATION
- Static MCP checks:
- Build/test command:
- Simulator screenshot path:
The
BUILD PROMPT
must be ready to paste into an AI coding agent. It should
name the framework choices, file boundaries, data models, screens, design
tokens, previews, and verification commands.
Color Generation Rules
Derive colors from the app description, not from a generic favorite palette.
Map product language to color direction:
Description signal
Likely direction
finance, trust, business, security
blues, deep greens, neutral surfaces
health, fitness, outdoors, growth
greens, teals, energetic accents
food, cooking, hospitality
warm reds, oranges, tomato, cream surfaces
education, productivity, notes
calm blues, indigo, focused neutrals
kids, creativity, games
brighter multi-color accents with restraint
luxury, fashion, portfolio
black, ivory, metallic accents, high contrast
meditation, sleep, wellness
muted lavender, sage, soft blue, low glare
developer, AI, automation
electric blue, violet, graphite, cyan accents
Always produce a tokenized palette:
enum AppTheme {
static let primary = Color(hex: "#0A6EBD")
static let secondary = Color(hex: "#18A058")
static let accent = Color(hex: "#F59E0B")
static let background = Color(.systemBackground)
static let surface = Color(.secondarySystemBackground)
static let text = Color(.label)
}
Rules:
Use semantic system colors for background, surface, and body text wherever
possible.
Never scatter raw hex values at call sites. Put all custom colors in one
theme type.
Pick foreground text for colored surfaces by contrast measurement, not by
guessing white.
If black and white both fail 4.5:1 on a small label, darken or lighten the
fill color.
Provide light and dark mode behavior.
Keep palettes domain-appropriate. A banking app should not look like a toy;
a game should not look like enterprise settings.
Prompt Template
Use this exact template when turning a description into an executable prompt:
You are building a native iOS app from this user description:
"<USER_DESCRIPTION>"
Create a production-ready SwiftUI implementation with:
- iOS deployment target: iOS 17+ unless the user asks otherwise
- Architecture: MVVM with protocol-based dependencies
- State: @MainActor @Observable view models for UI-rendered state
- Navigation: typed routes and deep-link-ready structure
- Design: tokenized colors, spacing, typography, radius, shadows
- Accessibility: Dynamic Type, VoiceOver labels, 44pt targets, contrast checks
- Previews: every main state must render without network or disk
- Testing: unit-testable use cases and injected repositories
Derived product brief:
- Name:
- Audience:
- Problem:
- Primary user journey:
- Secondary flows:
- Data model:
- Offline behavior:
Derived visual direction:
- Personality:
- Color tokens:
- Typography:
- Component style:
- Motion:
Implement:
1. App entry point
2. Theme/design tokens
3. Models
4. Repository protocols and mock repositories
5. View models
6. Screens
7. Reusable components
8. Previews
9. Tests where practical
Do not:
- Use raw colors, spacing, or font sizes in views
- Create live dependencies inside view models
- Use @Observable without @MainActor for UI state
- Claim the app works without build/test/screenshot evidence
User Update Loop
When the user gives feedback, update only the affected parts.
Examples:
User says
Update
"make it more premium"
Typography, spacing, surface treatment, color saturation, motion restraint
"change to red and black"
Color tokens, gradients, button fills, chart/category colors, contrast notes
"for kids"
Tone, iconography, larger touch targets, playful accents, simpler navigation
"add AI chat"
Feature list, architecture, privacy rules, Foundation Models availability
"make it for gym trainers"
Audience, workflows, data model, dashboard hierarchy
After updating, return:
UPDATED SECTIONS
- ...
UNCHANGED SECTIONS
- ...
NEXT EXECUTION PROMPT
...
Do not regenerate the whole plan unless the user's feedback changes the product
category or primary user journey.
Anti-Patterns
// WRONG: ask the user 15 setup questions before doing anything.
Why: the user gave enough signal to create a first useful prompt.
// RIGHT: infer defaults, label assumptions, and ask only blocking questions.
// WRONG: "Use blue because blue looks modern."
Why: color must follow the product domain and accessibility contrast.
// RIGHT: derive a palette from the app's audience, emotional tone, and task
context, then choose foreground colors by measurement.
// WRONG: rewrite the full prompt after every small user change.
Why: it loses continuity and makes user feedback feel ignored.
// RIGHT: update the affected prompt sections and preserve the rest.
---
# Device Hub
https://nagarjuna2997.github.io/ios-agent-skill/guides/tooling-device-hub.html
← Back to all frameworks & guides
All articles →Developer Tools · Reference guideDevice HubRepository guidance for Device Hub. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
September 2026 Duo check
1. What it replaces
2. The matrix that actually matters
iOS 27: app resizability
3. Accessibility testing
4. Reproducing a device-specific bug
5. Multi-device workflows
Anti-Patterns
Checklist
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Choose a device matrix
↓2
Discover available runtimes
↓3
Reproduce the same scenario
↓4
Compare recorded evidence
02 / ArchitectureResponsibility boundariesBoundary 1
Device inventoryBoundary 2
Scenario runnerBoundary 3
Device-specific evidenceConnected responsibilities, not a required class hierarchy or an execution trace.
Load this when:
reproducing a device-specific bug, managing simulators,
running accessibility checks, or testing across several devices at once.
Device Hub (Xcode 27) brings devices and simulators together in one place, so you
can diagnose and reproduce issues, inspect device state, and run testing
workflows without leaving Xcode.
Verification status (2026-09-16):
Xcode 27 is released. Device Hub UI workflows still require hands-on verification; this repository’s local runtime checks use Xcode 26.6.
September 2026 Duo check
Apple describes Duo support in Device Hub as upcoming. Use the runtime MCP’s
simulator_environment
to inspect the selected Xcode and installed profiles. See
docs/platforms/iphone-duo.md
for the dated source and adaptation checks; do not infer simulator availability from the hardware announcement.
1. What it replaces
Previously these were four separate places — Devices and Simulators, the
Accessibility Inspector,
xcrun simctl
, and Console. Device Hub consolidates
them:
Task
Before
Now
See connected devices and simulators
Devices and Simulators window
Device Hub
Inspect device state
Console + Settings on device
Device Hub
Reproduce a bug on a specific model
Manual scheme juggling
Device Hub
Run one test across several devices
Scripted xcodebuild loops
Device Hub
The practical gain is fewer context switches while chasing a device-specific
bug — which is most of the cost of chasing one.
2. The matrix that actually matters
Testing "on a simulator" is not testing. This skill's rules produce bugs that
only appear on specific configurations:
Configuration
Catches
Smallest supported device (iPhone SE)
Truncation, layouts that assume height
Largest + landscape
Stretched layouts, wasted space
iPad
Missing NavigationSplitView, size-class assumptions, app resizability
Dark mode
Gray-on-gray, invisible shadows, unreadable pills
Accessibility text sizes
Clipped rows, fixed heights, unreflowed HStacks
RTL (Arabic, Hebrew)
Hardcoded leading/trailing, mirrored icons
Oldest supported OS
APIs used without an @available guard
Physical device
Performance, camera, motion, biometrics, real network
The last one is not optional. A simulator has your Mac's CPU and no radio;
main-actor contention and network flakiness both hide there.
iOS 27: app resizability
Rebuilding against the iOS 27 SDK
automatically opts your app into
resizability
on iPad and in iPhone Mirroring. This is a behavior change you
inherit without asking for it.
That makes iPad and resized-window testing mandatory on an SDK bump, not a nice
to have. Anything that assumed a fixed width will surface here:
// FRAGILE — assumes a width that resizing invalidates.
.frame(width: 390)
GeometryReader { geo in
if geo.size.width > 700 { … } // a resized window crosses this constantly
}
// ROBUST — adapts.
@Environment(\.horizontalSizeClass) private var sizeClass
ViewThatFits(in: .horizontal) { wideLayout; narrowLayout }
Verify: launch on iPad, drag the window through every width, and confirm nothing
clips, overlaps, or jumps layout mid-drag.
3. Accessibility testing
Device Hub surfaces accessibility inspection alongside the device, which makes
it practical to check per-device rather than once at the end.
What to verify — these map to the rules in
docs/design/design-tokens.md
:
VoiceOver
reaches every interactive element in a sensible order, and each
has a meaningful label. Decorative images are hidden.
Dynamic Type
at
.accessibility5
— no clipping, rows reflow.
Contrast
≥ 4.5:1 body, 3:1 large text and controls.
Tap targets
≥ 44×44pt at every text size.
Reduce Motion
and
Reduce Transparency
are honoured.
// The three environment values every screen should respect.
@Environment(\.dynamicTypeSize) private var typeSize
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@Environment(\.accessibilityReduceTransparency) private var reduceTransparency
Automate what you can so it does not depend on remembering:
@Test("no element is missing an accessibility label")
func labels() throws {
let app = XCUIApplication()
app.launch()
for button in app.buttons.allElementsBoundByIndex {
#expect(!button.label.isEmpty)
}
}
Full reference:
docs/frameworks/accessibility.md
.
4. Reproducing a device-specific bug
1. Reproduce it on the exact reported configuration — model, OS, text size,
appearance, language. Not an approximation.
2. Inspect device state at the moment of failure.
3. Narrow: does it reproduce on a simulator? A different model? A different
OS version? Each "no" is information about the cause.
4. Fix.
5. Re-verify on the original configuration, then on the matrix above.
Step 1 is the one people skip. "It probably also happens on the 15 Pro" is a
guess, and the whole point of a device-specific bug is that the device is
specific.
This is the
swift-debugger
method — reproduce, isolate, fix, prove — applied to
hardware. See
.claude/agents/swift-debugger.md
.
5. Multi-device workflows
Running one test across several devices at once is worth it when:
You are verifying a
layout
change (widths and text sizes vary).
You are verifying an
availability
guard (OS versions vary).
You are checking a
performance
regression (hardware varies).
It is not worth it for pure logic tests — those are the same everywhere, and a
unit test is faster than a UI test on five devices. Reserve the matrix for what
actually varies by device.
Anti-Patterns
# 1. "Works on my simulator."
No radio, your Mac's CPU. Performance and network bugs hide there.
# 2. Testing one device size.
iPhone SE and iPad Pro are different products.
# 3. Skipping iPad after an SDK bump.
iOS 27 auto-opts you into resizability. Fixed-width assumptions break.
# 4. Accessibility as a pre-release pass.
Cheap per screen during development; expensive as a sweep at the end.
# 5. Approximating the reported configuration.
The device is the variable. Reproduce it exactly.
# 6. Running the full matrix on logic-only tests.
Slow, and it tells you nothing a unit test would not.
# 7. Testing only in English.
German truncates, Arabic mirrors.
Checklist
[ ] Verified on the smallest and largest supported devices.
[ ] Verified on iPad, including dragging the window across widths (iOS 27
resizability).
[ ] Verified in light and dark mode.
[ ] Verified at
.accessibility5
text size.
[ ] Verified in one RTL language.
[ ] Verified on the oldest supported OS version.
[ ] Verified on at least one physical device.
[ ] VoiceOver reaches every control with a meaningful label.
[ ] A reported bug was reproduced on its exact configuration before fixing.
---
# Foundation Models `fm` CLI
https://nagarjuna2997.github.io/ios-agent-skill/guides/tooling-fm-cli.html
← Back to all frameworks & guides
All articles →Developer Tools · Reference guideFoundation Models `fm` CLIRepository guidance for fm CLI. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
Overview
1. Use Cases
2. Safe Workflow
3. Agent Integration Pattern
4. What Not to Do
5. Review Checklist
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Define a local model experiment
↓2
Inspect available CLI support
↓3
Run a scoped prompt
↓4
Record output and limitations
02 / ArchitectureResponsibility boundariesBoundary 1
Experiment inputBoundary 2
Local model CLIBoundary 3
Evaluation recordConnected responsibilities, not a required class hierarchy or an execution trace.
Overview
Apple announced the
fm
command-line tool for prompting Foundation Models from the terminal. This fits agent workflows: use it to explore prompts, inspect model behavior, and produce quick repros before moving a prompt into app code or Evaluations.
Do not hardcode undocumented flags in automation. Use the installed tool's
fm --help
output for exact syntax in the current Xcode seed.
1. Use Cases
quick prompt exploration
comparing short instruction variants
creating repro steps for prompt bugs
generating sample outputs for evaluation design
validating model availability on a development Mac
pairing with the Foundation Models Python SDK for scripts
Do not treat manual CLI output as a release gate. Promote important cases into
docs/testing/evaluations.md
.
2. Safe Workflow
xcrun --find fm
fm --help
Then run the current seed's documented prompt/evaluation commands. Capture:
Xcode version
OS version
model/provider route if shown
prompt
instructions
output
token/latency data if shown
Keep prompt experiments under
work/
or another ignored scratch location. Do not commit transcripts that contain private user data.
3. Agent Integration Pattern
Draft a prompt in a plain text file.
Run it through
fm
manually.
Save only sanitized outputs that illustrate a failure or improvement.
Convert the prompt into app code with availability checks.
Add an Evaluations dataset before merging.
Profile the real app path with Instruments.
This keeps terminal exploration useful without letting it replace app-level testing.
4. What Not to Do
Do not rely on undocumented CLI output formats for stable CI parsing.
Do not paste real user data into terminal prompts.
Do not commit large generated transcripts.
Do not assume CLI behavior matches a device with different model availability.
Do not ship prompt changes based only on one or two successful CLI samples.
5. Review Checklist
[ ] CLI experiment includes Xcode/OS seed info
[ ] Exact
fm --help
syntax was checked locally
[ ] Private data removed from prompt/output artifacts
[ ] Important cases promoted to Evaluations
[ ] Real app path profiled with Foundation Models Instruments
See also:
docs/frameworks/foundation-models.md
,
docs/testing/evaluations.md
,
docs/tooling/foundation-models-instruments.md
.
---
# Foundation Models Instruments
https://nagarjuna2997.github.io/ios-agent-skill/guides/tooling-foundation-models-instruments.html
← Back to all frameworks & guides
All articles →Developer Tools · Reference guideFoundation Models InstrumentsRepository guidance for Foundation Models Instruments. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
Overview
1. When to Profile
2. Recording Workflow
3. What to Inspect
4. Optimization Playbook
5. PR Evidence Template
6. Review Checklist
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Reproduce a model request
↓2
Record an Instruments trace
↓3
Inspect latency and resources
↓4
Measure a focused change
02 / ArchitectureResponsibility boundariesBoundary 1
App workloadBoundary 2
Instruments recordingBoundary 3
Performance evidenceConnected responsibilities, not a required class hierarchy or an execution trace.
Overview
The Foundation Models instrument in Instruments shows how an app uses language models at runtime: prompts, responses, token use, tool calls, tool latency, session timelines, and bottlenecks. Use it before optimizing prompts, Dynamic Profiles, tools, context windows, or Private Cloud Compute usage.
1. When to Profile
Profile whenever:
a Foundation Models feature feels slow
token usage grows unexpectedly
a
LanguageModelSession
hits context limits
tool calls take too long or return too much data
Dynamic Profiles swap models/instructions unexpectedly
PCC/server-model routing changes product cost or latency
evaluation failures need runtime traces
Do not optimize prompts from intuition. Record the runtime behavior first.
2. Recording Workflow
Open the Xcode project.
Choose Product > Profile.
Select the Foundation Models template in Instruments.
Record while exercising the exact AI feature path.
Capture screenshots or export the trace for PR evidence.
Before recording, make sure the device is not thermally constrained and no background workload is skewing latency.
3. What to Inspect
Signal
Why It Matters
Input tokens
Long instructions, schemas, and tool descriptions can dominate context
Output tokens
Long answers can increase latency and power use
Cached tokens
Reused prefixes can reduce repeated work
Tool calls
Slow tools or verbose outputs often cause model delays
Session timeline
Reveals repeated calls, retries, or unexpected profile changes
Model/provider
Confirms on-device vs PCC/server routing
Errors
Shows context overflow, model unavailability, or tool failure
Pair this with
tokenCount(for:)
and
contextSize
checks in code when available.
4. Optimization Playbook
Finding
Fix
Huge instructions
Split feature roles, shorten policies, use Dynamic Profiles
Huge tool schemas
Reduce tool count, tighten argument types, avoid redundant descriptions
Huge tool output
Return compact structured summaries, page results, cap search count
Repeated cold latency
Use prewarm(promptPrefix:) where appropriate
Context overflow
Summarize transcript, start a new session, or split work
Wrong model route
Inspect profile/model selection and availability branches
PCC cost/latency too high
Route smaller tasks to SystemLanguageModel
Always rerun evaluations after prompt or tool-schema changes. Lower token count is not a win if quality regresses.
5. PR Evidence Template
Foundation Models profiling:
- Device / OS:
- Feature path:
- Input token range:
- Output token range:
- Tool calls:
- Slowest tool:
- On-device/PCC/server route:
- Optimization made:
- Evaluation result after optimization:
6. Review Checklist
[ ] A trace exists for slow or complex AI flows
[ ] Token use is measured before and after prompt/tool changes
[ ] Tool outputs are bounded and summarized
[ ] Dynamic Profile routing is visible in evidence
[ ] Context overflow has an explicit mitigation
[ ] Performance changes are paired with Evaluations results
See also:
docs/frameworks/foundation-models.md
,
docs/testing/evaluations.md
,
docs/frameworks/core-spotlight-rag.md
.
---
# From an Idea to a Running Apple App
https://nagarjuna2997.github.io/ios-agent-skill/guides/tooling-idea-to-app.html
← Back to all frameworks & guides
All articles →Tooling · Reference guideFrom an Idea to a Running Apple AppRepository guidance for From an Idea to a Running Apple App. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
Context
Pattern
One command to start
Build and see it
Finish the app identity
Client capabilities
Anti-Patterns
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Clarify the product idea
↓2
Define screens and states
↓3
Implement a vertical slice
↓4
Verify acceptance criteria
02 / ArchitectureResponsibility boundariesBoundary 1
Product briefBoundary 2
Feature implementationBoundary 3
Acceptance evidenceConnected responsibilities, not a required class hierarchy or an execution trace.
Context
Use this when the user gives an app description and wants the coding agent to implement it. The CLI creates a project starting point; the agent implements the requested features and verifies them with Xcode.
Pattern
One command to start
npx -y @nagarjuna2002/ios-agent@0.2.0 new MyApp --brief "A reading tracker with offline reading sessions and weekly goals" --xcodegen
The output includes SwiftUI source, an editable implementation brief, an XcodeGen project specification, and separate SVG icon layers. It preserves an existing nonempty project unless explicitly instructed; even force mode does not silently overwrite generated-file collisions.
Open the new folder in Claude Code, Codex, or Gemini CLI with this skill installed. Ask the agent to implement
App/APP_BRIEF.md
through build and visual verification. It should translate the idea into screens, data models, persistence, user flows, error states, and a small testable first release. Build the actual requested product instead of stopping at the starter screen.
Build and see it
On macOS with Xcode and XcodeGen installed:
cd MyApp/App
xcodegen generate --spec project.yml
open MyApp.xcodeproj
xcodebuild -project MyApp.xcodeproj -scheme MyApp -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' -derivedDataPath ../.ios-agent/build build CODE_SIGNING_ALLOWED=NO
Use Xcode’s destination list for simulator tests. The optional simulator MCP can help run and inspect the app; it requires local Xcode and is not exposed by the public knowledge server. Keep account credentials, service configuration and signing identities in the user’s environment.
Finish the app identity
Customize the generated background, foreground and accent SVGs in the app’s IconLayers folder. Follow
Icon Composer
to create a native icon with editable groups and appearance variants, then assign it to the target and verify it in a build. The starter layers are intentionally generic artwork.
Client capabilities
Claude Code, Codex and Gemini CLI can perform local implementation when granted filesystem/terminal access. The portable ChatGPT plugin supplies the same workflow and bundled references. ChatGPT without a coding environment can prepare a plan and code artifacts; a local macOS environment must perform iOS simulator builds. A remote MCP knowledge endpoint supplies public reference tools and never gets access to the user’s local source tree.
Anti-Patterns
Calling the app complete just because scaffolding succeeded.
Claiming automatic App Store publication, signing, backend provisioning or native icon creation.
Replacing an existing app’s architecture with the starter scaffold.
Presenting mock services or placeholder test assertions as production features.
---
# iOS Simulator MCP
https://nagarjuna2997.github.io/ios-agent-skill/guides/tooling-ios-simulator-mcp.html
← Back to all frameworks & guides
All articles →Developer Tools · Reference guideiOS Simulator MCPRepository guidance for iOS Simulator MCP. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
Context
Package Boundary
Sidebar preview and current device discovery
Optional MCP configuration
Current Package
Planned Tool Surface
Safety Rules
Evidence Contract
Anti-Patterns
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Discover available simulator
↓2
Build install and launch
↓3
Exercise the requested screen
↓4
Capture bounded evidence
02 / ArchitectureResponsibility boundariesBoundary 1
MCP clientBoundary 2
Simulator tool boundaryBoundary 3
Xcode and simulatorConnected responsibilities, not a required class hierarchy or an execution trace.
Context
Use this when the task needs evidence from a running app: screenshots, videos, logs, deep links, accessibility trees, gestures, app install/launch, or repeated design iteration.
Do not add these tools to
ios-agent-mcp
. The existing package is intentionally a read-only static analyzer that runs without Xcode, without a simulator, without network, and without writes. Simulator control has a different runtime contract: macOS, Xcode, a booted simulator, and user-visible side effects.
Package Boundary
Package
Contract
Good for
ios-agent-mcp
Read project files, analyze Swift, return findings
Concurrency, architecture, SwiftUI, security, testing, performance, App Store readiness
ios-simulator-mcp
Drive Xcode and a booted simulator
Build, test, install, launch, open native Simulator and preview screenshots
The split keeps a Swift linter lightweight while still giving agents a path to runtime validation.
Sidebar preview and current device discovery
Published package:
@nagarjuna2002/ios-simulator-mcp@0.2.0
(the scoped name belongs to this repository; the unscoped npm name belongs to a different publisher).
Use
simulator_environment
to inspect installed Xcode, runtimes and actual device types. For iPhone Duo, see
docs/platforms/iphone-duo.md
; do not claim a profile exists before discovery confirms it.
Call
simulator_list
and choose a real UDID.
Call
simulator_show
to boot, wait and open the native Simulator window.
Build, install and launch the app with the existing runtime tools.
Call
simulator_preview_start
. Open the returned loopback URL in the client's browser/sidebar, or any browser on the same Mac.
Interact in the native Simulator window. The browser shows refreshed screenshots with pause and fit controls; it does not send taps or keyboard input.
Call
simulator_preview_stop
when done. This removes preview screenshots and closes its listener; it leaves the Simulator running.
From a terminal, for a device that is already booted:
npx -y @nagarjuna2002/ios-simulator-mcp@0.2.0 --viewer DEVICE-UDID
Open the printed URL. Stop with Ctrl-C. It is an opt-in local viewer, not a cloud simulator or a public sharing link. The browser viewer does not upload screenshots. When
screenshot
uses
includeImage: true
, pixels are sent to the MCP client and may be processed by its model/provider. The tokenized URL grants access to simulator screenshots while running, so treat it as private.
Optional MCP configuration
Add this server to a local MCP-capable client (Claude Desktop/Code, Codex or Gemini CLI) on a Mac with Xcode:
{
"mcpServers": {
"ios-simulator": {
"command": "npx",
"args": ["-y", "@nagarjuna2002/ios-simulator-mcp@0.2.0"]
}
}
}
Adapt the surrounding configuration format to the client. The portable knowledge plugin stays independent of Xcode; simulator control is opt-in. Hosted ChatGPT cannot reach another computer's localhost just by installing this package. Use a local client for runtime control; screenshots can be returned as MCP images by calling
screenshot
with
includeImage: true
.
Current Package
The first executable slice now lives in
ios-simulator-mcp/
. It is deliberately
separate from
ios-agent-mcp
because it requires macOS, Xcode, and simulator
side effects.
Install from source:
cd ios-simulator-mcp
npm install
npm run build
node dist/index.js --help
Implemented tools:
Tool
Purpose
Backend
simulator_environment
Inspect Xcode, runtimes and device profiles
xcodebuild, xcode-select, simctl list
simulator_show
Boot, wait and open native Simulator
bootstatus, open
simulator_preview_start / simulator_preview_stop
Start/stop private loopback screenshot viewer
local HTTP + simctl io screenshot
simulator_list
List available device instances
xcrun simctl list --json
simulator_boot
Boot a simulator by UDID
xcrun simctl boot
simulator_shutdown
Shut down a booted simulator
xcrun simctl shutdown
build_project
Build an app or test target
xcodebuild build
run_tests
Run tests with captured result output
xcodebuild test
install_app
Install an .app bundle
xcrun simctl install
launch_app
Launch by bundle identifier
xcrun simctl launch
terminate_app
Terminate by bundle identifier
xcrun simctl terminate
open_deep_link
Open a URL or universal link
xcrun simctl openurl
screenshot
Capture a PNG for review
xcrun simctl io screenshot
Planned Tool Surface
Next, extend the deterministic Apple-tooling tier:
Tool
Purpose
Backend
record_video
Record and stop simulator video
xcrun simctl io recordVideo
stream_logs
Capture app logs with predicates
xcrun simctl spawn log stream
reset_app_state
Reset one app's container and permissions
app uninstall/install or container cleanup
Add UI-driving tools only after the backend spike chooses XCUITest, WebDriverAgent, or idb:
Tool
Purpose
inspect_accessibility_tree
Return visible elements, labels, roles, frames, enabled state
tap
Tap by accessibility id, text, or coordinates
double_tap
Double tap an element or point
long_press
Long press with a duration
swipe
Swipe by direction or coordinate path
type_text
Type into a focused or targeted field
wait_for_element
Poll until an element appears, disappears, or changes state
Safety Rules
Never erase all simulator content without explicit user approval.
Prefer resetting one app's state over resetting the entire simulator.
Return exact command output, screenshot paths, video paths, and log excerpts as evidence.
Capture simulator identity in every result: device name, UDID, runtime, app bundle id, and build configuration.
Time-bound every long-running command and return handles for recording/log streams.
Do not claim visual correctness from a build alone. A visual task needs a screenshot or video.
Do not type secrets into apps unless the user explicitly provides test credentials for that run.
Evidence Contract
Every runtime tool should return structured data suitable for workflow branching:
{
"status": "passed",
"device": {
"name": "iPhone 17 Pro",
"udid": "SIMULATOR-UDID",
"runtime": "iOS 27.0"
},
"app": {
"bundle_id": "com.example.App",
"configuration": "Debug"
},
"artifacts": {
"screenshot": "artifacts/screens/home.png",
"video": "artifacts/videos/flow.mov",
"logs": "artifacts/logs/app.log"
},
"summary": "Launched app and captured home screen."
}
For failure results, include the failing command, exit code, stderr, and the nearest useful artifact. A failed build with no artifact is still useful evidence; a visual claim with no artifact is not.
Anti-Patterns
// WRONG: add simulator control to ios-agent-mcp
Why: makes every static-analysis install depend on Xcode and a booted simulator.
// RIGHT: create ios-simulator-mcp with a macOS/Xcode runtime contract.
// WRONG: treat screenshot capture as enough for UI automation.
Why: screenshots prove pixels, not element identity or accessibility state.
// RIGHT: pair screenshots with accessibility-tree inspection once a UI backend is chosen.
// WRONG: expose "reset simulator" as a casual command.
Why: erasing content is destructive and surprises users.
// RIGHT: expose reset_app_state first, and require approval for simulator erase.
---
# AI-assisted issue previews
https://nagarjuna2997.github.io/ios-agent-skill/guides/tooling-issue-reporting.html
← Back to project internals
All articles →Tooling · Project internalsAI-assisted issue previewsRepository guidance for AI-assisted issue previews. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
Local developer feedback on every failed step
Private feedback from the AI chat — source preview only
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Identify a significant package problem
↓2
Prepare a category-only preview
↓3
Let developer review the public draft
↓4
Developer submits if desired
02 / ArchitectureResponsibility boundariesBoundary 1
Local failure categoryBoundary 2
Report previewBoundary 3
User-controlled GitHub issueConnected responsibilities, not a required class hierarchy or an execution trace.
Availability:
ios-agent-mcp 2.7.0 and later, with 36 unified tools.
When an iOS Agent operation fails, the AI can prepare a report using
prepare_issue_report
. Report problems with this package, not ordinary errors
in the user's app. A preview is not a diagnosis or proof of a package bug.
Example MCP arguments:
{
"feature": "asset-generation",
"symptom": "invalid-output",
"client": "claude",
"platform": "macos",
"reproducibility": "repeated"
}
Report only significant missing/incorrect guidance or a blocking package failure.
Minor warnings and normal bugs in the generated app do not belong here.
Show the complete category-only preview and say the GitHub issue will be public.
If the developer requested reporting for this issue or enabled opening major-issue
drafts for this session, the AI opens the prefilled
submissionUrl
through its
client's browser capability. Otherwise ask before opening. If browser access is
unavailable, display the link. Never claim the tool itself launches a browser.
The developer reviews the draft, signs into GitHub if needed, and clicks Submit.
Opening the draft does not submit it. No automatic submission or private backend
is needed. Do not open repeat drafts after dismissal or for the same failure.
The source also supports
missing-guidance
and
incorrect-guidance
symptoms;
these additions are pending the next npm release. Use
feature: "local-references"
for knowledge gaps. Disclose missing knowledge and label any outside research as
external, rather than attributing it to this repository.
The tool takes fixed enums only. Unknown fields and arbitrary text are rejected.
It does not read files, environment variables, project names or machine identity;
collect logs; open URLs; write a report file; or send network requests. It returns
package version, selected categories, a non-user-specific grouping key, preview
text and URLs. Missing client/platform values stay
unknown
.
Opening the URLs sends the fixed fields to GitHub. Submitting creates a public
issue. The grouping key only aids search: this tool does not search remotely,
assert that duplicates are absent, or suppress duplicates automatically.
Local developer feedback on every failed step
The unified server appends troubleshooting guidance to returned unified tool errors, preserving the original diagnostic and structured result. The agent should explain the observed error, distinguish evidence from guesses, attempt a bounded fix within the approved task, and show the verification result. Compiler/test failures returned as successful tool calls and thrown transport errors are covered by agent instructions rather than this error-result decorator. Actual presentation depends on the coding client and agent following those instructions.
This feedback stays in the coding session, not in the generated app or the public website. Redact secrets before repeating diagnostics. It does not transmit additional details or confirm a GitHub submission. Repeated failures get local attention and public submission remains separately authorized. For an actionable public bug report, separately review and authorize a minimal synthetic reproduction; never attach private app code or raw logs automatically.
Private feedback from the AI chat — source preview only
The source adds
private_feedback
as a 37th tool. It is not in npm 2.7.0 and the
receiver is not deployed. Without
IOS_AGENT_PRIVATE_FEEDBACK_URL
, it returns
not-configured
and sends nothing. There is no automatic public fallback.
Once an operator deploys the receiver, the user does not need a GitHub account:
The AI calls
private_feedback
with
action: "preview"
and a
report
containing
the same fixed categories shown above. No raw errors, logs, app source or names.
Show the complete payload, destination and privacy notice in chat. Ask whether
to send it privately; declining must not interrupt development or cause repeated prompts.
Only after explicit approval, call it with
action: "submit"
, the returned
previewId
, and
userApproved: true
. Approval expires after 15 minutes and is
consumed on the first attempt. The AI must never assert approval on its own.
Tell the user whether the receiver confirmed it. An unknown/network result is
not success; do not retry silently. No issue URL or private issue content is returned.
Example preview arguments:
{"action":"preview","report":{"feature":"simulator","symptom":"timeout","client":"claude"}}
Only the category fields and package version leave the device. The hosting provider
sees the request IP and may retain access logs; repository collaborators and GitHub
can access reports. Private repository access is not end-to-end encryption. Category
reports show patterns, not enough detail to prove or fix every bug. Any detailed
reproduction needs a separately approved privacy review.
The receiver rechecks that its fixed destination is private before every submission,
reserves uncertain requests against duplication, and applies bounded limits. It cannot
independently prove that an AI obtained consent; client instructions and the coding
client’s tool-approval controls remain important. There is no background failure hook
or automatic report on every tool call. See the
operator setup
.
---
# Build from local source and Apple guidance
https://nagarjuna2997.github.io/ios-agent-skill/guides/tooling-offline-source-library.html
← Back to all frameworks & guides
All articles →Tooling · Reference guideBuild from local source and Apple guidanceRepository guidance for Build from local source and Apple guidance. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
Context
Pattern
Where the complete code lives
Apple guidance and ownership
Anti-Patterns
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Search a narrow topic
↓2
Read the matching outline
↓3
Retrieve bounded source sections
↓4
Preserve source attribution
02 / ArchitectureResponsibility boundariesBoundary 1
Local source filesBoundary 2
Search indexBoundary 3
Bounded retrievalConnected responsibilities, not a required class hierarchy or an execution trace.
Context
Use this workflow when implementing an Apple app without repeatedly browsing documentation or feeding entire manuals to an AI. The repository contains original implementation guides, editable source files, app templates, and testable Swift packages. Apple links identify the source of API guidance and support checking changed APIs; the local implementations can be read without opening those links.
This is not Apple's private framework implementation or a mirror of every Apple documentation article. The 405-technology directory includes services, tools and legacy APIs as well as Swift frameworks. A directory entry is not a promise of a complete working example for that technology. Generated discovery guides remain labelled as such.
Pattern
Use a clone for offline work after installation. Search runs locally with Node and does not call an AI or make network requests:
node scripts/query-library.mjs search "Persistence" source
node scripts/query-library.mjs outline docs/frameworks/swiftdata.md
node scripts/query-library.mjs read samples/SkillPatterns/Sources/SkillPatterns/Persistence.swift 0 6000
search
returns up to eight file paths, titles and sizes, without file bodies.
outline
returns headings with character offsets.
read
returns exact content, a content hash and
nextOffset
; continue from that offset until it is null when the complete source is needed. Offsets are JavaScript UTF-16 string indexes, not bytes. No excerpt is silently presented as a complete file.
The same library is bundled inside the knowledge MCP package:
Call
search_local_references
with a feature/API query and
kind: "source"
for reusable code, or
kind: "guide"
for explanations.
Use
get_reference_outline
to select a guide section.
Call
read_local_reference
with its exact returned path and offset. The default body budget is 6,000 characters; the maximum is 16,000 per call. Follow
nextOffset
only if necessary.
Read sibling package manifests, dependencies and tests before adapting a source file. Preserve error handling, actor isolation and availability requirements.
Build and test the resulting app with the installed SDK. A source file being indexed does not prove it builds independently.
get_apple_technology
now defaults to an overview with local guide/source routes. Request
view: "full"
only when the entire guide and topic map are needed. The repository's Markdown examples also contain explicitly labelled wrong patterns; retain their surrounding explanation and do not automatically extract every fenced block as production Swift.
Where the complete code lives
samples/SkillPatterns/
: Swift package containing persistence, streams, routing, observation, composition and signal-processing implementations with tests.
samples/AppleRecipes/
: original Apple API implementations with their own manifest, tests and build evidence. See its README for exact platform coverage.
templates/ios-app/
: an app's screens, models, repositories and test starting points.
templates/common-patterns/
: editable networking, persistence, authentication, design and navigation source; adaptation and app-level testing are required.
cli/src/
: complete source of the app scaffolder.
mcp-server/src/
: complete source of the analysis and local knowledge servers.
See
docs/apple/local-library.md
for the generated file inventory. The index contains metadata only. Content has one canonical source file; the MCP bundle stores identical content once by SHA-256. Plugin archives carry those same canonical files so they work without fetching individual guides.
Apple guidance and ownership
Original code in this repository is covered by the repository's MIT license, subject to any file-specific notices. Apple documentation links in the guides are attribution and freshness references. Apple SDK binaries, internal source and proprietary manuals are not relicensed as part of this project. Use the installed SDK and the linked official guidance for availability or behavior that changed after the snapshot.
Anti-Patterns
WRONG: load the whole catalog and all guides for each feature. RIGHT: search first, then fetch the relevant section or source.
WRONG: label link coverage as implementation coverage. RIGHT: identify actual source paths and show build/test evidence.
WRONG: concatenate all Swift fences into one target. RIGHT: choose the correct implementation, its dependencies and tests; keep labelled anti-patterns out of production.
WRONG: promise a fixed token reduction. RIGHT: limit returned characters and measure the selected model's actual usage. Characters are not tokens.
---
# Project Scaffolding and Layout
https://nagarjuna2997.github.io/ios-agent-skill/guides/tooling-project-scaffolding.html
← Back to all frameworks & guides
All articles →Tooling · Reference guideProject Scaffolding and LayoutRepository guidance for Project Scaffolding and Layout. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
1. The layout
Create from a description
2. The rule that decides where anything goes
The one exception, and why it is not a violation
3. Why hiding internals is not cosmetic
What the reference tools actually do
4. One declaration, four behaviours
The same move, applied to the command surface
Exit codes are part of the interface
What a --fix flag may repair
5. Root discovery — how two processes agree
Interop
6. Cross-platform
Anti-Patterns
Checklist
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Choose project identity
↓2
Generate the project layout
↓3
Keep tool files separated
↓4
Build the generated project
02 / ArchitectureResponsibility boundariesBoundary 1
User-owned appBoundary 2
Tool-owned workspaceBoundary 3
Generated project configurationConnected responsibilities, not a required class hierarchy or an execution trace.
Load this when:
designing what a tool writes into a user's project, deciding
where caches and state belong, or reviewing a scaffold that puts more than a
handful of entries at the project root.
Covers the layout
ios-agent
generates, the rule that decides where any new
file goes, why hiding internals is not cosmetic, and how the CLI and the MCP
server agree on a project root without configuring each other.
Implementation:
cli/
(the
ios-agent
package). Every claim here is enforced
by a test in
cli/test/
.
1. The layout
MyApp/
├── App/ # the user's source — the only directory they edit
│ ├── MyApp/
│ └── MyAppTests/
├── README.md
├── LICENSE
├── .gitignore
└── .ios-agent/ # tool-owned; deleting it loses nothing
├── .gitignore # generated
├── config.json # tracked
├── state.json
├── metadata.json
├── cache/
├── logs/
├── build/
├── screenshots/
├── templates/ # tracked
├── plugins/ # tracked
└── tmp/
With
--minimal
, the whole project is
MyApp/App/
, and
.ios-agent/
materialises the first time a command needs it.
Create from a description
ios-agent new TeaLog --brief 'An offline tea journal with tasting notes' --xcodegen
cd TeaLog/App
xcodegen generate --spec project.yml
open TeaLog.xcodeproj
--brief
saves the description and an implementation checklist in
App/APP_BRIEF.md
for your coding agent. It does not call an AI service or
implement the described features. Both flags are optional and work with
--minimal
; the visible root remains
App/
,
README.md
, and
LICENSE
(or only
App/
in minimal mode).
--xcodegen
writes an editable
App/project.yml
,
App/BUILD.md
, and separate
SVG starters under
App/<Name>/IconLayers/
. The specification includes an iOS
17+ SwiftUI app, a unit-test target, and a shared scheme. The included test is a
placeholder to replace before shipping. Requires macOS, Xcode 15+ with an iOS
simulator runtime, and XcodeGen installed separately. The CLI neither installs
nor invokes them. Use an XcodeGen version compatible with your Xcode.
The layer folder contains background, foreground, and accent SVGs plus a manifest
and import instructions. Import them into
Icon Composer
using a compatible Xcode installation, customize the appearance, save a native
icon, configure the target, and validate it in Xcode. The manifest is our source
layer inventory, not Apple's format; no native
.icon
is generated or validated.
The starter layers are excluded from app resources.
--force
permits a non-empty destination but refuses existing generated file
paths or symlink destinations before writing. It never overwrites your source,
brief, specification, README, or configuration. Choose a new app directory when
regenerating a starter.
Project configuration follows the
XcodeGen specification
.
The CLI emits no
.xcodeproj
; running XcodeGen creates the real project.
2. The rule that decides where anything goes
Split by authorship, not by importance.
If a
human
writes it, it is visible. If the
tool
writes it, it is
hidden.
Importance is the tempting axis and it is the wrong one, because it has no
edge. Everything feels important to whoever added it, so a layout sorted by
importance grows a new root directory per release until the project root is a
list of implementation details. Authorship has a sharp edge: either a person
typed it or a program emitted it, and nobody argues about which.
Two consequences fall out immediately, and both are worth more than the tidiness:
clean needs no confirmation prompt.
Nothing in
.ios-agent/
was authored,
so there is nothing to lose. A prompt would be theatre.
Deleting .ios-agent/ is a supported recovery step.
"Delete it and re-run"
is only safe advice if it is structurally true, and here it is — which turns the
most common support answer in this class of tool from a risk into a fix.
The one exception, and why it is not a violation
config.json
is tracked, and a human may edit it. That is the same bargain
.git/config
makes: tool-managed by default, legible and editable by anyone who
opens it. It stays inside
.ios-agent/
because it is
maintained
by the tool —
ios-agent init
writes it, and future commands will update it.
If it ever becomes a file people are expected to hand-edit as the primary
workflow, it should move to the root and become visible, the way
pubspec.yaml
,
app.json
,
firebase.json
, and
Cargo.toml
all are.
No professional tool
hides configuration its users are expected to author
— a hidden file is one
they cannot discover, and one that reads as noise in a pull request.
3. Why hiding internals is not cosmetic
Four concrete costs, none of them aesthetic:
A root directory is an API.
Anything visible gets referenced — in a script,
a CI job, a README someone wrote.
cache/
at the root will be depended on
within a release, and then it cannot be renamed. Inside
.ios-agent/
, the whole
tree stays private and refactorable.
Every root entry is a question the user has to answer.
Nine directories at
the root is nine decisions about whether to touch each one, made by someone who
wanted to write a view. One
App/
is no decision at all.
Review noise trains people to skim.
A generated
metadata/
that changes on
every build turns pull requests into scroll-past exercises, and reviewers who
learn to skim generated files skim the real ones too.
.gitignore drift is a silent failure.
With internals at the root, every new
tool directory needs a matching root
.gitignore
line that someone must
remember. They forget, a cache lands in the repository, and nobody notices until
a clone is slow. The generated
.ios-agent/.gitignore
uses ignore-everything
then unignore, so a directory added tomorrow is ignored the moment it is
declared.
What the reference tools actually do
Tool
Visible, human-authored
Hidden, tool-authored
Flutter
pubspec.yaml, lib/
.dart_tool/
Expo
app.json, app/
.expo/
Firebase CLI
firebase.json
.firebase/
Cargo
Cargo.toml, src/
target/
npm
package.json, src/
node_modules/
Xcode
project.pbxproj
xcuserdata/, DerivedData
git
—
.git/
The pattern is unanimous, and the split is authorship every time. Note also that
every one of them keeps its
hand-edited config visible at the root
— which
is the point in §2 about where
config.json
would have to move.
4. One declaration, four behaviours
The design's leverage is not the directory name; it is that everything derives
from a single table (
cli/src/layout.ts
):
export const INTERNAL_ENTRIES: readonly InternalEntry[] = [
{ name: "config.json", kind: "file", tracked: true, eager: true, purpose: "…" },
{ name: "cache", kind: "directory", tracked: false, eager: false, purpose: "…" },
// …
];
Adding a future feature — simulator state, plugin cache, build artifacts — is
one row. That row then automatically produces:
its entry in the generated
.ios-agent/.gitignore
inclusion in, or exclusion from,
ios-agent clean
a path in
ios-agent where --json
a
doctor
check that it has not leaked to the project root
The alternative — a constant here, a gitignore line there, a
clean
list
somewhere else — is three places that must be edited together and eventually are
not. The failure mode is specific and quiet: a new cache directory that
clean
skips and git happily commits.
This is also why the tests assert the
relationship
rather than the contents:
test("clean never targets a tracked entry", () => {
const disposable = new Set(disposableEntries().map((e) => e.name));
for (const name of INTERNAL_ENTRIES.filter((e) => e.tracked).map((e) => e.name)) {
assert.ok(!disposable.has(name), `${name} is both tracked and disposable`);
}
});
That test does not care what the entries are. It fails the day someone adds a
row that is both tracked and deletable, which is the bug that would cost a user
their template overrides.
The same move, applied to the command surface
COMMANDS
in
cli/src/commands.ts
is the second instance of the pattern.
Dispatch,
help
, and the bash and zsh completion scripts all read one table, so
help text cannot describe a flag the parser rejects and completions cannot offer
a command that no longer exists. Hand-maintained help drifts, and the drift is
invisible until a user follows the documentation and gets an error.
The test is the same shape as the layout one — it asserts coverage rather than
content:
test("completions cover every dispatchable command", () => {
for (const shell of ["bash", "zsh"]) {
const script = capture(run(["completions", shell]));
for (const name of commandNames()) assert.ok(script.includes(name));
}
});
Exit codes are part of the interface
Code
Meaning
0
Success
1
Usage error, or no project found
2
doctor found problems
doctor
returns 2 rather than 1 so a caller can distinguish "the project is
unhealthy" from "you invoked it wrong". Collapsing both into 1 forces scripts to
parse stderr, which is exactly what
--json
exists to prevent.
What a
--fix
flag may repair
Only defects with a
derivable correct value
: a stale generated gitignore, a
missing internal directory, a config behind the current layout version. A
missing
App/
is reported and left alone — creating it would invent a project
structure the user never asked for.
The line is worth stating explicitly because it is easy to cross by accident: a
--fix
that guesses turns a diagnostic into a source of surprise changes, and
users stop trusting the command that was supposed to be the safe one.
5. Root discovery — how two processes agree
.ios-agent/
doubles as a
root marker
, exactly as
.git/
does. Both the
CLI and the MCP server walk up from the working directory until they find one:
1. an explicit --project / argument
2. IOS_AGENT_HOME (CLI) or IOS_AGENT_PROJECT (server)
3. the nearest ancestor containing .ios-agent/
4. cwd (server only — the CLI reports failure instead)
This is why
ios-agent where
works from
App/MyApp/Views/
, and it is why the
MCP server no longer analyses whatever subtree a client happened to spawn it in.
Both report how the root was resolved
, not just what it is:
{ "project_root": "/Users/you/MyApp", "resolved_from": "marker" }
An implicit root is unfalsifiable. Without
resolved_from
, "0 Swift files" is
identical whether the project is empty or the tool is pointed at the wrong
directory — and that ambiguity is the most expensive minute in using a tool like
this.
The server reads the marker; it never creates it.
That keeps
ios-agent-mcp
's declared
filesystem: read, network: none
contract intact.
Scaffolding writes, so it lives in a separate package rather than quietly
turning the analyzer into something that mutates your project.
Interop
Other tools should ask rather than hardcode:
ios-agent where --json
One process owns the layout; everything else queries it. A rename then stays a
change in one package instead of a coordinated release across several.
6. Cross-platform
Per-project vs. user-level caches are different things.
Anything shared
across projects — downloaded templates, SDK metadata, plugin code — belongs in
the user-level cache, or every project duplicates it and every fresh clone
re-downloads it.
.ios-agent/cache/
is only for data derived from
this
project.
Platform
User-level cache
macOS
~/Library/Caches/ios-agent
Windows
%LOCALAPPDATA%\ios-agent\Cache
Linux/other
$XDG_CACHE_HOME/ios-agent, else ~/.cache/ios-agent
~/.ios-agent
is not on that list deliberately: a dotfile in
$HOME
is the
convention every platform has since moved away from, and on macOS and Windows it
is excluded from the OS's own cache-eviction handling.
Windows specifics:
A leading dot does not hide a directory in Explorer.
doctor
says so and
suggests
attrib +h
, rather than pretending the name is enough.
CON
,
PRN
,
AUX
,
NUL
,
COM1
–
COM9
,
LPT1
–
LPT9
are reserved
regardless of extension.
validateProjectName
rejects them on every platform,
so a project created on macOS still checks out on Windows.
Tracked paths in
config.json
are stored POSIX-style. That file crosses
machines by design, and
App\MyApp
is unreadable on the Mac that builds it.
macOS specifics:
the default filesystem is case-insensitive but
case-
preserving
, so
App/
and
app/
collide on a Mac and not on Linux CI.
Never generate two paths differing only in case.
Anti-Patterns
# WRONG — internals at the project root.
MyApp/
├── cache/ # depended on by a script within a release, now frozen
├── logs/ # in every pull request
├── metadata/ # regenerated on build, diffed by humans forever
├── config/
├── generated/
└── App/ # the one directory the user wanted, 1 of 6
# RIGHT — one hidden directory.
MyApp/
├── App/
└── .ios-agent/
// WRONG — the internal directory name written out at a call site.
const cache = path.join(root, ".ios-agent", "cache");
// A rename half-lands: the CLI writes to the new directory while the MCP
// server still reads the old one, and the symptom is an empty result with no
// error anywhere.
// RIGHT — derive from the layout.
const { cache } = layoutFor(root);
// WRONG — clean deletes a hand-maintained list.
const REMOVE = ["cache", "logs", "tmp"];
// It drifts from the gitignore the first time someone adds a directory to one
// and not the other. Then either a cache gets committed, or clean deletes a
// tracked template override.
// RIGHT — derive both from one declaration.
for (const entry of disposableEntries()) { … }
// WRONG — the tool falls back to cwd and says nothing.
const root = process.cwd();
// "0 Swift files" now means either an empty project or a wrong directory, and
// nothing in the output distinguishes them.
// RIGHT — resolve, and report how.
const { root, source } = resolveRootFrom(argv, env); // "flag" | "marker" | "cwd"
// WRONG — a per-project cache for data that is not project-specific.
const templates = path.join(root, ".ios-agent", "cache", "templates");
// Ten projects, ten copies, ten downloads.
// RIGHT — user-level, per platform.
const templates = path.join(globalCacheDir(), "templates");
// WRONG — ~/.ios-agent as the user-level location.
const home = path.join(os.homedir(), ".ios-agent");
// Ignored by macOS cache eviction and by Windows roaming rules, and wrong on
// every platform's own convention.
// RIGHT — the documented per-platform directory, overridable.
globalCacheDir(process.env, process.platform);
// WRONG — the project name accepted as given.
fs.mkdirSync(path.join(parent, name));
// "../evil" escapes the parent, "my-app" is not a Swift type name, and "CON"
// produces a directory Windows cannot open.
// RIGHT — validate before anything exists on disk.
validateProjectName(name);
// WRONG — scaffolding into a directory with contents already in it.
fs.mkdirSync(root, { recursive: true });
write(readme);
// Silently overwrites a README someone spent an afternoon on.
// RIGHT — refuse, and make --force explicit and non-destructive.
if (existing.length > 0 && !force) throw new ScaffoldError(…);
// WRONG — a single source directory, to be widened later.
{ "sourceDir": "App/MyApp" }
// Multiple apps were always coming, and the widening is a breaking change to
// every consumer of a tracked file.
// RIGHT — a list from day one, even with one entry.
{ "apps": [{ "name": "MyApp", "path": "App/MyApp", "platforms": ["iOS"] }] }
# WRONG — writing a fake .xcodeproj or claiming source files are a built app.
# RIGHT — emit Swift sources and an optional XcodeGen specification.
# Let Xcode or XcodeGen create the real project, then verify its build.
Checklist
[ ] Exactly one tool-owned directory, and it is hidden
[ ] Nothing the tool writes appears at the project root — asserted by a test
[ ] Every internal path derives from one declaration, not a string literal
[ ] The gitignore is generated from that declaration, not hand-maintained
[ ] What
clean
deletes is the complement of what is tracked, provably
[ ] Deleting the internal directory loses nothing a human authored
[ ] The internal directory is created lazily, not as an empty promise
[ ] Root discovery walks up from cwd, like git
[ ] Every path-reporting output states how the root was resolved
[ ] A machine-readable
where --json
exists, so nothing else hardcodes the name
[ ] Read-only consumers stay read-only — scaffolding lives in its own package
[ ] Cross-project caches are user-level, per each platform's own convention
[ ] Windows reserved names rejected on every platform
[ ] Tracked config stores POSIX-style paths
[ ] Project names validated before anything is written
[ ] A non-empty target directory is refused unless explicitly forced
[ ] Config carries a layout version, and a newer one is refused rather than rewritten
[ ] Lists are lists from day one where more than one is coming
[ ]
doctor
fails when the layout is broken — proven by mutation tests
[ ]
--fix
repairs only what has a derivable correct value, and never invents structure
[ ] Help text and shell completions are generated from the command table, not maintained
[ ] Exit codes distinguish usage errors from an unhealthy project
[ ]
--json
is available on every command a script would call
---
# Visual Iteration Loop
https://nagarjuna2997.github.io/ios-agent-skill/guides/tooling-visual-iteration-loop.html
← Back to all frameworks & guides
All articles →Tooling · Reference guideVisual Iteration LoopRepository guidance for Visual Iteration Loop. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
Context
Pattern
Stop Conditions
Visual Review Checklist
Artifact Naming
Anti-Patterns
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Define a visual acceptance check
↓2
Capture the current screen
↓3
Compare against the design
↓4
Fix and recapture within budget
02 / ArchitectureResponsibility boundariesBoundary 1
Design targetBoundary 2
Runtime screenshotBoundary 3
Review and repairConnected responsibilities, not a required class hierarchy or an execution trace.
Context
Use this when the user asks for a screen to look premium, polished, more native, more accessible, more animated, or closer to a reference. Static Swift review can find many issues, but visual quality needs a loop that sees the app running.
The workflow is:
Design -> Build -> See -> Improve
Pattern
Design
: route to
ui-ux-designer
,
motion-designer
,
3d-experience-designer
, or
accessibility-reviewer
depending on the requested surface.
Build
: compile with Xcode and run the narrowest test target available.
See
: launch the app in the simulator, capture screenshots or video, and inspect the accessibility tree.
Review
: compare the artifact against the request, platform conventions, Dynamic Type, contrast, spacing, reduced motion, and tap targets.
Improve
: patch the SwiftUI/UIKit/RealityKit/Metal code and repeat until the stop condition is met.
Stop Conditions
Declare the stop condition before the first edit. Good examples:
GOAL: Checkout screen matches the provided reference while keeping native iOS controls.
CHECK: Build passes, home-to-checkout flow launches, screenshots captured at compact and large text sizes.
MAX: 3 visual passes.
ON-STALL: Return the best screenshot and the remaining issues.
Visual Review Checklist
Area
Questions
Hierarchy
Is the primary action obvious within two seconds? Does visual weight match importance?
Spacing
Are margins, gutters, and component gaps consistent with tokens?
Typography
Does the screen use Dynamic Type and avoid fixed font sizes?
Color
Does contrast pass, and do semantic roles survive dark mode?
Motion
Does animation clarify state, avoid fighting layout, and honor Reduce Motion?
Haptics
Are haptics tied to meaningful state transitions, not decoration?
Accessibility
Are labels, traits, focus order, tap targets, and content size tested?
Runtime
Does the captured screenshot/video prove the actual app state?
Artifact Naming
Use stable paths so iterations can be compared:
artifacts/visual/home-pass-01-compact.png
artifacts/visual/home-pass-01-accessibility-xxl.png
artifacts/visual/home-pass-02-compact.png
artifacts/visual/home-pass-02.mov
When visual diffing is added, compare the same route, device, appearance, locale, and content-size category. A screenshot from a different simulator configuration is not a clean comparison.
Anti-Patterns
// WRONG: "I made it premium" after editing SwiftUI.
Why: visual quality was not observed.
// RIGHT: build, launch, capture, review, then report with artifacts.
// WRONG: animate every entrance and every state change.
Why: excessive motion makes the app feel slower and can violate Reduce Motion.
// RIGHT: animate hierarchy changes, confirmations, and spatial continuity.
// WRONG: judge accessibility from a screenshot only.
Why: labels, traits, focus order, and hit targets are semantic/runtime facts.
// RIGHT: pair screenshot review with accessibility-tree inspection and Dynamic Type captures.
---
# Xcode 27 Coding Agents
https://nagarjuna2997.github.io/ios-agent-skill/guides/tooling-xcode-27-agents.html
← Back to all frameworks & guides
All articles →Developer Tools · Reference guideXcode 27 Coding AgentsRepository guidance for Xcode Agents. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
1. What they are
2. Xcode agent vs. Claude Code
3. The same verification contract applies
4. Agent-assisted localization
5. Agent-assisted testing
6. Instruments
7. Keeping agents on this skill's rules
Anti-Patterns
Checklist
Connect this server inside Xcode 27
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Check installed Xcode support
↓2
Connect the chosen agent
↓3
Scope the requested edit
↓4
Verify with local build evidence
02 / ArchitectureResponsibility boundariesBoundary 1
Xcode workspaceBoundary 2
Agent connectionBoundary 3
Build and review toolsConnected responsibilities, not a required class hierarchy or an execution trace.
Load this when:
working inside Xcode's agent features, deciding what to
delegate to an in-Xcode agent versus a CLI agent, or setting up agent-assisted
localization or testing.
Xcode 27 has coding agents built in, powered by a model of your choice. This
document covers what they are good at, what they are not, and how the discipline
in
docs/orchestration/
applies inside Xcode.
Verification status (2026-09-16):
Xcode 27 is released. The local verification host has Xcode 26.6, so in-editor agent connection, menu paths and delegated reviewer behavior remain untested. CLI MCP tests do not establish Xcode integration.
1. What they are
Agents in Xcode work across the development cycle — prototyping, filling in
implementation, and polishing — and work the same whether you are solo or on a
team. Xcode supplies the agent with
project context
: your targets, your
schemes, your code style, and your string catalogs.
That project context is the real difference from a general-purpose agent in a
terminal. An in-Xcode agent knows the build graph. An external agent has to
rediscover it.
Strength
Because
Localization and string catalogs
It sees the catalogs and Apple's language-specific style guidance
Scaffolding UI and boilerplate
It knows the project's targets and conventions
Test authoring
It can run the tests it writes
Diagnosing a build failure
It has the actual compiler output
Weakness
Because
Cross-repo work
Scoped to the Xcode project
Long-running orchestration
No worktrees, no batch fan-out
Non-Swift toolchain work
Not what it is built for
2. Xcode agent vs. Claude Code
These are complements, not competitors. Route by the shape of the work.
Work
Use
"Add German and update the string catalog"
Xcode agent — it owns catalogs
"Why does this fail on iPad only?"
Xcode agent — Device Hub is right there
"Write tests for this view model"
Xcode agent — can run them immediately
"Apply this rule across 30 modules"
Claude Code — /batch, worktrees, PRs
"Restructure our architecture layers"
Claude Code — plan/review subagents
"Audit the repo against our skill rules"
Claude Code — hooks and CI
Rule of thumb:
inside one project and one build graph → Xcode. Across files,
repos, or PRs → Claude Code.
3. The same verification contract applies
An agent in Xcode is still an agent. Everything in
docs/orchestration/verification.md
holds:
Never accept "done" without evidence.
In Xcode the evidence is right
there — the build result and the test navigator. Look at it.
The author does not grade the work.
If the agent wrote the code
and
the
test, both encode the same misunderstanding. Read the test yourself and ask
whether it would fail against the old behavior.
A green build is not a passing feature.
It compiles. That is one claim.
Agent says What you check
"Added tests, they pass." → Run them. Read them. Would they fail before?
"Fixed the layout issue." → Run it on the device size that broke.
"Localized to 8 languages." → Check pluralization and RTL, not just presence.
"Build succeeds." → True and insufficient.
4. Agent-assisted localization
The strongest first use, because it is high-volume, low-ambiguity, and verifiable.
Agents can add languages, update string catalogs, and translate strings, using
Apple-provided language-specific style guidance and your app's context.
What still needs you:
Plural variants.
Agents handle language-specific plural forms, but the
rule
per string is a product decision. Check the catalog's variants.
Context strings.
A key named
title
translates badly without a comment.
Add comments before running the agent, not after.
RTL layout.
Translation is not layout. Verify Arabic and Hebrew visually.
Truncation.
German and Finnish run long. Check at accessibility text sizes.
// Give the agent something to work with — the comment IS the context.
Text("cart.checkout.button", comment: "Button that starts checkout. Keep under 15 characters.")
See
docs/design/interaction-standards.md
§6 for the localization standards the
output must meet.
5. Agent-assisted testing
Agents write tests and can run them. Two failure modes to watch:
Tests that assert the implementation, not the behavior.
A test that mirrors
the code line for line passes forever and catches nothing.
// WEAK — restates the implementation.
#expect(viewModel.items.count == viewModel.repository.items.count)
// STRONG — asserts the behavior the user depends on.
#expect(viewModel.errorMessage != nil) // a failed load must surface
Tests written to pass rather than to catch.
Ask of any generated test:
would
this have failed before the fix?
If not, delete it.
Use
docs/testing/mocking-strategy.md
for what to substitute, and
checklists/testing.md
for coverage shape.
6. Instruments
Xcode 27's profiling improvements matter for the concurrency rules this skill
enforces:
Instrument
Shows
Swift Concurrency
Async task scheduling, actor contention, thread usage
Time Profiler
CPU bottlenecks, with a "top functions" view
System Trace
System-level view of threads and hardware
Run comparisons
The measured impact of a change
The Swift Concurrency instrument is the tool for the failure this skill warns
about most: main-actor contention from CPU work that should have been
nonisolated
or on an actor. Do not guess at isolation performance — measure it.
See
docs/swift/swift-concurrency.md
.
7. Keeping agents on this skill's rules
The rules in
SKILL.md
apply to code an Xcode agent writes too, but Xcode does
not read
CLAUDE.md
. Two ways to keep them enforced:
A pre-commit hook or CI
, so the rules bind regardless of which agent wrote
the code.
templates/hooks/forbid-antipatterns.sh
runs standalone:
bash
# .git/hooks/pre-commit
for file in $(git diff --cached --name-only --diff-filter=ACM | grep '\.swift$'); do
echo "{\"tool_input\":{\"file_path\":\"$PWD/$file\"}}" \
| .claude/hooks/forbid-antipatterns.sh || exit 1
done
Review with swift-reviewer
before the PR, which checks the same rules
with model judgment where a grep cannot reach.
Deterministic enforcement is what makes multi-agent work safe: it does not matter
which agent wrote the line if the rule is checked mechanically. See
docs/orchestration/hooks.md
.
Anti-Patterns
# 1. Accepting a diff you have not read because the build went green.
Compiling is one claim, not a review.
# 2. Letting the agent write both the code and its only test.
Same misunderstanding, encoded twice.
# 3. Shipping generated translations without checking plurals, RTL, or truncation.
# 4. Using an in-Xcode agent for a 30-module migration.
No worktrees, no isolation. Use /batch.
# 5. Using Claude Code for string-catalog work.
Xcode owns the catalogs and the style guidance.
# 6. Assuming Xcode agents follow SKILL.md.
They do not read it. Enforce with hooks or CI.
# 7. Guessing at concurrency performance.
The Swift Concurrency instrument measures actor contention directly.
Checklist
[ ] The work is scoped to one project — otherwise route it to
/batch
.
[ ] Generated diffs are read, not just built.
[ ] Generated tests would have failed before the change.
[ ] Localization output checked for plurals, RTL, and truncation.
[ ] String keys have comments before the agent runs.
[ ] This skill's rules are enforced by a hook or CI, not by hoping.
[ ] Isolation performance is measured, not assumed.
Connect this server inside Xcode 27
Apple documents separate
in-Xcode agent environments
. A terminal agent’s working MCP setup does not automatically configure the IDE agent.
Select the agent in Xcode’s Intelligence settings. Its configuration lives under
~/Library/Developer/Xcode/CodingAssistant
:
ClaudeAgentConfig
for Claude,
codex
for Codex, and
gemini
for Gemini.
Merge one
ios-agent
stdio entry into that agent’s configuration; retain existing settings. Use an absolute executable path if Xcode cannot find Node on its GUI PATH. The server command is
npx
with args
["-y", "ios-agent-mcp@latest", "--project", "/absolute/path/to/App"]
. An installed
ios-agent-mcp
executable avoids a package fetch during the session.
Reload the agent, list its available tools, then call
analyze_swift_project
and
review_app_intents
on that absolute project path. Verify returned filenames and file counts.
Ask the IDE agent to read the project’s
AGENTS.md
/
CLAUDE.md
, make a small change, build and run a relevant test. Record the selected agent, Xcode build, tool results and test output. Check that a reviewer can inspect the actual diff; do not assume Claude-specific subagent definitions automatically load in every IDE agent.
Apple also supports agent plug-ins through Intelligence settings → Plug-ins. This repository does not claim its existing client ZIPs have passed Xcode’s plug-in import. No Xcode-only artifact is published without that check.
Current result:
local stdio MCP tests pass. The above
Xcode 27 session remains untested
because the verification host has Xcode 26.6. No global agent configuration was changed during this documentation pass. Apple’s own external-tools server (
mcpbridge
/
mcp-server
) is a separate integration; do not confuse it with this project’s
ios-agent
server.
---
# Xcode Memory Debugging
https://nagarjuna2997.github.io/ios-agent-skill/guides/tooling-xcode-memory-debugging.html
← Back to all frameworks & guides
All articles →Tooling · Reference guideXcode Memory DebuggingRepository guidance for Xcode Memory Debugging. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
Context
First-pass triage
Memory Graph workflow
Instruments Allocations workflow
Sanitizers and runtime diagnostics
Crash logs, jetsam, and MetricKit
Reducing memory
Metal Memory viewer
Report template
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Reproduce memory growth
↓2
Inspect memory graph or trace
↓3
Identify ownership issue
↓4
Re-measure after a focused fix
02 / ArchitectureResponsibility boundariesBoundary 1
Object ownershipBoundary 2
Runtime diagnosticBoundary 3
Memory evidenceConnected responsibilities, not a required class hierarchy or an execution trace.
Context
Load this when an iOS, iPadOS, macOS, watchOS, tvOS, or visionOS app has rising memory, a suspected leak, a retain cycle,
deinit
not firing,
EXC_BAD_ACCESS
, jetsam termination, slow scrolling caused by allocation churn, image/cache pressure, or Metal resource growth.
Apple's Xcode and Instruments tools provide the evidence. This file tells agents what to ask for, what to inspect, and how to report the result.
First-pass triage
Reproduce the issue in a clean run.
Record device or simulator, OS version, scheme, build configuration, and the exact interaction.
Watch Xcode's Debug navigator memory report for current and peak memory.
Capture whether Xcode shows normal, warning, or termination-risk memory pressure.
Separate Swift object leaks from large non-object allocations such as images, decoded video frames, Core Animation backing stores, Metal textures, and malloc buffers.
If the issue only happens on device, do not close it based on simulator evidence.
Memory Graph workflow
Use Xcode Debug Memory Graph when objects stay alive after their owner should be gone.
Checklist:
Navigate to the feature and back out.
Press the Debug Memory Graph button.
Search for the model, view controller, coordinator, task owner, delegate, or cache type.
Inspect incoming strong references.
Look for closure captures, delegates declared strong, timers, notification observers, Combine subscriptions, task handles, and global singletons.
Enable Malloc Stack in the scheme Diagnostics tab when allocation stack traces are needed.
Export the memory graph when sharing evidence.
Report:
INSPECTED: Memory Graph shows FeedViewModel retained by SearchCoordinator.onComplete closure.
Fix: capture self weakly or clear onComplete in stop().
Still unverified: no device rerun after patch.
Instruments Allocations workflow
Use Instruments Allocations when memory grows but the owner is unclear, allocations churn during scrolling, or the issue involves malloc, images, buffers, or anonymous virtual memory.
Checklist:
Profile with the Allocations instrument.
Mark a generation before entering the feature.
Exercise the feature.
Mark another generation after leaving.
Inspect persistent allocations by type, size, count, and responsible stack.
Compare before and after the fix with the same interaction.
Prefer this over guessing from code when the problem is "memory keeps going up."
Sanitizers and runtime diagnostics
Use scheme Diagnostics or test plans to enable the right tool:
Address Sanitizer: memory access errors such as use-after-free and buffer overflow.
Thread Sanitizer: data races in supported simulator runs.
Main Thread Checker: system APIs used off the main thread.
Undefined Behavior Sanitizer: C-family undefined behavior; do not describe it as a Swift-only checker.
Guard Malloc: targeted investigation of memory access crashes when the overhead is acceptable.
Sanitizers change timing and resource use. A sanitizer finding is strong evidence. A clean sanitizer run is not proof that no bug exists.
Crash logs, jetsam, and MetricKit
Use crash reports and device logs when the issue comes from TestFlight, App Store, or a build without debugger entitlements.
Checklist:
Symbolicate crash reports before making code claims.
Distinguish
EXC_BAD_ACCESS
, language exception aborts, watchdog terminations, and jetsam.
Treat jetsam as memory pressure evidence, not as a normal crash.
Use Xcode Organizer and MetricKit for production peak-memory and memory-at-suspension trends.
Attach the relevant OS version, app version, device, and stack or report summary.
Reducing memory
Start with the largest measured source, not the easiest code to edit.
Common fixes:
Decode images to display size instead of full camera size.
Downsample thumbnails.
Bound in-memory caches by count and cost.
Release feature-scoped resources when leaving the flow.
Stream large files instead of reading them fully.
Cancel long-running async work.
Avoid retaining entire response models when only a small projection is needed.
Reuse buffers carefully in image, audio, and Metal paths.
Label Metal resources and release temporary render targets promptly.
Metal Memory viewer
Use Xcode GPU capture and the Metal Memory viewer when GPU resources grow, AR sessions stutter, texture memory spikes, or render passes allocate unexpected buffers.
Checklist:
Capture a GPU frame near the memory spike.
Inspect textures, buffers, heaps, and render targets by allocated size.
Verify resource labels are meaningful.
Look for per-frame resource creation.
Export GPU trace or CSV when sharing evidence.
Cross-reference local Metal code in
docs/frameworks/metal.md
.
Report template
Status: INSPECTED / VERIFIED / UNVERIFIED
Environment: device/simulator, OS, Xcode, build configuration
Symptom: what grows, crashes, or remains retained
Evidence: Memory Graph / Allocations / sanitizer / crash log / jetsam / MetricKit / Metal Memory viewer
Likely cause: ownership path, allocation source, or resource lifetime
Change made: exact code or configuration change
Remaining risk: what was not rerun or not available
---
# UIKit Essentials
https://nagarjuna2997.github.io/ios-agent-skill/guides/uikit-uikit-essentials.html
← Back to all frameworks & guides
All articles →Core UI and Apps · Reference guideUIKit EssentialsRepository guidance for UIKit. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
UIViewController Lifecycle
UIView and Auto Layout
Programmatic Constraints with Anchors
UIStackView
UITableView with Diffable Data Source
UICollectionView Compositional Layout
UINavigationController and UITabBarController
Keyboard Handling and UIResponder Chain
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Choose view controller boundary
↓2
Compose constrained views
↓3
Connect model updates
↓4
Test lifecycle transitions
02 / ArchitectureResponsibility boundariesBoundary 1
View controllerBoundary 2
View hierarchyBoundary 3
Model servicesConnected responsibilities, not a required class hierarchy or an execution trace.
UIViewController Lifecycle
class MyViewController: UIViewController {
// Called once when the view is loaded into memory
override func viewDidLoad() {
super.viewDidLoad()
setupUI()
fetchInitialData()
}
// Called every time the view is about to appear (animated or not)
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
navigationController?.setNavigationBarHidden(false, animated: animated)
}
// Called after the view has fully appeared on screen
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
startAnimations()
}
// Called when the view is about to be removed from the hierarchy
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
stopAnimations()
}
// Called after the view has been removed
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
cancelPendingRequests()
}
// Called when the view controller's view is released from memory
deinit {
NotificationCenter.default.removeObserver(self)
}
}
UIView and Auto Layout
Programmatic Constraints with Anchors
class ProfileView: UIViewController {
private let avatarImageView: UIImageView = {
let iv = UIImageView()
iv.translatesAutoresizingMaskIntoConstraints = false
iv.contentMode = .scaleAspectFill
iv.clipsToBounds = true
iv.layer.cornerRadius = 40
return iv
}()
private let nameLabel: UILabel = {
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.font = .preferredFont(forTextStyle: .headline)
label.adjustsFontForContentSizeCategory = true
return label
}()
override func viewDidLoad() {
super.viewDidLoad()
view.addSubview(avatarImageView)
view.addSubview(nameLabel)
NSLayoutConstraint.activate([
avatarImageView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 20),
avatarImageView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
avatarImageView.widthAnchor.constraint(equalToConstant: 80),
avatarImageView.heightAnchor.constraint(equalToConstant: 80),
nameLabel.topAnchor.constraint(equalTo: avatarImageView.bottomAnchor, constant: 12),
nameLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor),
])
}
}
UIStackView
func createFormStack() -> UIStackView {
let emailField = UITextField()
emailField.placeholder = "Email"
emailField.borderStyle = .roundedRect
let passwordField = UITextField()
passwordField.placeholder = "Password"
passwordField.borderStyle = .roundedRect
passwordField.isSecureTextEntry = true
let loginButton = UIButton(type: .system)
loginButton.setTitle("Log In", for: .normal)
loginButton.configuration = .filled()
let stack = UIStackView(arrangedSubviews: [emailField, passwordField, loginButton])
stack.axis = .vertical
stack.spacing = 16
stack.alignment = .fill
stack.distribution = .fill
stack.translatesAutoresizingMaskIntoConstraints = false
return stack
}
UITableView with Diffable Data Source
class ContactsViewController: UIViewController {
enum Section { case main }
struct Contact: Hashable {
let id: UUID
let name: String
let email: String
}
private var tableView: UITableView!
private var dataSource: UITableViewDiffableDataSource<Section, Contact>!
override func viewDidLoad() {
super.viewDidLoad()
configureTableView()
configureDataSource()
applyInitialSnapshot()
}
private func configureTableView() {
tableView = UITableView(frame: view.bounds, style: .insetGrouped)
tableView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
view.addSubview(tableView)
}
private func configureDataSource() {
dataSource = UITableViewDiffableDataSource<Section, Contact>(
tableView: tableView
) { tableView, indexPath, contact in
let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
var content = cell.defaultContentConfiguration()
content.text = contact.name
content.secondaryText = contact.email
cell.contentConfiguration = content
return cell
}
}
private func applyInitialSnapshot() {
var snapshot = NSDiffableDataSourceSnapshot<Section, Contact>()
snapshot.appendSections([.main])
snapshot.appendItems([
Contact(id: UUID(), name: "Alice", email: "alice@example.com"),
Contact(id: UUID(), name: "Bob", email: "bob@example.com"),
])
dataSource.apply(snapshot, animatingDifferences: true)
}
}
UICollectionView Compositional Layout
class AppStoreViewController: UIViewController {
enum Section: Int, CaseIterable {
case featured, categories, topApps
}
private func createLayout() -> UICollectionViewCompositionalLayout {
return UICollectionViewCompositionalLayout { sectionIndex, environment in
guard let section = Section(rawValue: sectionIndex) else { return nil }
switch section {
case .featured:
return self.createFeaturedSection()
case .categories:
return self.createCategoriesSection()
case .topApps:
return self.createTopAppsSection()
}
}
}
// Full-width horizontally scrolling section
private func createFeaturedSection() -> NSCollectionLayoutSection {
let itemSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1.0),
heightDimension: .fractionalHeight(1.0)
)
let item = NSCollectionLayoutItem(layoutSize: itemSize)
item.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 8, bottom: 0, trailing: 8)
let groupSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(0.9),
heightDimension: .absolute(250)
)
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
let section = NSCollectionLayoutSection(group: group)
section.orthogonalScrollingBehavior = .groupPagingCentered
section.contentInsets = NSDirectionalEdgeInsets(top: 16, leading: 0, bottom: 16, trailing: 0)
return section
}
// Grid of categories
private func createCategoriesSection() -> NSCollectionLayoutSection {
let itemSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(0.5),
heightDimension: .absolute(60)
)
let item = NSCollectionLayoutItem(layoutSize: itemSize)
item.contentInsets = NSDirectionalEdgeInsets(top: 4, leading: 4, bottom: 4, trailing: 4)
let groupSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1.0),
heightDimension: .absolute(60)
)
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
let section = NSCollectionLayoutSection(group: group)
section.contentInsets = NSDirectionalEdgeInsets(top: 8, leading: 12, bottom: 8, trailing: 12)
let header = NSCollectionLayoutBoundarySupplementaryItem(
layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(44)),
elementKind: UICollectionView.elementKindSectionHeader,
alignment: .top
)
section.boundarySupplementaryItems = [header]
return section
}
// Vertical list section
private func createTopAppsSection() -> NSCollectionLayoutSection {
let itemSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1.0),
heightDimension: .absolute(70)
)
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1.0),
heightDimension: .absolute(70)
)
let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, subitems: [item])
let section = NSCollectionLayoutSection(group: group)
section.contentInsets = NSDirectionalEdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)
return section
}
}
UINavigationController and UITabBarController
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options: UIScene.ConnectionOptions) {
guard let windowScene = scene as? UIWindowScene else { return }
window = UIWindow(windowScene: windowScene)
// Tab bar with navigation controllers
let homeNav = UINavigationController(rootViewController: HomeViewController())
homeNav.tabBarItem = UITabBarItem(title: "Home", image: UIImage(systemName: "house"), tag: 0)
let searchNav = UINavigationController(rootViewController: SearchViewController())
searchNav.tabBarItem = UITabBarItem(title: "Search", image: UIImage(systemName: "magnifyingglass"), tag: 1)
let profileNav = UINavigationController(rootViewController: ProfileViewController())
profileNav.tabBarItem = UITabBarItem(title: "Profile", image: UIImage(systemName: "person"), tag: 2)
let tabBar = UITabBarController()
tabBar.viewControllers = [homeNav, searchNav, profileNav]
tabBar.tabBar.tintColor = .systemBlue
window?.rootViewController = tabBar
window?.makeKeyAndVisible()
}
}
// Programmatic navigation
class HomeViewController: UIViewController {
func showDetail(for item: Item) {
let detailVC = DetailViewController(item: item)
navigationController?.pushViewController(detailVC, animated: true)
}
func presentModal() {
let modalVC = ModalViewController()
modalVC.modalPresentationStyle = .pageSheet
if let sheet = modalVC.sheetPresentationController {
sheet.detents = [.medium(), .large()]
sheet.prefersGrabberVisible = true
}
present(modalVC, animated: true)
}
}
Keyboard Handling and UIResponder Chain
class FormViewController: UIViewController {
@IBOutlet weak var scrollView: UIScrollView!
override func viewDidLoad() {
super.viewDidLoad()
// Register for keyboard notifications
NotificationCenter.default.addObserver(
self, selector: #selector(keyboardWillShow),
name: UIResponder.keyboardWillShowNotification, object: nil
)
NotificationCenter.default.addObserver(
self, selector: #selector(keyboardWillHide),
name: UIResponder.keyboardWillHideNotification, object: nil
)
// Dismiss keyboard on tap
let tap = UITapGestureRecognizer(target: self, action: #selector(dismissKeyboard))
tap.cancelsTouchesInView = false
view.addGestureRecognizer(tap)
}
@objc private func keyboardWillShow(_ notification: Notification) {
guard let frame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect,
let duration = notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double
else { return }
let insets = UIEdgeInsets(top: 0, left: 0, bottom: frame.height, right: 0)
UIView.animate(withDuration: duration) {
self.scrollView.contentInset = insets
self.scrollView.scrollIndicatorInsets = insets
}
}
@objc private func keyboardWillHide(_ notification: Notification) {
guard let duration = notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double
else { return }
UIView.animate(withDuration: duration) {
self.scrollView.contentInset = .zero
self.scrollView.scrollIndicatorInsets = .zero
}
}
@objc private func dismissKeyboard() {
view.endEditing(true)
}
}
// UITextField delegate for field navigation
extension FormViewController: UITextFieldDelegate {
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
if let nextField = view.viewWithTag(textField.tag + 1) as? UITextField {
nextField.becomeFirstResponder()
} else {
textField.resignFirstResponder()
}
return true
}
}
---
# Native vs. Web Animation and 3D
https://nagarjuna2997.github.io/ios-agent-skill/guides/web-native-vs-web-animation.html
← Back to all frameworks & guides
All articles →Design and Motion · Reference guideNative vs. Web Animation and 3DRepository guidance for Native vs Web Animation, WebKit. Examples, decisions, and verification limits from the maintained source guide.Read or improve the source
·
All guidesOn this pageVisual overview: workflow and architecture
Context
Decision Tree
Translation Table
WKWebView Is Appropriate When
Native Is Preferred When
Anti-Patterns
Visual overview
Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.
01 / WorkflowFrom intent to a checked result1
Identify rendering environment
↓2
Compare native and web needs
↓3
Choose an explicit boundary
↓4
Test accessibility and performance
02 / ArchitectureResponsibility boundariesBoundary 1
Native UIBoundary 2
Web content boundaryBoundary 3
Animation runtimeConnected responsibilities, not a required class hierarchy or an execution trace.
Context
Use this when the user asks for Anime.js, GSAP, Framer Motion, Three.js, PixiJS, p5.js, Matter.js, WebGL, WebGPU, CSS animation, or a JavaScript-style visual effect inside an Apple-platform app.
The default answer is not to add web dependencies to a native iOS app. First translate the intent into native Apple frameworks. Use
WKWebView
only when the actual web runtime is the requirement.
Decision Tree
User asks for a web animation or 3D library
|
v
Is the app already a web app or must it render existing web content?
| yes
v
Use WKWebView with a narrow bridge and content-security rules.
|
no
v
Translate the visual intent to native SwiftUI, UIKit, RealityKit, SpriteKit, or Metal.
Translation Table
Web vocabulary
Native Apple route
Anime.js timelines, stagger, keyframes
SwiftUI KeyframeAnimator, PhaseAnimator, explicit phase state
GSAP timelines and scroll triggers
SwiftUI animation state, scroll phase/offset, Core Animation when UIKit layers are involved
Framer Motion transitions
SwiftUI transitions, matched geometry, navigation transitions
Three.js product scenes
RealityKit, Model3D, RealityView, Metal only for custom rendering
PixiJS sprites and particles
SpriteKit, SwiftUI Canvas, or Metal particles
Matter.js physics
SpriteKit physics, RealityKit physics
p5.js generative visuals
SwiftUI Canvas, Core Graphics, Metal for GPU-heavy work
CSS transforms and filters
SwiftUI transforms, materials, visualEffect, layer effects
WebGL/WebGPU shaders
Metal shaders and compute
WKWebView Is Appropriate When
the product already ships a web surface and the app is a native shell
the user has a real existing Three.js/GSAP asset that must run unchanged
the same visual must be shared with a web product
the interaction depends on browser APIs or DOM layout
a remote web team owns the experience
If you choose
WKWebView
, isolate it:
disable arbitrary navigation unless explicitly needed
use typed message handlers instead of stringly bridge calls
validate every message from JavaScript
avoid injecting secrets into the page context
document offline behavior and loading/error states
Native Is Preferred When
the effect is decorative or UI-level
the screen must feel like a platform-native iOS view
accessibility, Dynamic Type, Reduce Motion, or VoiceOver are central
the effect needs camera, AR, haptics, widgets, App Intents, or SwiftData
App Store review risk is higher with remote executable behavior
Anti-Patterns
// WRONG: install Three.js because the user said "3D card".
Why: a simple tilt belongs in SwiftUI transforms.
// RIGHT: use SwiftUI for perspective-only effects; use RealityKit for real 3D assets.
// WRONG: put a checkout, login, or settings screen in WKWebView for animation.
Why: native controls, accessibility, autofill, and platform trust matter more than animation syntax.
// RIGHT: build the screen natively and translate the animation vocabulary.
// WRONG: bridge JavaScript by accepting arbitrary command strings.
Why: it creates injection and authorization bugs.
// RIGHT: expose a small typed message schema and reject unknown actions.
---
# iOS Agent Skill — Swift Reviews & Xcode Simulator MCP
https://nagarjuna2997.github.io/ios-agent-skill/
The developer playground
Good ideas deserve
working apps.
An open-source iOS development toolkit: Swift code reviews, local Apple references, app scaffolding and Xcode Simulator tools for your coding agent.
Choose your AI
Explore the source
Claude · ChatGPT / Codex · Gemini CLI · Muse
Drag an object. On touch, drag sideways. With a keyboard, use arrow keys; Escape resets.
Illustrative artwork, not a recorded app run.
Reset objects
Freeze objects
Meet iOS Agent Skill
Swift guidance.
Tools to check the work.
iOS Agent Skill is an MIT-licensed repository and the
ios-agent-mcp npm server
for developers building or reviewing Apple apps with AI coding assistants.
Use it when an agent needs focused Apple references, file-located Swift review findings, an editable app starter, or a way to build, test and capture simulator screens. Your coding agent makes the changes; you keep your app’s source and branding.
Choose a client and follow its setup guide
, or
read the practical questions below
.
Make it.
Run it. Check it.
Use trusted sourcesWork with local Swift source and attributed Apple guides.
Build with your agentGive it focused context and the tools to make progress.
See it runningBuild, test and inspect the result in your simulator.
One idea. A little further with every step.
Watch the workflow
come together.
From a brief to a build you can actually check.
Your agent does the work. These tools help it stay grounded.
Skip to installation
A clear brief. Editable source.
Small changes. Actual checks.
Real Reading List simulator capture · iOS 26.5
01 / SHAPE IT
Start with
something worth making.
A reading list. A daily habit. Your next useful idea. Describe the screens, the behavior, and what “working” should mean.
The app starter gives your agent editable source and a brief. Relevant Apple guides and Swift examples help it plan the next move.
See the idea-to-app workflow
02 / BUILD IT
Make a change.
Learn from the result.
Let your coding agent implement one feature, review the relevant Swift, and run the checks. A failure is the next thing to understand.
Keep the diagnostic, explain the smallest fix, and verify again. Bounded retries keep a broken build from becoming an endless loop.
Explore the build-and-fix loop
03 / CHECK IT
Don’t just hear
that it works. See it.
Build and test with Xcode, then inspect the simulator. Compare the result with the acceptance criteria you started with.
This Reading List capture is from a real iOS 26.5 simulator acceptance run using synthetic book data. It demonstrates this screen—not a live AI session or an iOS 27 run.
Run the demo yourself
Recorded simulator evidence, not a live preview.
A little less guesswork
From the first idea
to the last check.
Keep your favorite AI. Give it a focused toolkit for the work of building an Apple app.
01Start an appAn editable Swift starter, implementation brief and optional XcodeGen specification.
02Reuse Apple knowledgeSearch local Swift source and guides in bounded sections, plus a dated directory of Apple technologies and release notes.
03Review Swift codeFile-located findings for concurrency, architecture, SwiftUI, availability, security, performance and App Intents.
04Generate design assetsNamed light/dark/high-contrast colors and an opaque 1024px app icon rendered locally from editable SVG layers.
05Verify on a simulatorBuild, test, install, launch and capture screenshots through Xcode.
Your agent implements the app and verifies the result. Local simulator tools require a Mac with Xcode. The features above are available in ios-agent-mcp 2.7.0.
One package. Your choice.
Meet your agent’s
new toolkit.
Choose your coding client. Connect the server. Start with one small, verifiable task.
Node.js 20+ for the local server.
No separate simulator package to install.
Using ChatGPT web? See connection options
Claude
Codex
Gemini CLI
Muse
01 / CONNECT YOUR CLIENT
claude mcp add ios-agent -- npx -y ios-agent-mcp@latest
Run from your app folder, then reconnect Claude Code.
Claude setup and usage guide
02 / GIVE IT A SMALL FIRST TASK
“Review this app’s Swift concurrency. Show each finding with its file and line. Propose the smallest fix, then help me test it.”
Open source. Open about the limits.
Real tools.
Proof over promises.
Inspect the source, review the rules, and ask your agent for actual build and test results.
A quality or token-saving advantage has not been established. The verification notes tell you what’s tested and what still needs work.
Read the evidence
MIT
Open source. Yours to build on.
Local first
Source and guides, retrieved in focused sections.
npm
See package downloads
Before you install
What can you
use it for?
Concrete workflows, with the requirements and limits up front.
How do I review Swift code with an MCP server?Connect ios-agent-mcp to your local coding client, open your app folder, and ask for a focused concurrency, SwiftUI, architecture or availability review. Findings include file locations so you can inspect a proposed change. These checks are heuristics; validate fixes with the Swift compiler and your tests.Try the review workflow
Can an AI agent build and run my app in iOS Simulator?The bundled tools can build, test, install, launch and capture screenshots using a local Mac with Xcode. Start with a configured Xcode project, an available simulator and a connected client. A screenshot confirms what was displayed, not every behavior.Inspect the Reading List demo and acceptance checks
Does it work with Claude, Codex, Gemini CLI and Muse?There are setup guides for all four client families. Gemini’s extension and local connection are verified; Muse’s tool discovery and Stop hook are verified. Those checks do not establish a complete model-driven app-building session in every client.Compare setup paths
·
Read verification records
Can I use it from ChatGPT in a browser?ChatGPT web needs its own supported connection path. A local stdio MCP command is not a browser installation. The guide separates portable skills from a separately configured HTTPS or private-tunnel MCP connection, subject to your account and workspace policy.Read ChatGPT connection options
Is it free, and what do I need?The repository is MIT-licensed. The local server requires Node.js 20 or later; build, test and simulator tools additionally need macOS and Xcode. Your chosen AI service may have its own costs. XcodeGen is required when generating an Xcode project from the optional starter specification.Check installation requirements
Does it replace Xcode or guarantee a finished app?No. It supplies reusable guidance and tools alongside your coding agent and Xcode. Static reviews can miss issues or produce false positives. Token savings and an advantage over other workflows have not been established. Source references, tests and recorded checks let you evaluate it yourself.See evidence and limitations
For release changes, read the
published releases
and
changelog
. Source-only features are not automatically available in the npm release.
Built in the open
Good work grows
with good feedback.
See the mentions, discussions and people helping this project get better.
Meet the community
Your source. Your app.
What will you
make next?
Let’s build it
Explore or star on GitHub
---
# Install iOS Agent MCP — Claude, Codex, Gemini CLI & Muse
https://nagarjuna2997.github.io/ios-agent-skill/install.html
One package · your choice of client
Install it.
Put it to work.
Choose the AI you use. Local coding clients connect to the same MCP server; ChatGPT has a separate connection path.
Claude
Codex
ChatGPT
Gemini CLI
Muse Code
Local server: Node.js 20+. Simulator tools also require macOS and Xcode. Run setup from your app folder. Review tool permissions before allowing file changes or simulator actions.Claude
In Claude Code, connect the local server:
claude mcp add ios-agent -- npx -y ios-agent-mcp@latest
Copy
Restart or reconnect your Claude session and check that the MCP tools are available.Ask the agent to list the connected tools, search only the relevant local references, and review a small Swift file first. For changes, ask it to build and test, show the command results, and explain unresolved failures.
Claude Desktop:
use the
Desktop JSON setup
instead of the CLI command. Preserve existing settings.Codex
Connect the server to your local Codex coding environment:
codex mcp add ios-agent -- npx -y ios-agent-mcp@latest
Copy
Open your app folder in Codex and reconnect the MCP server if needed.Ask the agent to list the connected tools, search only the relevant local references, and review a small Swift file first. For changes, ask it to build and test, show the command results, and explain unresolved failures.
Use the local coding environment for source edits and Xcode workflows. The ChatGPT web setup below is different.ChatGPT
ChatGPT web cannot run the local
npx
command on your Mac directly.
For guidance, use the skills-only release package through a supported import or plugin-development flow available to your account.For connected tools, configure an HTTPS knowledge MCP endpoint or a supported private MCP tunnel. This project does not operate a public endpoint for you.Follow the
official connection guide (new tab)
. Account and workspace policy may limit access.Ask for a scoped implementation plan and relevant Apple references. Use an appropriately connected local coding environment for builds, simulator execution and evidence.
The hosted knowledge server serves references and plans. It is not a remotely hosted Mac or simulator.
Full ChatGPT setup ↗Gemini CLI
Connect the published server from your project folder:
gemini mcp add ios-agent -- npx -y ios-agent-mcp@latest
Copy
Restart Gemini CLI and confirm the server is connected.Ask the agent to list the connected tools, search only the relevant local references, and review a small Swift file first. For changes, ask it to build and test, show the command results, and explain unresolved failures.
This is for Gemini CLI, not Gemini web chat. The repository extension is an alternative for bundled guidance; check its version pin is published before installing it. Use one setup method to avoid duplicate tools.Muse Code
Install the server outside the agent sandbox:
npm install -g ios-agent-mcp@latest
Copy
Merge this into
~/.config/muse/settings.json
. Keep your other settings and existing servers.
{
"schema_version": 1,
"mcpServers": {
"ios-agent": { "command": "ios-agent-mcp", "args": [] }
}
}
Restart Muse. If the executable is not found, use its absolute path from
command -v ios-agent-mcp
.Ask the agent to list the connected tools, search only the relevant local references, and review a small Swift file first. For changes, ask it to build and test, show the command results, and explain unresolved failures.
Muse tool discovery and a Stop hook have been verified; a complete model-driven app-building session has not.Start small. Verify the result.
Try this prompt after your local MCP connection is working:
Review this app’s Swift concurrency. Search only the relevant local references.
Explain each finding with its file and line, and separate heuristics from confirmed bugs.
Propose the smallest fix. After approval, implement it, run the relevant tests,
and report what passed and what remains unverified.
When a step fails, ask the agent to show the relevant diagnostic, explain its next fix and report the result of verification. Keep this feedback in the development session, not in the app’s interface. Public issue reporting is separate: prepare a local preview, then review and authorize submission. A private chat-based option is being prepared in source; it is not deployed or available in npm 2.7.0.
For a new app, describe screens, persistence and acceptance criteria first. A starter is scaffolding; your agent still implements the features. Prefer one feature and one verification loop at a time.
Do not assume a connected tool means the app is complete. Ask for actual build/test results and simulator evidence where applicable.
Troubleshooting and full client documentation ↗
---
# Apple development guide library
https://nagarjuna2997.github.io/ios-agent-skill/library.html
Learn from the sourceFrameworks. Features.
Practical decisions.Explore 99 tracked technologies and 98 developer guides. Project maintenance and contributor references are listed separately below. These are maintained references, not a claim that every example has been compiled. Each guide retains its own availability and verification notes.Start with a tested walkthrough →
·
Project internalsFind a framework, feature or workflowAuthentication, Security, and PrivacyApp Store Submission ChecklistPrivacy Manifest
DesignProfessional UI/UX SystemDesign
DesignGenerate real asset catalogsDesign
DesigniOS Color System -- Complete Guide for Stunning SwiftUI UIsDesign
DesignDesign Tokens, Adaptive Color, and Liquid GlassDesign
DesigniOS Font Catalog — Ultimate ReferenceDesign
DesignLayer-by-Layer Icons with Icon ComposerDesign
DesignInteraction StandardsDesign
Design and MotionAdopting Liquid GlassLiquid Glass
Design and MotionStunning UI Patterns -- Complete SwiftUI Pattern LibraryHuman Interface Guidelines
DesignThird-Party Animation IntegrationDesign
DesigniOS Typography System -- Complete Guide for Stunning SwiftUI TextDesign
Core UI and AppsActivityKit & Live ActivitiesActivityKit
Core UI and AppsApp ClipsApp Clips
Core UI and AppsApp IntentsApp Intents
AI and Machine LearningApple IntelligenceApple Intelligence
Graphics, 3D, and GamesARKit -- Complete Guide for Augmented Reality on iOS and iPadOSARKit
Authentication, Security, and PrivacyAuthenticationServicesAuthenticationServices
Camera, Media, and AudioAVFoundationAVFoundation
System IntegrationBackgroundTasksBackgroundTasks
Data ManagementCloudKitCloudKit
AI and Machine LearningCore AI -- Custom On-Device AI Models on Apple SiliconCore AI
Data ManagementCore DataCore Data
System IntegrationCoreLocationCore Location
AI and Machine LearningCore Spotlight RAG -- Private App-Local Retrieval for Foundation ModelsCore Spotlight RAG, Core Spotlight
Authentication, Security, and PrivacyCryptoKitCryptoKit
Data ManagementSwiftData and Core Data ConcurrencyData Concurrency
Authentication, Security, and PrivacyDevice Integrity (DeviceCheck & App Attest)Device Integrity
Core UI and AppsExtended Apple FrameworksQuickLook, LinkPresentation, UniformTypeIdentifiers, PDFKit, PencilKit, Core Animation, Core Haptics, MLX, File Provider, SQLite, Multipeer Connectivity, Nearby Interaction, Network Extension, SpriteKit, GameplayKit, Game Controller, RoomPlan, Object Capture, AVKit, Core Media, ReplayKit, MusicKit, ShazamKit, App Store Server API, Wallet Orders, Security Framework, AccessorySetupKit, ExternalAccessory, SensorKit
AI and Machine LearningFoundation ModelsFoundation Models
Data ManagementFoundation FrameworkFoundation
Networking and ConnectivityCoreBluetoothCore Bluetooth
Health, Hardware, and SensorsCoreMotionCore Motion
Health, Hardware, and SensorsCoreNFCCore NFC
Health, Hardware, and SensorsHealthKitHealthKit
System IntegrationHomeKit and MatterHomeKit
Authentication, Security, and PrivacyLocalAuthenticationLocalAuthentication
System IntegrationMapKitMapKit
Graphics, 3D, and GamesMetal -- GPU Rendering, Compute, and Performance-Critical GraphicsMetal
AI and Machine LearningCoreML -- Complete Guide for On-Device Machine LearningCore ML
AI and Machine LearningNaturalLanguage Framework -- Complete Guide for Text Processing and NLPNatural Language
AI and Machine LearningSound AnalysisSound Analysis
AI and Machine LearningSpeech Framework -- Complete Guide for Speech Recognition and TranscriptionSpeech
AI and Machine LearningTranslationTranslation
AI and Machine LearningVision Framework -- Complete Guide for Image Analysis and Computer VisionVision
Networking and ConnectivityNetwork.frameworkNetwork Framework
Networking and ConnectivityNetworkingNetworking
System IntegrationOSLog & MetricKitOSLog
Core UI and AppsPhotosUI & AVKitPhotosUI, Photos
Graphics, 3D, and GamesRealityKit -- Complete Guide for 3D Rendering, AR, and Spatial AppsRealityKit
Graphics, 3D, and GamesSceneKit -- Legacy 3D Scenes, Animation, and AR RenderingSceneKit
System IntegrationContacts FrameworkContacts
System IntegrationEventKitEventKit
Commerce and WalletPassKit & FinanceKitPassKit
System IntegrationWeatherKitWeatherKit
Commerce and WalletStoreKit 2StoreKit
Core UI and AppsSwift ChartsSwift Charts
Data ManagementSwiftDataSwiftData
Core UI and AppsTipKitTipKit
System IntegrationUserNotificationsUserNotifications
Core UI and AppsVisionKitVisionKit
Core UI and AppsWidgetKitWidgetKit
McpMCP Usage ExamplesMcp
McpInstalling the iOS Agent MCP ServerMcp
McpApple Knowledge MCPMcp
McpMCP Tool ReferenceMcp
OrchestrationHooks — Deterministic EnforcementOrchestration
OrchestrationLoopsOrchestration
OrchestrationSubagentsOrchestration
OrchestrationVerification and the Evidence ContractOrchestration
PlatformsiOS Platform GuideiOS
PlatformsmacOS Platform GuidemacOS
PlatformstvOS Platform GuidetvOS
PlatformsvisionOS Platform GuidevisionOS
PlatformswatchOS Platform GuidewatchOS
Design and MotionSwiftUI AnimationsSwiftUI Animation
SwiftuiRouting, Deep Links, and State RestorationSwiftui
SwiftuiSwiftUI GesturesSwiftui
SwiftuiSwiftUI iOS 27 InteractionsSwiftui
SwiftuiSwiftUI Layout SystemSwiftui
SwiftuiSwiftUI NavigationSwiftui
SwiftuiSwiftUI State and Data FlowSwiftui
Core UI and AppsSwiftUI Views and ControlsSwiftUI
AI and Machine LearningEvaluations -- Testing Intelligence-Powered FeaturesEvaluations
ToolingApp-building loop (preview)Tooling
ToolingApp Description WorkflowTooling
Developer ToolsDevice HubDevice Hub
Developer ToolsFoundation Models `fm` CLIfm CLI
Developer ToolsFoundation Models InstrumentsFoundation Models Instruments
ToolingFrom an Idea to a Running Apple AppTooling
Developer ToolsiOS Simulator MCPiOS Simulator MCP
ToolingBuild from local source and Apple guidanceTooling
ToolingProject Scaffolding and LayoutTooling
ToolingVisual Iteration LoopTooling
Developer ToolsXcode 27 Coding AgentsXcode Agents
ToolingXcode Memory DebuggingTooling
Core UI and AppsUIKit EssentialsUIKit
Design and MotionNative vs. Web Animation and 3DNative vs Web Animation, WebKit Project internals
Contributor and maintainer references for this project: publication, analyzer planning, issue reporting and agent coordination.
McpOfficial MCP Registry publicationMcp
McpvNext MCP Analysis ToolsMcp
OrchestrationDynamic Workflows and Large-Scale JobsOrchestration
OrchestrationMain Agent RouterOrchestration
ToolingAI-assisted issue previewsTooling
---
# Site map
https://nagarjuna2997.github.io/ios-agent-skill/pages.html
Site mapEvery public page on this website.Pages
Practical Swift & AI development guidesDaily community monitorCommunity & MentionsiOS Agent Skill — Swift Reviews & Xcode Simulator MCPApple development guide librarySite mapFrom first prompt to shipped app: guided Swift seriesInstall guides
Install iOS Agent MCP — Claude, Codex, Gemini CLI & MuseBlog
How can design tokens generate light, dark and…How should I review availability guards when moving a…ChatGPT and Codex: plan the app, then verify it locallyChoose an AI client for your iOS workflowHow can Claude Code hooks protect generated files and…Claude Code: turn a Swift finding into a tested patchGemini CLI: connect once and review one iOS featureGenerate light and dark asset catalogs without a paid…Which Muse Code MCP settings actually work with a local…Muse Code: what our integration actually verifiesReview AI-generated Swift before you trust itHow can I catch Swift concurrency review findings before…SwiftUI: review state transitions before polishing the…Five checks for an AI-built SwiftUI appDocs
Documentation indexApp Store Submission ChecklistGenerate real asset catalogsiOS Color System -- Complete Guide for Stunning SwiftUI UIsDesign Tokens, Adaptive Color, and Liquid GlassiOS Font Catalog — Ultimate ReferenceLayer-by-Layer Icons with Icon ComposerInteraction StandardsAdopting Liquid GlassProfessional UI/UX SystemStunning UI Patterns -- Complete SwiftUI Pattern LibraryThird-Party Animation IntegrationiOS Typography System -- Complete Guide for Stunning…ActivityKit & Live ActivitiesApp ClipsApp IntentsApple IntelligenceARKit -- Complete Guide for Augmented Reality on iOS and…AuthenticationServicesAVFoundationBackgroundTasksCloudKitCore AI -- Custom On-Device AI Models on Apple SiliconCore DataCoreLocationCore Spotlight RAG -- Private App-Local Retrieval for…CryptoKitSwiftData and Core Data ConcurrencyDevice Integrity (DeviceCheck & App Attest)Extended Apple FrameworksFoundation ModelsFoundation FrameworkCoreBluetoothCoreMotionCoreNFCHealthKitHomeKit and MatterLocalAuthenticationMapKitMetal -- GPU Rendering, Compute, and Performance-Critical…CoreML -- Complete Guide for On-Device Machine LearningNaturalLanguage Framework -- Complete Guide for Text…Sound AnalysisSpeech Framework -- Complete Guide for Speech Recognition…TranslationVision Framework -- Complete Guide for Image Analysis and…Network.frameworkNetworkingOSLog & MetricKitPhotosUI & AVKitRealityKit -- Complete Guide for 3D Rendering, AR, and…SceneKit -- Legacy 3D Scenes, Animation, and AR RenderingContacts FrameworkEventKitPassKit & FinanceKitWeatherKitStoreKit 2Swift ChartsSwiftDataTipKitUserNotificationsVisionKitWidgetKitMCP Usage ExamplesInstalling the iOS Agent MCP ServerApple Knowledge MCPOfficial MCP Registry publicationMCP Tool ReferencevNext MCP Analysis ToolsDynamic Workflows and Large-Scale JobsHooks — Deterministic EnforcementLoopsMain Agent RouterSubagentsVerification and the Evidence ContractiOS Platform GuidemacOS Platform GuidetvOS Platform GuidevisionOS Platform GuidewatchOS Platform GuideSwiftUI AnimationsRouting, Deep Links, and State RestorationSwiftUI GesturesSwiftUI iOS 27 InteractionsSwiftUI Layout SystemSwiftUI NavigationSwiftUI State and Data FlowSwiftUI Views and ControlsEvaluations -- Testing Intelligence-Powered FeaturesApp-building loop (preview)App Description WorkflowDevice HubFoundation Models `fm` CLIFoundation Models InstrumentsFrom an Idea to a Running Apple AppiOS Simulator MCPAI-assisted issue previewsBuild from local source and Apple guidanceProject Scaffolding and LayoutVisual Iteration LoopXcode 27 Coding AgentsXcode Memory DebuggingUIKit EssentialsNative vs. Web Animation and 3DWhat "vibe coding" a Swift app actually means, and what…What you need before an AI can build you an iOS app: Mac,…Choosing a coding agent for iOS in 2026: Claude Code,…Your first hour: install Xcode, run the simulator, build…How coding agents read a project: AGENTS.md, CLAUDE.md,…Skills, plugins, MCP servers, hooks: the four words…Build a SwiftUI to-do app with Claude Code: the brief,…Compare the same to-do app task in Codex without changing…Prepare an Antigravity app-building trial and identify…Prepare a Muse app-building trial beyond tool discoveryWriting an app brief an agent can build from: ten…Generating an Xcode project the agent can edit: XcodeGen…Running and seeing your app: simulator boot, install,…What to do when the build fails: reading Xcode errors…Review Swift 6 settings and actor isolation before…Getting an agent to run checks: hooks, Stop conditions…Making the agent prove it: screenshots as evidence, not…Reviewing agent output before you commit: a focused…Git for vibe coders: branches per prompt, checkpoints,…Keeping the agent inside your design: tokens, no…SwiftUI prompt practice: 25 scoped exercises and…When a SwiftUI agent task fails: ten failure categories…Local persistence contracts: Reading List JSON storage…Localization with an agent: what to check before it…Search, filtering and empty states that pass…Networking and Codable with an agent: the concurrency…Navigation in SwiftUI: what agents get wrong with…Adding widgets with App Intents: a timeline and…Designing Siri actions with App Intents and testable…On-device AI: Foundation Models readiness and fallback…Notifications, background tasks and the permissions…Deep links and universal links, and using them for…App icons in 2026: Liquid Glass, Icon Composer, and…Color systems that survive dark mode and high contrast,…Typography in SwiftUI that respects Dynamic Type: rules…Motion and haptics: what to ask an agent for, and what…Making an agent-built app look intentional, not templatedPreparing repeatable simulator captures for App Store…From Figma to SwiftUI with an agent: variables to tokens…Swift Testing vs XCTest: what to have the agent generate…UI tests an agent can write and maintainAccessibility review of an agent-built app: the checks…Performance: hangs, launch time and the patterns agents…Memory: retain cycles agents create in closures and CombineSecurity review of an agent-built app: keychain, ATS,…Static review vs the compiler: what heuristics catch and…App Store readiness: the checks before you upload an…Privacy manifests, permissions strings and the things…Preparing for TestFlight: signing, builds and account…Investigating App Review feedback without guessing the…Versioning, changelogs and release notes the agent can…CI for an iOS repo with agent-written code: what to run…Subagents for iOS: splitting review, build and design…Preparing to verify Xcode 27 agents with external skillsKeeping up with Apple: refreshing references after each…Planning a safe Swift 6 and iOS 27 migrationCoordinating parallel coding work with isolated checkoutsWriting your own review rule for a Swift codebaseWriting your own skill for a Swift project: SKILL.md…Building a Claude Code plugin from a skillBuilding a Codex plugin from the same skillCross-client packaging: verified Muse scope and an…Verifying a skill across clients: the client verification…Measuring whether a skill helps: a paired benchmark…Ask for the finding, not the fix: why review-first…When the agent says "done", run the tests yourself onceSmall prompts, one screen at a time, commit between eachKeep signing and account changes separate from code repairsReading agent transcripts: the three phrases that mean it…Turn every failure into a rule: how findings become reviewsVerify Muse observer behavior before recommending itCodex worktrees for parallel screensTreat background builds as running work, not completed…Claude Code plan mode before any multi-file Swift changeAsk the agent to search local Apple references before the…Ask about actor ownership before adding MainActorWhat to put in .gitignore for agent-built Xcode projects"Show me the screenshot" as a standing instructionPin your Xcode version in AGENTS.md and stop the agent…One deep link per screen makes every screenshot automatableKeep design tokens in one file and tell the agent it's…Review the XcodeGen spec before regenerating the projectUse `#available` audits before every beta upgradeHooks beat reminders: enforce, don't request
---
# From first prompt to shipped app: guided Swift series
https://nagarjuna2997.github.io/ios-agent-skill/series.html
← Back to all articlesGuided learning · Series roadmapFrom first prompt
to shipped app.A step-by-step Swift learning path, from opening Xcode to reviewing, testing and shipping an app.All 84 lessons are available to read, with a diagram in each. These educational guides distinguish recorded results from practice exercises and blocked hands-on labs. Read each lesson’s evidence section before treating an example as verified.0 · Orientation
1 · First app with an agent
2 · Making the agent reliable
3 · Real features
4 · Design and assets
5 · Quality and testing
6 · Shipping
7 · Pro workflows
8 · Tips and tricksLevel 0
Orientation (for people who have never opened Xcode)
0.1
What "vibe coding" a Swift app actually means, and what it doesn't
Read lesson0.2
What you need before an AI can build you an iOS app: Mac, Xcode, Apple ID, simulator
Read lesson0.3
Choosing a coding agent for iOS in 2026: Claude Code, Codex, Antigravity, Muse Code, Xcode's own
Read lesson0.4
Your first hour: install Xcode, run the simulator, build the template app without AI
Read lesson0.5
How coding agents read a project: AGENTS.md, CLAUDE.md, GEMINI.md, and what to put in them
Read lesson0.6
Skills, plugins, MCP servers, hooks: the four words you'll see everywhere, explained once
Read lesson
Related reading available now
Choose an AI client
What to do next: Level 1 →Level 1
First app with an agent (day one)
1.1
Build a SwiftUI to-do app with Claude Code: the brief, checks and repair workflow
Read lesson1.2
Compare the same to-do app task in Codex without changing the acceptance criteria
Read lesson1.3
Prepare an Antigravity app-building trial and identify missing setup
Read lesson1.4
Prepare a Muse app-building trial beyond tool discovery
Read lesson1.5
Writing an app brief an agent can build from: ten practice briefs
Read lesson1.6
Generating an Xcode project the agent can edit: XcodeGen vs opening .xcodeproj by hand
Read lesson1.7
Running and seeing your app: simulator boot, install, launch, screenshot from the agent
Read lesson1.8
What to do when the build fails: reading Xcode errors with an agent
Read lesson
Related reading available now
Plan an app and verify locallyFive simulator checks
What to do next: Level 2 →Level 2
Making the agent reliable (week one)
2.1
Review Swift 6 settings and actor isolation before changing agent-generated code
Read lesson2.2
Getting an agent to run checks: hooks, Stop conditions and verification limits
Read lesson2.3
Making the agent prove it: screenshots as evidence, not "done"
Read lesson2.4
Reviewing agent output before you commit: a focused checklist
Read lesson2.5
Git for vibe coders: branches per prompt, checkpoints, and undoing an agent's mess
Read lesson2.6
Keeping the agent inside your design: tokens, no hardcoded colors, no fixed fonts
Read lesson2.7
SwiftUI prompt practice: 25 scoped exercises and acceptance checks
Read lesson2.8
When a SwiftUI agent task fails: ten failure categories to distinguish
Read lesson
Related reading available now
Generated-file guards and Stop checksReview concurrency findings
What to do next: Level 3 →Level 3
Real features (weeks two to four)
3.1
Local persistence contracts: Reading List JSON storage and a SwiftData alternative
Read lesson3.2
Search, filtering and empty states that pass accessibility review
Read lesson3.3
Networking and Codable with an agent: the concurrency mistakes to catch
Read lesson3.4
Navigation in SwiftUI: what agents get wrong with NavigationStack
Read lesson3.5
Adding widgets with App Intents: a timeline and verification plan
Read lesson3.6
Designing Siri actions with App Intents and testable domain operations
Read lesson3.7
On-device AI: Foundation Models readiness and fallback design
Read lesson3.8
Notifications, background tasks and the permissions agents forget
Read lesson3.9
Deep links and universal links, and using them for automated screenshots
Read lesson3.10
Localization with an agent: what to check before it "translates" everything
Read lesson
Related reading available now
Review state transitions
What to do next: Level 4 →Level 4
Design and assets
4.1
App icons in 2026: Liquid Glass, Icon Composer, and generating them from SVG layers
Read lesson4.2
Color systems that survive dark mode and high contrast, generated from tokens
Read lesson4.3
Typography in SwiftUI that respects Dynamic Type: rules an agent can follow
Read lesson4.4
Motion and haptics: what to ask an agent for, and what reduced-motion users need
Read lesson4.5
Making an agent-built app look intentional, not templated
Read lesson4.6
Preparing repeatable simulator captures for App Store screenshots
Read lesson4.7
From Figma to SwiftUI with an agent: variables to tokens to code
Read lesson
Related reading available now
Generate and compile color assets
What to do next: Level 5 →Level 5
Quality and testing
5.1
Swift Testing vs XCTest: what to have the agent generate in 2026
Read lesson5.2
UI tests an agent can write and maintain
Read lesson5.3
Accessibility review of an agent-built app: the checks that matter
Read lesson5.4
Performance: hangs, launch time and the patterns agents introduce
Read lesson5.5
Memory: retain cycles agents create in closures and Combine
Read lesson5.6
Security review of an agent-built app: keychain, ATS, secrets in source
Read lesson5.7
Static review vs the compiler: what heuristics catch and what they miss
Read lesson
Related reading available now
Review before accepting changes
What to do next: Level 6 →Level 6
Shipping
6.1
App Store readiness: the checks before you upload an agent-built app
Read lesson6.2
Privacy manifests, permissions strings and the things agents leave blank
Read lesson6.3
Preparing for TestFlight: signing, builds and account boundaries
Read lesson6.4
Investigating App Review feedback without guessing the cause
Read lesson6.5
Versioning, changelogs and release notes the agent can draft
Read lesson6.6
CI for an iOS repo with agent-written code: what to run on every PR
Read lesson
What to do next: Level 7 →Level 7
Pro workflows
7.1
Subagents for iOS: splitting review, build and design across agents
Read lesson7.2
Coordinating parallel coding work with isolated checkouts
Read lesson7.3
Writing your own review rule for a Swift codebase
Read lesson7.4
Writing your own skill for a Swift project: SKILL.md structure that agents follow
Read lesson7.5
Building a Claude Code plugin from a skill
Read lesson7.6
Building a Codex plugin from the same skill
Read lesson7.7
Cross-client packaging: verified Muse scope and an Antigravity test boundary
Read lesson7.8
Verifying a skill across clients: the client verification record method
Read lesson7.9
Measuring whether a skill helps: a paired benchmark protocol
Read lesson7.10
Preparing to verify Xcode 27 agents with external skills
Read lesson7.11
Keeping up with Apple: refreshing references after each beta without breaking rules
Read lesson7.12
Planning a safe Swift 6 and iOS 27 migration
Read lesson
Related reading available now
Inspect a client verification record
What to do next: Level 8 →Level 8
Tips and tricks (short posts, one idea each)
8.1
Ask for the finding, not the fix: why review-first prompts produce better Swift
Read lesson8.2
Ask about actor ownership before adding MainActor
Read lesson8.3
"Show me the screenshot" as a standing instruction
Read lesson8.4
Pin your Xcode version in AGENTS.md and stop the agent guessing APIs
Read lesson8.5
One deep link per screen makes every screenshot automatable
Read lesson8.6
Keep design tokens in one file and tell the agent it's the only source of color
Read lesson8.7
Review the XcodeGen spec before regenerating the project
Read lesson8.8
Use `#available` audits before every beta upgrade
Read lesson8.9
Hooks beat reminders: enforce, don't request
Read lesson8.10
When the agent says "done", run the tests yourself once
Read lesson8.11
Small prompts, one screen at a time, commit between each
Read lesson8.12
Keep signing and account changes separate from code repairs
Read lesson8.13
Reading agent transcripts: the three phrases that mean it guessed
Read lesson8.14
Turn every failure into a rule: how findings become reviews
Read lesson8.15
Verify Muse observer behavior before recommending it
Read lesson8.16
Codex worktrees for parallel screens
Read lesson8.17
Treat background builds as running work, not completed tasks
Read lesson8.18
Claude Code plan mode before any multi-file Swift change
Read lesson8.19
Ask the agent to search local Apple references before the web
Read lesson8.20
What to put in .gitignore for agent-built Xcode projects
Read lesson
Explore all available articles →
---
# What "vibe coding" a Swift app actually means, and what…
https://nagarjuna2997.github.io/ios-agent-skill/series/0.1.html
← Guided series
·
All articlesLesson 0.1 · Guided learningWhat "vibe coding" a Swift app actually means, and what it doesn'tOn this page
Start with an observable promise
The development loop
Divide responsibility deliberately
Exercise: turn an adjective into a check
What this lesson establishes
Related reading
“Vibe coding” usually describes asking an AI to turn an idea into software and steering the result through conversation. For a Swift app, the useful version still includes reading changes, compiling, testing behavior and checking the interface. A fluent completion message is not an acceptance test.
Start with an observable promise
“Make a beautiful to-do app” leaves important decisions unstated. Does a task survive a restart? Can the user undo deletion? Does an empty search explain what happened? The agent can fill these gaps, but its choices may not be yours.
Try this smaller brief:
Create a local task list with a title and completion state.
An empty title cannot be saved.
Tasks survive terminating and reopening the app.
Searching never deletes stored tasks.
Support larger text and meaningful accessibility labels.
Before editing, identify the existing scheme and storage design.
Each sentence gives you something to inspect. The purpose is not to find magic wording; it is to make disagreement visible before a large implementation exists.
The development loop
The evidence loop1
Describe behavior
↓2
Inspect proposed change
↓3
Build and exercise
↓4
Accept or return failure
The decision in this diagram is based on evidence. When a test fails, return the actual failure to the agent with the acceptance criterion it violated. When a build cannot run, stop at “implementation written, build unverified.” Do not silently turn that into “finished.”
Divide responsibility deliberately
Participant
Useful work
What it cannot establish alone
Developer
Choose behavior, inspect tradeoffs, accept changes
That a plausible implementation actually runs
Coding agent
Inspect source, propose edits, invoke available tools
That an unexecuted check passed
Swift compiler
Check the program against a selected toolchain
That search or persistence matches the brief
Tests and simulator
Exercise selected behavior and presentation
That every device and every input works
For your first feature, retain a checkpoint before the edit. Ask for one change, review the diff, and run the relevant check. If the agent also rewrites navigation or adds a networking dependency, ask how those changes relate to the requested feature. A small diff makes both review and rollback easier.
Exercise: turn an adjective into a check
Take “fast, accessible search” and split it into independent questions. What data set will you test? What does no match look like? Can VoiceOver identify the search field? Does clearing the query restore the list? Avoid inventing a performance number until you have a measurement method and a representative device.
Write those answers in your brief. Ask the agent to identify missing decisions without editing any files. Then pick one transition to implement. You now have a useful collaboration boundary instead of an unlimited “build everything” instruction.
What this lesson establishes
This is an orientation lesson, not an app-generation benchmark. It makes no claim about reduced cost, universal correctness or a client finishing an app unattended. The project's
verification contract
explains how to distinguish inspected code from executed results.
Related reading
Review AI-generated Swift before you trust it
A focused review, a small patch and a real test beat a confident completion message.Five checks for an AI-built SwiftUI app
Use persistence, search and accessible states to make “it works” a testable claim.
What to do next
Next: What you need before an AI can build you an iOS app: Mac, Xcode, Apple ID, simulator
---
# What you need before an AI can build you an iOS app: Mac,…
https://nagarjuna2997.github.io/ios-agent-skill/series/0.2.html
← Guided series
·
All articlesLesson 0.2 · Guided learningWhat you need before an AI can build you an iOS app: Mac, Xcode, Apple ID, simulatorOn this page
Separate three kinds of access
Inspect the installed toolchain
Readiness checklist
Add the agent last
Completion checkpoint
Related reading
For the local workflow in this series, start with a Mac that can run your selected Xcode, a compatible simulator runtime and enough storage for build products. Add a coding client only after a normal app build works. This separates environment failures from generated-code failures.
Separate three kinds of access
Apple tools, an Apple developer account and an AI subscription are different things. Buying access to one does not configure the others. Running a simulator build is also different from signing an app for a physical phone or distributing it through TestFlight.
Apple explains the distinction between a free developer account and program membership in its
account overview
. Check the current membership requirements when you reach distribution; this lesson does not ask you to purchase anything.
Local development prerequisites1
Mac + selected Xcode
↓2
Available iOS runtime
↓3
Known project + scheme
↓4
Baseline before agent
Inspect the installed toolchain
Open Terminal and run these read-only checks:
xcodebuild -version
xcode-select -p
xcrun simctl list devices available
The first identifies Xcode, the second identifies the selected developer directory, and the third lists usable simulator devices. Keep their meanings separate. An installed Xcode application does not necessarily mean a command-line session has selected it; a simulator application does not necessarily mean the runtime you need is installed.
For this series preparation, the local check reported Xcode 26.6, build 17F113. Available devices used an iOS 26.5 runtime, and none was initially booted. These are facts about the test Mac, not minimum system requirements for your machine.
Readiness checklist
Check
Passing evidence
If it fails
Xcode selection
Version and developer directory are available
Open Xcode and finish its setup; inspect the selected tools
Simulator runtime
An available device is listed
Install a compatible runtime through Xcode settings
Project
A project/workspace and scheme are known
Open the project and inspect its schemes
Build
The selected scheme compiles
Diagnose the first meaningful build error
Agent connection
Expected tools are discoverable
Check the client configuration and executable path
Do not paste account passwords, signing credentials or authentication tokens into a debugging conversation. Most environment diagnosis needs a version, a command and a redacted error, not account access.
Add the agent last
The repository's local npm tools require Node.js 20 or later. That dependency belongs to the MCP server, not to Swift itself. You can learn Xcode and run a Swift project before installing it. When you add the server, use the
setup instructions
for your actual client rather than copying another client's configuration.
Keep a short environment record in the project: Xcode version, deployment target, simulator runtime, scheme and the build command you used. This gives later failures a baseline. If the baseline changes after an update, investigate that change before rewriting the app.
Completion checkpoint
You are ready for the next lesson when you can name the selected Xcode version and find an available simulator. A successful account login is not the completion criterion here. Apple's
run-destination guide
explains the next step from available device to running app.
Related reading
Choose an AI client for your iOS workflow
Understand the difference between a skill, a local MCP connection and ChatGPT web setup.Five checks for an AI-built SwiftUI app
Use persistence, search and accessible states to make “it works” a testable claim.
What to do next
Next: Choosing a coding agent for iOS in 2026: Claude Code, Codex, Antigravity, Muse Code, Xcode's own
---
# Choosing a coding agent for iOS in 2026: Claude Code,…
https://nagarjuna2997.github.io/ios-agent-skill/series/0.3.html
← Guided series
·
All articlesLesson 0.3 · Guided learningChoosing a coding agent for iOS in 2026: Claude Code, Codex, Antigravity, Muse Code, Xcode's ownOn this page
Decide where execution happens
What the project records actually say
Run a fair trial
Keep cost evidence honest
Choose a starting point
Related reading
Choose a coding client by where the work will run, which tools it can actually call and what evidence you can inspect. A brand comparison is less useful than a small reproducible task in your own development environment.
Decide where execution happens
A browser conversation can help refine an app brief. A local coding environment can inspect files and invoke local commands when configured and authorized. Those are different execution paths. A model knowing Swift does not give it access to your Mac's Xcode installation.
Choose by execution needs1
Need local build?
↓2
Check client access
↓3
Try identical small task
↓4
Compare actual evidence
For local iOS work, first confirm that the client can see the intended project and that you understand how changes and command results are displayed. Then test one read-only review. Do not begin by letting several clients edit the same checkout.
What the project records actually say
Client
Available project evidence
Boundary
Claude Code
A bounded text-repair check
Not a complete app-generation benchmark
Codex
Local setup instructions
No same-brief comparison is presented in this lesson
Gemini CLI
Extension validation and stdio connection record
Model-driven task remains a separate check
Muse Code
Tool discovery and Stop hook with echo provider
Not proof of model repair or observer behavior
Antigravity and Xcode agents
Topics requested for later comparison
Not tested in this lesson
These rows are intentionally not a ranking. Read the
client setup guide
and the
Muse connection walkthrough
for the exact scope of the available records. A checkmark beside “connected” should never be read as “best at building apps.”
Run a fair trial
Use a disposable branch and a small synthetic feature. Keep the brief, starting commit, toolchain, acceptance criteria and retry limit constant. Record the client and model versions, elapsed time, tool failures, final diff and test outcomes. If a client cannot run a required command, record that limitation rather than substituting a different acceptance test.
Inspect this project without editing it.
Identify the app target and existing test command.
Explain one small change to the empty state.
State what you can verify here and what you cannot.
This first prompt tests whether the client identifies real project context. Follow it with the same small implementation task only after the inspection is accurate.
Keep cost evidence honest
A subscription price is not the cost of a run. Retries, model choice, account limits and included usage make casual comparisons misleading. Record whatever usage information the client actually exposes and identify missing measurements. This article has no measured per-run cost table because that paired experiment has not been performed.
Choose a starting point
Use the client you can already operate reliably, connect one tool path, and keep the task small. Switch because of an observed constraint—such as unavailable local execution or poor failure reporting—not because an untested comparison claims a universal winner. The later same-app lessons must earn their conclusions through comparable runs.
Related reading
Choose an AI client for your iOS workflow
Understand the difference between a skill, a local MCP connection and ChatGPT web setup.Which Muse Code MCP settings actually work with a local Swift review server?
A reproducible Muse 1.3.0 connection check, with the exact configuration and clear limits on what discovery proves.
What to do next
Next: Your first hour: install Xcode, run the simulator, build the template app without AI
---
# Your first hour: install Xcode, run the simulator, build…
https://nagarjuna2997.github.io/ios-agent-skill/series/0.4.html
← Guided series
·
All articlesLesson 0.4 · Guided learningYour first hour: install Xcode, run the simulator, build the template app without AIOn this page
Create a small baseline
Keep a command-line baseline too
Diagnose the first meaningful failure
Change one visible thing
Verification boundary
Recorded baseline result
Related reading
Before asking an agent to fix Swift, establish that your Mac can build and run an app without an agent. This gives you a baseline for separating installation, project and implementation problems.
Create a small baseline
In Xcode, start a new iOS app project using the SwiftUI interface option available in your installed version. Choose a local folder and a neutral project name such as FirstRun. Select an available iPhone simulator as the destination, then use Product → Run. Exact template controls can change between Xcode versions; follow the options shown by your installed toolchain.
Apple's
simulator instructions
describe selecting a simulated run destination. The important result is an app window that responds, not merely a successful project-creation dialog.
A baseline you can diagnose1
Create or open project
↓2
Select simulator
↓3
Build → install → launch
↓4
Change text and repeat
Keep a command-line baseline too
For an existing checkout, list the schemes before choosing a build command:
xcodebuild -list -project YourApp.xcodeproj
Replace the project name with the actual file. Do not run a copied command against a guessed scheme. Once you know the scheme, a simulator build can separate compilation from device signing:
xcodebuild -project YourApp.xcodeproj \
-scheme YourScheme -sdk iphonesimulator \
-derivedDataPath /tmp/first-run-build \
CODE_SIGNING_ALLOWED=NO build
This command builds; it does not install or launch. A successful build therefore does not complete the entire exercise. Return to Xcode to run on your selected simulator and inspect the screen.
Diagnose the first meaningful failure
Symptom
Check first
Avoid
Developer tools cannot be found
Selected Xcode and first-launch setup
Asking the agent to rewrite Swift
Requested destination is unavailable
Installed runtime and available devices
Repeatedly using an obsolete device identifier
Scheme cannot be found
Project/workspace and scheme list
Guessing the scheme from the folder name
A long build log may contain many consequences of one error. Read the first relevant failure in its target context. Keep the command, selected SDK and a short redacted diagnostic together when asking for help.
Change one visible thing
After the baseline runs, change the initial text, rebuild and confirm the visible change. This establishes that you are editing the project you launched. Save a version-control checkpoint before introducing an agent. If later changes fail, you now have a known starting point to compare against.
Verification boundary
The command-line preparation for this lesson used the repository's existing Reading List project with Xcode 26.6, rather than a newly created Xcode GUI template. Its build result is recorded separately in the series evidence. The new-project GUI walkthrough above is instructional guidance, not a claim that a fresh installation and template flow were recorded end to end. Do not treat it as the completed Level 1 agent tutorial.
Recorded baseline result
The existing Reading List sample built successfully, installed and launched on an iOS 26.5 simulator. The first screenshot was captured before the app appeared and showed the home screen; a second capture showed the app's empty state. This is why a successful launch command alone is not visual verification.
Existing Reading List sample on iOS 26.5; not a newly generated template.
Read the baseline evidence record
. Persistence, search behavior and accessibility were not retested in this baseline run.
Related reading
Five checks for an AI-built SwiftUI app
Use persistence, search and accessible states to make “it works” a testable claim.
What to do next
Next: How coding agents read a project: AGENTS.md, CLAUDE.md, GEMINI.md, and what to put in them
---
# How coding agents read a project: AGENTS.md, CLAUDE.md,…
https://nagarjuna2997.github.io/ios-agent-skill/series/0.5.html
← Guided series
·
All articlesLesson 0.5 · Guided learningHow coding agents read a project: AGENTS.md, CLAUDE.md, GEMINI.md, and what to put in themOn this page
Put stable decisions in the project
A useful starting document
Separate guidance from enforcement
Avoid conflicting mirrors
Check that the instructions are useful
Related reading
Project instruction files explain durable constraints to a coding agent: how to build, where state belongs and what must be verified. They are not a substitute for source code, tests or permissions.
Put stable decisions in the project
A conversation is a poor place to keep the only copy of your build command. Put recurring facts in a short instruction file next to the project. Keep task-specific goals in the current brief. This avoids burying the important constraints under yesterday's debugging transcript.
Where project knowledge belongs1
Stable rules → instructions
↓2
Current goal → brief
↓3
Executable checks → scripts
↓4
Results → evidence record
For Codex, OpenAI documents
AGENTS.md instructions
. Claude Code documents
CLAUDE.md project memory
, and Gemini CLI documents
GEMINI.md context files
. File discovery and precedence belong to the individual client; do not assume renaming one file guarantees identical behavior everywhere.
A useful starting document
# Project workflow
- Inspect the existing project and scheme before editing.
- Keep UI-observed state on the project's intended actor.
- Use named color assets and support Dynamic Type.
- Preserve loading, empty, content and error states.
- Run the relevant build and tests after a change.
- Report commands, failures and checks that could not run.
- Do not modify signing settings as an incidental fix.
This example describes a workflow. Add your actual project paths and tested commands after you have established them. Do not copy a deployment target or scheme from another app merely to make the file look complete.
Separate guidance from enforcement
Need
Put it in
Why
Stable architecture decision
Project instructions
The agent needs context before editing
Current feature requirements
Task brief
They change between tasks
Repeatable validation
Test or script
It can produce an executable result
Prevent editing a generated file
Supported guard/hook
A reminder alone does not enforce a boundary
The repository's instruction entry point asks agents to retrieve relevant local source before expanding context, preserve state transitions and report failures. Those are useful constraints to study; copying the whole library into every app instruction file would make the important local facts harder to find.
Avoid conflicting mirrors
If several clients share a project, choose a canonical instruction source and a documented way to synchronize the client-specific files. After a change, compare the mirrors. A stale file can tell one client to follow a rule that another client no longer sees.
Do not add private credentials to instructions. They are ordinary files that may be committed, indexed or read by tools. Prefer references to the project's established secret-loading mechanism, without including the values.
Check that the instructions are useful
Ask the client to identify the relevant project instructions and summarize the build/check requirements before editing. Inspect the answer against the files. Then run a small task and verify the resulting behavior; a correct summary is not proof that every instruction was enforced.
The exercise is complete when another developer can read the file and reproduce the intended check without recovering context from your chat history.
Related reading
Choose an AI client for your iOS workflow
Understand the difference between a skill, a local MCP connection and ChatGPT web setup.How can Claude Code hooks protect generated files and verify a project before stopping?
A tested generated-file guard and Stop check, with configuration, reproducible exit codes, and enforcement limits.
What to do next
Next: Skills, plugins, MCP servers, hooks: the four words you'll see everywhere, explained once
---
# Skills, plugins, MCP servers, hooks: the four words…
https://nagarjuna2997.github.io/ios-agent-skill/series/0.6.html
← Guided series
·
All articlesLesson 0.6 · Guided learningSkills, plugins, MCP servers, hooks: the four words you'll see everywhere, explained onceOn this page
Follow one change through the system
Four terms, four questions
Where failures belong
A small installation exercise
Completion checkpoint
Related reading
A skill explains a reusable procedure. A plugin packages capabilities for a client. An MCP server exposes callable tools or resources. A hook runs at a supported lifecycle event. These mechanisms can work together, but they solve different problems.
Follow one change through the system
Imagine asking an agent to review a Swift file. Project instructions establish the app's constraints. A skill can describe which checks matter. An MCP tool can inspect the file and return findings. A supported hook can run a deterministic check at the appropriate event. You still inspect the change and its evidence.
How extension layers cooperate1
Plugin packages capabilities
↓2
Skill supplies procedure
↓3
MCP exposes operations
↓4
Hook invokes event checks
The arrows describe responsibility, not a guarantee that every client loads every component. A plugin may distribute a skill and a server configuration together, but the installed client must still discover and execute them correctly.
Four terms, four questions
Mechanism
Question it answers
Concrete example
Skill
How should this task be approached?
Review concurrency before accepting a Swift change
Plugin
How are capabilities packaged for this client?
A manifest plus skill and tool configuration
MCP server
Which operations can the agent call?
Review a Swift file and return finding locations
Hook
What runs at this lifecycle event?
Check protected generated files or run verification
Do not install all four simply because the words appear in a guide. Start with the need. If you only need guidance, a tool server may be unnecessary. If you need a simulator screenshot, prose alone cannot take one; the environment needs an executable operation and the required local tools.
Where failures belong
If the skill is missing, investigate discovery and task routing. If tools are absent, inspect the MCP connection. If a hook does not run, inspect the client's event and configuration support. If the tool runs but the app does not compile, inspect the actual compiler failure. Reinstalling a plugin does not automatically fix a Swift error.
Anthropic's
customization guide
distinguishes several of these extension mechanisms. Use the documentation for your client before copying event names or configuration structures.
A small installation exercise
Open the project's
setup guide
and choose one client. Configure one connection path. Reconnect the client, ask it to list the available tools, and perform one read-only operation against synthetic source. Record the client and package version. Do not equate a tool list with a completed app-building workflow.
Next, read the
hook walkthrough
. Notice that it distinguishes executing a script directly from having a client dispatch that hook. This distinction is useful whenever a configuration example looks correct but has not been tested through the actual runtime.
Completion checkpoint
You should now be able to explain which component provides guidance, which provides execution and which provides lifecycle enforcement. If an integration fails, name the layer before changing it. That habit makes later build/test loops easier to debug and keeps setup complexity proportional to the task.
Related reading
How can Claude Code hooks protect generated files and verify a project before stopping?
A tested generated-file guard and Stop check, with configuration, reproducible exit codes, and enforcement limits.Which Muse Code MCP settings actually work with a local Swift review server?
A reproducible Muse 1.3.0 connection check, with the exact configuration and clear limits on what discovery proves.
What to do next
Next: Build a SwiftUI to-do app with Claude Code: the brief, checks and repair workflow
---
# Build a SwiftUI to-do app with Claude Code: the brief,…
https://nagarjuna2997.github.io/ios-agent-skill/series/1.1.html
← Guided series
·
All articlesLesson 1.1 · Guided learningBuild a SwiftUI to-do app with Claude Code: the brief, checks and repair workflowOn this page
Work through the example
Implementation reference
Plan and implement your own app
Acceptance and failure review
Evidence and limits
Related reading
A first Claude session needs a fixed brief, a clean project and a bounded repair budget. The useful deliverable is not a confident transcript: it is a diff whose build and behavior can be inspected.
Empty task list → Add valid title → Save locally → Relaunch and verify1
Empty task list
↓2
Add valid title
↓3
Save locally
↓4
Relaunch and verify
Work through the example
Ask Claude to preserve the acceptance checks while implementing title validation, completion and persistence. Reject a repair that merely weakens a test.
Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.
Implementation reference
The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.
Plan and implement your own app
Start with a project, an app brief, and trusted executable acceptance checks. Use the demo JSON files as a schema example. Each criterion maps to existing check IDs; screen names map to screenshot-producing checks. Check commands run directly with argument arrays, without a shell, in the project directory. You must author meaningful tests for your intended behavior; a successful process alone cannot establish that an arbitrary brief is satisfied.
node /path/to/ios-agent-skill/mcp-server/dist/unified.js loop init --project /path/to/app --brief BRIEF.md --checks checks.json --attempts 3
node /path/to/ios-agent-skill/mcp-server/dist/unified.js loop resume --project /path/to/app
Without
--plan
, init asks the installed, authenticated
claude
CLI for a structured plan. Review
.ios-agent/loop/state.json
before resuming. Resume runs checks first, sends failing log tails and the plan to Claude, then reruns checks after edits. Repairs are limited to 1–10 attempts, 12 Claude turns per attempt, and a ten-minute timeout per Claude invocation. Each check has its own timeout (maximum 30 minutes). This bounds attempts and time, not monetary cost. Claude subscription/API usage is governed by your own local CLI configuration.
The repair adapter exposes Read, Glob, Grep, Edit and Write; it disables external MCP connections and does not grant shell commands. Your configured acceptance commands run with your local user permissions. Inspect them before running; this workflow is not a security sandbox. List tests, scripts and project settings in
protectedFiles
so the loop detects changed acceptance files instead of accepting weakened tests. Dependencies must already be installed.
Acceptance and failure review
Checkpoint
What to inspect
If it does not match
Empty task list
Confirm the input and environment
Preserve the failure and return to this step
Add valid title
Inspect the intermediate artifact
Preserve the failure and return to this step
Save locally
Run the focused check
Preserve the failure and return to this step
Relaunch and verify
Record the observed result
Preserve the failure and return to this step
Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.
Evidence and limits
A timed Claude to-do app run has not been completed. The Reading List run is a separate reference, not this experiment.
This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The
series evidence record
separates executed checks from exercises and blocked environments.
Inspect the source used in this lesson
.
Related reading
Claude Code: turn a Swift finding into a tested patch
A small review-to-test loop for an existing iOS project.SwiftUI: review state transitions before polishing the screen
Make loading, empty, success and error states part of the implementation brief.
What to do next
Next: Compare the same to-do app task in Codex without changing the acceptance criteria
---
# Compare the same to-do app task in Codex without changing…
https://nagarjuna2997.github.io/ios-agent-skill/series/1.2.html
← Guided series
·
All articlesLesson 1.2 · Guided learningCompare the same to-do app task in Codex without changing the acceptance criteriaOn this page
Work through the example
Implementation reference
Protocol
Acceptance and failure review
Evidence and limits
Related reading
A comparison with Codex only means something when the starting project and acceptance criteria remain the same. Changing the brief after one client struggles measures two tasks rather than two implementations.
Same starting commit → Same task brief → Independent test run → Compare differences1
Same starting commit
↓2
Same task brief
↓3
Independent test run
↓4
Compare differences
Work through the example
Keep the first client's output in a separate branch. Compare data ownership, error handling and failed assertions before counting changed lines.
Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.
Implementation reference
The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.
Protocol
20 fixed prompts; one trial per client × prompt × condition.
Clients: Claude Code and Codex, using each account's default model without a model override.
Baseline gets the task and source-file constraints. Treatment additionally gets
the skill entry point plus local guides, templates, examples and retrieval scripts.
No MCP server or subagent bundle is supplied to the model. This tests the
guidance treatment, not the entire product.
Each task gets an empty
Sources/Solution.swift
, Swift 6 and Foundation.
No secrets or personal app source is used.
Hidden assertions are introduced after the client exits, compiled separately
with the generated file, and executed independently. The client cannot edit them.
Alternate baseline-first and skill-first by task. Each call has a timeout.
Claude uses safe-mode, no session persistence and file tools only; Codex uses
ephemeral mode, ignores user config and disables automatic project docs.
Client tools/default prompts still differ, so compare conditions within a client.
Report compilation, assertions, static review blockers and client-reported token
usage. Cache/input token accounting differs by client; do not compare raw token
totals across providers or convert them into savings/cost without pricing data.
Provider failures/timeouts are recorded separately from acceptance failures.
A missing token field or failed scorer is unknown, not zero.
Acceptance and failure review
Checkpoint
What to inspect
If it does not match
Same starting commit
Confirm the input and environment
Preserve the failure and return to this step
Same task brief
Inspect the intermediate artifact
Preserve the failure and return to this step
Independent test run
Run the focused check
Preserve the failure and return to this step
Compare differences
Record the observed result
Preserve the failure and return to this step
Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.
Evidence and limits
No paired to-do app run or cost comparison is available; do not infer a winning client.
This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The
series evidence record
separates executed checks from exercises and blocked environments.
Inspect the source used in this lesson
.
Related reading
ChatGPT and Codex: plan the app, then verify it locally
Separate a browser planning session from a connected development environment.Five checks for an AI-built SwiftUI app
Use persistence, search and accessible states to make “it works” a testable claim.
What to do next
Next: Prepare an Antigravity app-building trial and identify missing setup
---
# Prepare an Antigravity app-building trial and identify…
https://nagarjuna2997.github.io/ios-agent-skill/series/1.3.html
← Guided series
·
All articlesLesson 1.3 · Guided learningPrepare an Antigravity app-building trial and identify missing setupOn this page
Work through the example
Implementation reference
6. Reporting failure
Acceptance and failure review
Evidence and limits
Related reading
An unavailable client is a setup boundary, not evidence that a model cannot build an app. Preserve the same task contract while documenting exactly which execution step cannot be performed.
Check executable → Verify authentication → Discover local tools → Run shared contract1
Check executable
↓2
Verify authentication
↓3
Discover local tools
↓4
Run shared contract
Work through the example
Prepare the brief and acceptance checks without substituting a different client. A later run must record the installed version and actual failures.
Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.
Implementation reference
The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.
6. Reporting failure
Report outcomes faithfully. Specifically:
If tests fail, say so and paste the failure.
If you skipped a step, say which and why.
If you could not reproduce a bug, say that — do not ship a speculative fix as
a confirmed one.
If part of the scope is blocked, finish everything else in full and state
exactly what you left out.
A partial result honestly labelled is more useful than a complete-looking result
that is wrong, because the human can act on the first and will be misled by the
second.
Acceptance and failure review
Checkpoint
What to inspect
If it does not match
Check executable
Confirm the input and environment
Preserve the failure and return to this step
Verify authentication
Inspect the intermediate artifact
Preserve the failure and return to this step
Discover local tools
Run the focused check
Preserve the failure and return to this step
Run shared contract
Record the observed result
Preserve the failure and return to this step
Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.
Evidence and limits
Antigravity is not installed in this environment. Its app-building tutorial is blocked; no commands or behavior are invented here.
This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The
series evidence record
separates executed checks from exercises and blocked environments.
Inspect the source used in this lesson
.
Related reading
Choose an AI client for your iOS workflow
Understand the difference between a skill, a local MCP connection and ChatGPT web setup.Gemini CLI: connect once and review one iOS feature
Avoid duplicate connections and keep setup evidence separate from app evidence.
What to do next
Next: Prepare a Muse app-building trial beyond tool discovery
---
# Prepare a Muse app-building trial beyond tool discovery
https://nagarjuna2997.github.io/ios-agent-skill/series/1.4.html
← Guided series
·
All articlesLesson 1.4 · Guided learningPrepare a Muse app-building trial beyond tool discoveryOn this page
Work through the example
Implementation reference
Muse Code
Verified compatibility, 2026-09-16
Recheck on upgrades
Acceptance and failure review
Evidence and limits
Related reading
Muse tool discovery and a model-driven build are separate milestones. The existing echo-provider record proves a connection and a Stop hook, not that Muse wrote or repaired the same to-do app.
Installed executable → MCP discovery → Model task → Independent checks1
Installed executable
↓2
MCP discovery
↓3
Model task
↓4
Independent checks
Work through the example
Read the bounded connection record before attempting edits. Keep sandbox configuration errors separate from compiler diagnostics.
Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.
Implementation reference
The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.
Muse Code
Muse Code joins Claude, Codex and Gemini CLI through the same unified local MCP
server. No separate server package, plugin ZIP or
MUSE.md
is needed.
Install the server outside the agent sandbox, using Node.js 20 or later:
npm install -g ios-agent-mcp@latest
Merge this into
~/.config/muse/settings.json
; preserve your other settings and
existing server entries. Do not replace the whole file.
schema_version
is required.
{
"schema_version": 1,
"mcpServers": {
"ios-agent": {
"command": "ios-agent-mcp",
"args": []
}
}
}
Copyable template
·
Settings-key verification note
·
Hook verification scope
. If Muse cannot find
the command, use the absolute executable path printed by
command -v ios-agent-mcp
.
Restart Muse after updating settings. This avoids fetching a package inside Muse's
default proxy-only sandbox. It does not disable sandboxing or grant network access.
For local instructions, use the repository's
AGENTS.md
. For skill discovery,
keep the original frontmatter-bearing
SKILL.md
under your project's
project-local .claude/skills/ios-agent-skill folder with its companion references. Do not rename the
frontmatter-stripped
AGENTS.md
to
SKILL.md
. Trust only workspaces you recognize.
Verified compatibility, 2026-09-16
Muse Code
1.3.0 (1.3.0-R3233.1)
connected to the published
ios-agent-mcp 2.7.0
using stdio and discovered
36 tools
, including reviews, local references,
create_app
and simulator tools. Initialization and tool discovery used the actual
Muse executable with its local
echo
provider. No account credentials or model
request were needed.
muse init
and discovery of the repository's Claude-format
skill were separately verified earlier.
A command-based
Stop hook executed
in an isolated test. The
optional maintainer hook template
runs
this repository's existing verification script. It is for sessions rooted in this
repository only: confirm the working directory before enabling it. Do not copy it
into an unrelated user's app or apply it globally across projects. Hook commands
execute shell code, so inspect commands before merging them into your settings.
Not verified:
model-directed tool invocation, PreToolUse/PostToolUse ports,
verification observer behavior, sandboxed simulator operations and end-to-end
app creation by Muse. Tool discovery is not evidence that a model completed an app.
No observer toggle or privacy/pricing-tier claim is supplied without verification.
Recorded verification result
.
The harness asserts four representative tools and records the complete discovered
catalog and installed server package version.
Recheck on upgrades
From this repository, with Muse and the MCP package already installed:
node scripts/verify-muse.mjs /absolute/path/to/muse /absolute/path/to/ios-agent-mcp/dist/unified.js
This uses temporary configuration, disables foreign personal context, and checks
MCP discovery plus a Stop hook without a model call. It prints versioned JSON
results and removes the temporary files. It does not edit your Muse settings.
Rerun it when either client or server changes; record model-based checks separately.
Updating npm's
@latest
does not automatically upgrade an existing global install:
rerun
npm install -g ios-agent-mcp@latest
when choosing to upgrade.
Official sources:
Meta announcement
,
configuration
, and
extensions
. The latter documentation
requires sign-in; the compatibility claims above come from executable tests.
Acceptance and failure review
Checkpoint
What to inspect
If it does not match
Installed executable
Confirm the input and environment
Preserve the failure and return to this step
MCP discovery
Inspect the intermediate artifact
Preserve the failure and return to this step
Model task
Run the focused check
Preserve the failure and return to this step
Independent checks
Record the observed result
Preserve the failure and return to this step
Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.
Evidence and limits
Muse is not on the current PATH, and the existing echo test cannot stand in for the requested app run.
This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The
series evidence record
separates executed checks from exercises and blocked environments.
Inspect the source used in this lesson
.
Related reading
Which Muse Code MCP settings actually work with a local Swift review server?
A reproducible Muse 1.3.0 connection check, with the exact configuration and clear limits on what discovery proves.Muse Code: what our integration actually verifies
Discovery and hooks are useful milestones, but they are not a finished app.
What to do next
Next: Writing an app brief an agent can build from: ten practice briefs
---
# Writing an app brief an agent can build from: ten…
https://nagarjuna2997.github.io/ios-agent-skill/series/1.5.html
← Guided series
·
All articlesLesson 1.5 · Guided learningWriting an app brief an agent can build from: ten practice briefsOn this page
Work through the example
Implementation reference
Input Contract
Acceptance and failure review
Ten briefs to practice with
Evidence and limits
Related reading
A usable brief specifies state transitions, data lifetime and exclusions. Ten different app ideas can share that structure without receiving the same implementation.
User goal → Observable behavior → Data and failure states → Acceptance criteria1
User goal
↓2
Observable behavior
↓3
Data and failure states
↓4
Acceptance criteria
Work through the example
For each brief, write one success check and one failure check. Keep optional polish out of the first implementation slice.
Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.
Implementation reference
The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.
Input Contract
Accept short, messy descriptions. Do not require the user to fill a long form.
Example:
Create a fitness app for beginners. It should track workouts, show progress,
feel energetic, and use blue and green.
If details are missing, infer reasonable defaults and label them as assumptions.
Ask a question only when the missing detail blocks implementation, such as
whether the app needs authentication, payments, health data, or backend sync.
Acceptance and failure review
Checkpoint
What to inspect
If it does not match
User goal
Confirm the input and environment
Preserve the failure and return to this step
Observable behavior
Inspect the intermediate artifact
Preserve the failure and return to this step
Data and failure states
Run the focused check
Preserve the failure and return to this step
Acceptance criteria
Record the observed result
Preserve the failure and return to this step
Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.
Ten briefs to practice with
These are authored exercises, not claimed outputs from ten model runs. Keep the first release local and small.
Idea
Core behavior
Acceptance check
Task list
Add and complete a task
Completion survives reopening
Reading list
Save and search books
Clearing search restores all books
Water log
Record a drink
Daily total changes once per entry
Pantry list
Mark an item used
Used item is excluded from available stock
Study cards
Reveal an answer
Navigation never changes the card data
Habit tracker
Mark a day complete
Repeated taps do not duplicate the day
Expense notes
Save an amount and note
Invalid numeric input cannot be saved
Travel checklist
Group packing items
Completion is independent in each trip
Workout journal
Record a set
A cancelled edit preserves the old value
Plant diary
Record watering
A new event appears with the chosen date
For each idea, add an empty state, an error recovery path and one explicit exclusion. For example, the reading list does not need accounts or a cloud backend in its first slice. That exclusion is a useful constraint, not missing ambition.
Evidence and limits
The example briefs are editorial exercises; they are not ten recorded model-generated plans.
This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The
series evidence record
separates executed checks from exercises and blocked environments.
Inspect the source used in this lesson
.
Related reading
SwiftUI: review state transitions before polishing the screen
Make loading, empty, success and error states part of the implementation brief.ChatGPT and Codex: plan the app, then verify it locally
Separate a browser planning session from a connected development environment.
What to do next
Next: Generating an Xcode project the agent can edit: XcodeGen vs opening .xcodeproj by hand
---
# Generating an Xcode project the agent can edit: XcodeGen…
https://nagarjuna2997.github.io/ios-agent-skill/series/1.6.html
← Guided series
·
All articlesLesson 1.6 · Guided learningGenerating an Xcode project the agent can edit: XcodeGen vs opening .xcodeproj by handOn this page
Work through the example
Implementation reference
1. The layout
Acceptance and failure review
Evidence and limits
Related reading
A project specification is easier to inspect than a large opaque project-file diff, but generation is still a build input. Review the generated target, files and deployment settings before trusting a successful command.
Description → Project specification → Generated project → Build scheme1
Description
↓2
Project specification
↓3
Generated project
↓4
Build scheme
Work through the example
Compare the checked-in Reading List project.yml with its Xcode project. Identify the app target and test targets without changing signing settings.
Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.
Implementation reference
The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.
1. The layout
MyApp/
├── App/ # the user's source — the only directory they edit
│ ├── MyApp/
│ └── MyAppTests/
├── README.md
├── LICENSE
├── .gitignore
└── .ios-agent/ # tool-owned; deleting it loses nothing
├── .gitignore # generated
├── config.json # tracked
├── state.json
├── metadata.json
├── cache/
├── logs/
├── build/
├── screenshots/
├── templates/ # tracked
├── plugins/ # tracked
└── tmp/
With
--minimal
, the whole project is
MyApp/App/
, and
.ios-agent/
materialises the first time a command needs it.
Acceptance and failure review
Checkpoint
What to inspect
If it does not match
Description
Confirm the input and environment
Preserve the failure and return to this step
Project specification
Inspect the intermediate artifact
Preserve the failure and return to this step
Generated project
Run the focused check
Preserve the failure and return to this step
Build scheme
Record the observed result
Preserve the failure and return to this step
Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.
Evidence and limits
XcodeGen is not on the current PATH; the checked-in project builds without regeneration.
This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The
series evidence record
separates executed checks from exercises and blocked environments.
Inspect the source used in this lesson
.
Related reading
ChatGPT and Codex: plan the app, then verify it locally
Separate a browser planning session from a connected development environment.Review AI-generated Swift before you trust it
A focused review, a small patch and a real test beat a confident completion message.
What to do next
Next: Running and seeing your app: simulator boot, install, launch, screenshot from the agent
---
# Running and seeing your app: simulator boot, install,…
https://nagarjuna2997.github.io/ios-agent-skill/series/1.7.html
← Guided series
·
All articlesLesson 1.7 · Guided learningRunning and seeing your app: simulator boot, install, launch, screenshot from the agentOn this page
Work through the example
Implementation reference
Evidence Contract
Acceptance and failure review
Evidence and limits
Recorded sample check
Related reading
Simulator orchestration has five distinct checkpoints: discover, boot, install, launch and capture. A process identifier after launch does not prove the screen you requested is visible.
Find available device → Boot runtime → Install and launch → Capture intended state1
Find available device
↓2
Boot runtime
↓3
Install and launch
↓4
Capture intended state
Work through the example
Use the recorded Reading List baseline to distinguish installation from visual readiness. Capture again if the first image still shows the home screen.
Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.
Implementation reference
The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.
Evidence Contract
Every runtime tool should return structured data suitable for workflow branching:
{
"status": "passed",
"device": {
"name": "iPhone 17 Pro",
"udid": "SIMULATOR-UDID",
"runtime": "iOS 27.0"
},
"app": {
"bundle_id": "com.example.App",
"configuration": "Debug"
},
"artifacts": {
"screenshot": "artifacts/screens/home.png",
"video": "artifacts/videos/flow.mov",
"logs": "artifacts/logs/app.log"
},
"summary": "Launched app and captured home screen."
}
For failure results, include the failing command, exit code, stderr, and the nearest useful artifact. A failed build with no artifact is still useful evidence; a visual claim with no artifact is not.
Acceptance and failure review
Checkpoint
What to inspect
If it does not match
Find available device
Confirm the input and environment
Preserve the failure and return to this step
Boot runtime
Inspect the intermediate artifact
Preserve the failure and return to this step
Install and launch
Run the focused check
Preserve the failure and return to this step
Capture intended state
Record the observed result
Preserve the failure and return to this step
Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.
Evidence and limits
A real baseline build, installation, launch and screenshot are recorded; this is not a newly generated to-do app.
This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The
series evidence record
separates executed checks from exercises and blocked environments.
Inspect the source used in this lesson
.
Recorded sample check
On Xcode 26.6 with an iOS 26.5 simulator, the Reading List verification script passed 3 unit tests and 2 UI tests. It exported six screenshots. This image is one of those outputs, not an AI-generated mockup. The sample uses JSON persistence and does not establish all-client app generation or a complete accessibility audit.
Related reading
Five checks for an AI-built SwiftUI app
Use persistence, search and accessible states to make “it works” a testable claim.
What to do next
Next: What to do when the build fails: reading Xcode errors with an agent
---
# What to do when the build fails: reading Xcode errors…
https://nagarjuna2997.github.io/ios-agent-skill/series/1.8.html
← Guided series
·
All articlesLesson 1.8 · Guided learningWhat to do when the build fails: reading Xcode errors with an agentOn this page
Work through the example
Implementation reference
Evidence and resume
Acceptance and failure review
Evidence and limits
Related reading
Build failures become manageable when you preserve the first relevant diagnostic and the exact command. Repeatedly changing unrelated Swift files destroys the context needed to explain the failure.
Reproduce command → Identify first error → Patch one cause → Repeat same check1
Reproduce command
↓2
Identify first error
↓3
Patch one cause
↓4
Repeat same check
Work through the example
Classify a failure as environment, project configuration, compiler or behavior before suggesting a fix. Record a bounded retry limit.
Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.
Implementation reference
The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.
Evidence and resume
Artifacts must use canonical relative paths under
.ios-agent/evidence/
. Symlink evidence paths are rejected. Checks must create fresh, nonempty artifacts; demo verification additionally validates PNG signatures.
State, logs and evidence are stored in
.ios-agent/
. Add that directory to your project's
.gitignore
; logs may contain source code, local paths or command output. The loop does not publish them or copy credentials. Claude receives the brief, configuration and failing log excerpts through your local authenticated CLI.
Successful checks are reused only when source fingerprints and saved evidence/log hashes match. Source changes invalidate cached results. Generated directories such as
.build
,
build
,
DerivedData
,
node_modules
,
.git
,
.ios-agent
and Xcode user/workspace metadata are excluded; do not keep implementation source there.
Interruptions preserve progress and terminate active process groups on macOS/Linux. Resume rechecks interrupted work. Windows process cleanup is limited to the direct child; iOS simulator verification requires macOS.
Frozen verification files cannot change mid-run. To intentionally revise the acceptance contract, archive
.ios-agent/loop
and initialize a new run. Do not edit saved results to claim completion.
Completion means every configured check passed with retained artifacts. Screenshots need human visual inspection; they are not automatic proof of visual quality, full accessibility or App Store readiness.
Acceptance and failure review
Checkpoint
What to inspect
If it does not match
Reproduce command
Confirm the input and environment
Preserve the failure and return to this step
Identify first error
Inspect the intermediate artifact
Preserve the failure and return to this step
Patch one cause
Run the focused check
Preserve the failure and return to this step
Repeat same check
Record the observed result
Preserve the failure and return to this step
Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.
Evidence and limits
The diagnostic exercises are examples, not a measured ranking of the ten most common errors.
This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The
series evidence record
separates executed checks from exercises and blocked environments.
Inspect the source used in this lesson
.
Related reading
Claude Code: turn a Swift finding into a tested patch
A small review-to-test loop for an existing iOS project.Review AI-generated Swift before you trust it
A focused review, a small patch and a real test beat a confident completion message.
What to do next
Next: Review Swift 6 settings and actor isolation before changing agent-generated code
---
# Review Swift 6 settings and actor isolation before…
https://nagarjuna2997.github.io/ios-agent-skill/series/2.1.html
← Guided series
·
All articlesLesson 2.1 · Guided learningReview Swift 6 settings and actor isolation before changing agent-generated codeOn this page
Work through the example
Implementation reference
@Observable grants no isolation
Acceptance and failure review
Evidence and limits
Related reading
Swift language mode, SDK availability and actor isolation answer different questions. Enabling checks reveals mismatches; it does not repair ownership or make CPU work safe on the main actor.
Inspect build settings → Locate isolation boundary → Apply minimal correction → Compile and exercise1
Inspect build settings
↓2
Locate isolation boundary
↓3
Apply minimal correction
↓4
Compile and exercise
Work through the example
Review a model's state mutations before adding MainActor. A detached task and an async function are not interchangeable escape hatches.
Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.
Implementation reference
The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.
@Observable
grants no isolation
@Observable
is a macro that generates observation plumbing. It says nothing
about which actor owns the state. An unannotated
@Observable
class is
nonisolated
, so any task may mutate it while SwiftUI reads it.
@Observable final class Model { var items: [Item] = [] } // nonisolated — racy
@MainActor @Observable final class Model { … } // correct
Under Swift 6 language mode the first form produces isolation errors as soon as
you touch it from an async context. Under Swift 5 mode with strict concurrency
checking set to
minimal
, it compiles silently and races at runtime.
Acceptance and failure review
Checkpoint
What to inspect
If it does not match
Inspect build settings
Confirm the input and environment
Preserve the failure and return to this step
Locate isolation boundary
Inspect the intermediate artifact
Preserve the failure and return to this step
Apply minimal correction
Run the focused check
Preserve the failure and return to this step
Compile and exercise
Record the observed result
Preserve the failure and return to this step
Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.
Evidence and limits
The local concurrency fixture has recorded static before/after findings; it is not proof that one setting fixes every migration.
This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The
series evidence record
separates executed checks from exercises and blocked environments.
Inspect the source used in this lesson
.
Related reading
How can I catch Swift concurrency review findings before accepting an agent’s changes?
A real before-and-after MCP review with file and line evidence, plus the limits of text-based concurrency checks.
What to do next
Next: Getting an agent to run checks: hooks, Stop conditions and verification limits
---
# Getting an agent to run checks: hooks, Stop conditions…
https://nagarjuna2997.github.io/ios-agent-skill/series/2.2.html
← Guided series
·
All articlesLesson 2.2 · Guided learningGetting an agent to run checks: hooks, Stop conditions and verification limitsOn this page
Work through the example
Implementation reference
1. Hook vs. CI vs. reviewer
Acceptance and failure review
Evidence and limits
Related reading
A reminder can be forgotten by an agent; a supported hook can run a deterministic check. Neither replaces CI, and a hook configuration is only tested when the real client dispatches it.
Tool event → Hook handler → Exit status → Continue or stop1
Tool event
↓2
Hook handler
↓3
Exit status
↓4
Continue or stop
Work through the example
Use a synthetic protected file to test both denial and allowance. Then deliberately fail verification and inspect whether the client stops.
Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.
Implementation reference
The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.
1. Hook vs. CI vs. reviewer
Three enforcement layers. Use the cheapest one that can decide the question.
Layer
Decides
Cost
Feedback speed
Hook
Rules a script can evaluate
~free
Immediate — model self-corrects mid-turn
CI
Same, plus full build/test
minutes
After push
Reviewer subagent
Judgment: is this correct, does it match intent
tokens
End of task
The ordering matters. Spending a reviewer subagent on "did you use
DispatchQueue.main.async
" is waste — a grep answers that for free, instantly,
and cannot be argued with. Reserve model judgment for what rules cannot express.
Rule of thumb:
if you can write the check as a grep or an exit code, it is a
hook. If it needs to understand intent, it is a reviewer.
Acceptance and failure review
Checkpoint
What to inspect
If it does not match
Tool event
Confirm the input and environment
Preserve the failure and return to this step
Hook handler
Inspect the intermediate artifact
Preserve the failure and return to this step
Exit status
Run the focused check
Preserve the failure and return to this step
Continue or stop
Record the observed result
Preserve the failure and return to this step
Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.
Evidence and limits
Claude script-level checks and Muse Stop discovery have different scopes. Full cross-client enforcement remains unverified.
This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The
series evidence record
separates executed checks from exercises and blocked environments.
Inspect the source used in this lesson
.
Related reading
How can Claude Code hooks protect generated files and verify a project before stopping?
A tested generated-file guard and Stop check, with configuration, reproducible exit codes, and enforcement limits.
What to do next
Next: Making the agent prove it: screenshots as evidence, not "done"
---
# Making the agent prove it: screenshots as evidence, not…
https://nagarjuna2997.github.io/ios-agent-skill/series/2.3.html
← Guided series
·
All articlesLesson 2.3 · Guided learningMaking the agent prove it: screenshots as evidence, not "done"On this page
Work through the example
Implementation reference
Visual Review Checklist
Acceptance and failure review
Evidence and limits
Recorded sample check
Related reading
A screenshot answers a visual question about one state on one device. Pair it with an acceptance criterion and a reproducible route to that state, rather than treating any image as completion.
Choose state → Navigate deterministically → Capture screen → Compare criterion1
Choose state
↓2
Navigate deterministically
↓3
Capture screen
↓4
Compare criterion
Work through the example
Name an empty, loading and content state. Record what each screenshot can show and what needs a behavioral or accessibility test.
Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.
Implementation reference
The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.
Visual Review Checklist
Area
Questions
Hierarchy
Is the primary action obvious within two seconds? Does visual weight match importance?
Spacing
Are margins, gutters, and component gaps consistent with tokens?
Typography
Does the screen use Dynamic Type and avoid fixed font sizes?
Color
Does contrast pass, and do semantic roles survive dark mode?
Motion
Does animation clarify state, avoid fighting layout, and honor Reduce Motion?
Haptics
Are haptics tied to meaningful state transitions, not decoration?
Accessibility
Are labels, traits, focus order, tap targets, and content size tested?
Runtime
Does the captured screenshot/video prove the actual app state?
Acceptance and failure review
Checkpoint
What to inspect
If it does not match
Choose state
Confirm the input and environment
Preserve the failure and return to this step
Navigate deterministically
Inspect the intermediate artifact
Preserve the failure and return to this step
Capture screen
Run the focused check
Preserve the failure and return to this step
Compare criterion
Record the observed result
Preserve the failure and return to this step
Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.
Evidence and limits
The baseline screenshot proves an empty screen was visible; it does not prove persistence or VoiceOver navigation.
This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The
series evidence record
separates executed checks from exercises and blocked environments.
Inspect the source used in this lesson
.
Recorded sample check
On Xcode 26.6 with an iOS 26.5 simulator, the Reading List verification script passed 3 unit tests and 2 UI tests. It exported six screenshots. This image is one of those outputs, not an AI-generated mockup. The sample uses JSON persistence and does not establish all-client app generation or a complete accessibility audit.
Related reading
Five checks for an AI-built SwiftUI app
Use persistence, search and accessible states to make “it works” a testable claim.Muse Code: what our integration actually verifies
Discovery and hooks are useful milestones, but they are not a finished app.
What to do next
Next: Reviewing agent output before you commit: a focused checklist
---
# Reviewing agent output before you commit: a focused…
https://nagarjuna2997.github.io/ios-agent-skill/series/2.4.html
← Guided series
·
All articlesLesson 2.4 · Guided learningReviewing agent output before you commit: a focused checklistOn this page
Work through the example
Implementation reference
3. What counts as evidence
Evidence for iOS specifically
When you genuinely cannot build
Acceptance and failure review
Evidence and limits
Related reading
Review the diff in an order that exposes expensive mistakes early: scope, data flow, errors, tests, then polish. A long list of stylistic suggestions is less useful than one reproducible blocker.
Inspect scope → Review risky boundaries → Run focused checks → Accept or revise1
Inspect scope
↓2
Review risky boundaries
↓3
Run focused checks
↓4
Accept or revise
Work through the example
Ask for a file location, supporting context and the smallest proposed change. Treat a clean heuristic report as limited evidence.
Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.
Implementation reference
The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.
3. What counts as evidence
Ordered by strength:
Test output
— the suite ran, with counts and pass/fail state.
Build output
— it compiles, with the exact command shown.
Command output
— a lint run, a script, a grep whose emptiness is the point.
A screenshot
— for UI work, the actual rendered result.
A file diff
— what changed, when the change itself is the deliverable.
A file:line citation
— for claims about what code does.
What does
not
count:
"It should work now."
"The change is correct."
"I verified the logic."
A summary of what a command would print.
A test you wrote but did not run.
Evidence for iOS specifically
### Build — SPM
swift build 2>&1 | tail -40
### Build — Xcode. List schemes first; never guess one.
xcodebuild -list -project MyApp.xcodeproj
xcodebuild build -scheme "MyApp" \
-destination 'platform=iOS Simulator,name=iPhone 16' 2>&1 | tail -40
### Tests
swift test 2>&1 | tail -60
xcodebuild test -scheme "MyApp" \
-destination 'platform=iOS Simulator,name=iPhone 16' 2>&1 | tail -60
### Lint
swiftlint lint --quiet
swiftformat --lint .
### Rule checks whose empty output IS the evidence
grep -rn "DispatchQueue.main.async" Sources/ # expect: nothing
grep -rn "APIClient\|URLSession" Sources/Presentation/ # expect: nothing
When a grep is the check,
show that it returned nothing
. An empty result you
did not display is indistinguishable from a check you did not run.
When you genuinely cannot build
Common on Linux CI, in containers without Xcode, or in a docs-only repository.
Say it once, plainly, and downgrade the affected claims:
UNVERIFIED — no Xcode toolchain in this environment (`xcodebuild: command not
found`). All Swift samples are INSPECTED against the framework docs in
docs/frameworks/, not compiled. A human should build before merging.
Then verify what you
can
: markdown structure, cross-references, script syntax
(
bash -n
), JSON/YAML validity. Partial verification honestly labelled beats
none.
Acceptance and failure review
Checkpoint
What to inspect
If it does not match
Inspect scope
Confirm the input and environment
Preserve the failure and return to this step
Review risky boundaries
Inspect the intermediate artifact
Preserve the failure and return to this step
Run focused checks
Run the focused check
Preserve the failure and return to this step
Accept or revise
Record the observed result
Preserve the failure and return to this step
Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.
Evidence and limits
This is a review procedure, not a guaranteed five-minute duration.
This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The
series evidence record
separates executed checks from exercises and blocked environments.
Inspect the source used in this lesson
.
Related reading
Review AI-generated Swift before you trust it
A focused review, a small patch and a real test beat a confident completion message.Claude Code: turn a Swift finding into a tested patch
A small review-to-test loop for an existing iOS project.
What to do next
Next: Git for vibe coders: branches per prompt, checkpoints, and undoing an agent's mess
---
# Git for vibe coders: branches per prompt, checkpoints,…
https://nagarjuna2997.github.io/ios-agent-skill/series/2.5.html
← Guided series
·
All articlesLesson 2.5 · Guided learningGit for vibe coders: branches per prompt, checkpoints, and undoing an agent's messOn this page
Work through the example
Implementation reference
2. /batch — many isolated changes
Making a batch succeed
Worktrees
Acceptance and failure review
Evidence and limits
Related reading
A checkpoint gives you a known state to compare and recover. A branch labels a line of work; a worktree gives another checkout. Neither automatically isolates simulator data or external resources.
Clean baseline → New branch or worktree → Small reviewed change → Test then integrate1
Clean baseline
↓2
New branch or worktree
↓3
Small reviewed change
↓4
Test then integrate
Work through the example
Inspect git status before asking an agent to edit. Commit coherent work, and review untracked files before switching tasks.
Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.
Implementation reference
The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.
2.
/batch
— many isolated changes
/batch
is a packaged use of subagents plus
git worktrees
, aimed at roughly
5–30 isolated changes that each become their own PR. Each unit gets a fresh
subagent working in its own worktree, so the changes cannot collide on disk.
Good fits:
Apply one mechanical rule across many modules — "add
@MainActor
to every
view model", "replace literal spacing with
Space.*
tokens".
Fix the same class of bug in many independent places.
Bump a dependency across several packages.
Migrate N files to a new API where each file stands alone.
Bad fits:
Units that share files.
Two workers editing
AppDelegate.swift
will
produce conflicting PRs.
Units with ordering constraints.
Batch has no dependency graph.
Exploratory work.
Batch executes a known change; it does not decide what
the change should be. Plan first (
ios-plan
), then batch.
One large refactor.
That is one PR, not thirty.
Making a batch succeed
The per-unit prompt must be
complete and self-contained
— each worker starts
cold and cannot see the others.
For <module>:
goal: every @Observable type the UI renders is @MainActor final class
scope: only files under <module>/ — do not touch shared/ or Package.swift
rules: behavior must not change; add nothing but the isolation annotations
verify: swift build && swift test --filter <module>
report: the diff, plus the real output of the verify command
if the module has no such types: report "no change needed" and stop
That last line matters. Without an explicit no-op path, workers invent work to
justify their existence.
Worktrees
Each unit gets its own working copy, so parallel writes are safe:
git worktree add ../wt-cart -b batch/cart-mainactor
git worktree add ../wt-orders -b batch/orders-mainactor
### … worker per worktree …
git worktree remove ../wt-cart
Isolation is the whole point. Without it, parallel writers corrupt each other's
work in ways that are extremely hard to diagnose after the fact.
Acceptance and failure review
Checkpoint
What to inspect
If it does not match
Clean baseline
Confirm the input and environment
Preserve the failure and return to this step
New branch or worktree
Inspect the intermediate artifact
Preserve the failure and return to this step
Small reviewed change
Run the focused check
Preserve the failure and return to this step
Test then integrate
Record the observed result
Preserve the failure and return to this step
Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.
Evidence and limits
The workflow is source guidance; a four-client isolation experiment is not claimed.
This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The
series evidence record
separates executed checks from exercises and blocked environments.
Inspect the source used in this lesson
.
Related reading
How can Claude Code hooks protect generated files and verify a project before stopping?
A tested generated-file guard and Stop check, with configuration, reproducible exit codes, and enforcement limits.Review AI-generated Swift before you trust it
A focused review, a small patch and a real test beat a confident completion message.
What to do next
Next: Keeping the agent inside your design: tokens, no hardcoded colors, no fixed fonts
---
# Keeping the agent inside your design: tokens, no…
https://nagarjuna2997.github.io/ios-agent-skill/series/2.6.html
← Guided series
·
All articlesLesson 2.6 · Guided learningKeeping the agent inside your design: tokens, no hardcoded colors, no fixed fontsOn this page
Work through the example
Implementation reference
1. The Three-Tier Token Architecture
Implementation
Injecting the theme
Tier 3 — component tokens as ViewModifiers
Anti-patterns
Acceptance and failure review
Evidence and limits
Related reading
Design tokens preserve intent across appearances. A semantic name such as primaryText describes purpose; a literal hex value describes only one rendering choice.
Primitive palette → Semantic roles → Component usage → Appearance checks1
Primitive palette
↓2
Semantic roles
↓3
Component usage
↓4
Appearance checks
Work through the example
Trace one button from its semantic color to its light, dark and high-contrast values. Remove literal colors only after understanding their role.
Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.
Implementation reference
The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.
1. The Three-Tier Token Architecture
Never let a raw color or number appear at a call site. Tokens flow in one
direction through three tiers:
Tier 1 — Primitive Tier 2 — Semantic Tier 3 — Component
(raw values) (intent) (usage)
blue500 #0A84FF → accent → Button.background
gray900 #1C1C1E → textPrimary → Card.titleColor
space4 16pt → spacing.contentInset → Card.padding
Rules
Views reference
Tier 3 or Tier 2 only
. A view that names
blue500
is a bug.
Tier 1 is
private
to the token module. It has no dark-mode variant — it is
literally just a number.
Tier 2 is where light/dark, high-contrast, and theme switching resolve.
Adding a theme means adding one Tier 2 implementation, not editing views.
Implementation
// DesignSystem/Tokens/Primitives.swift
// Tier 1 — raw values. Never referenced from a View.
enum Primitive {
static let blue500 = Color(hex: 0x0A84FF)
static let blue600 = Color(hex: 0x0060DF)
static let indigo500 = Color(hex: 0x5E5CE6)
static let red500 = Color(hex: 0xFF3B30)
static let green500 = Color(hex: 0x34C759)
static let amber500 = Color(hex: 0xFF9F0A)
// Spacing scale — a 4pt rhythm. Nothing else is permitted.
static let space1: CGFloat = 4
static let space2: CGFloat = 8
static let space3: CGFloat = 12
static let space4: CGFloat = 16
static let space5: CGFloat = 24
static let space6: CGFloat = 32
static let space7: CGFloat = 48
// Radii
static let radiusS: CGFloat = 8
static let radiusM: CGFloat = 16
static let radiusL: CGFloat = 24
}
// THE canonical hex initialiser for this skill. Defined here, in the token
// layer, and nowhere else — `color-system.md` and
// `templates/common-patterns/design-system.swift` reference this file rather
// than redeclaring it. Two files declaring `init(hex: String)` with different
// bodies is not a style disagreement: copy both into one target and the
// compiler rejects it with `invalid redeclaration of 'init(hex:)'`.
extension Color {
/// Hex as an integer literal: `Color(hex: 0x6C63FF)`.
///
/// Preferred over the string form because a typo is a compile error rather
/// than a runtime surprise — `0x6C63FZ` does not build, `"6C63FZ"` does.
init(hex: UInt32, opacity: Double = 1) {
self.init(
.sRGB,
red: Double((hex >> 16) & 0xFF) / 255,
green: Double((hex >> 8) & 0xFF) / 255,
blue: Double( hex & 0xFF) / 255,
opacity: opacity
)
}
/// Hex as a string: `Color(hex: "6C63FF")`, with or without `#`,
/// 6 digits (RGB) or 8 (RRGGBBAA).
///
/// Exists because designers hand over strings and remote themes arrive as
/// JSON. A malformed value traps in DEBUG and renders **magenta** in
/// release — never black. The earlier versions of this initialiser fell
/// back to black, which is indistinguishable from a deliberate colour and
/// so shipped unnoticed; magenta appears nowhere in any of these palettes
/// and is impossible to mistake for intent.
init(hex string: String, opacity: Double = 1) {
let cleaned = string
.trimmingCharacters(in: .whitespacesAndNewlines)
.replacingOccurrences(of: "#", with: "")
var value: UInt64 = 0
let scanned = Scanner(string: cleaned).scanHexInt64(&value)
switch (scanned, cleaned.count) {
case (true, 6):
self.init(hex: UInt32(truncatingIfNeeded: value), opacity: opacity)
case (true, 8):
let alpha = Double(value & 0xFF) / 255
self.init(hex: UInt32(truncatingIfNeeded: value >> 8), opacity: opacity * alpha)
default:
assertionFailure("Malformed hex colour literal: \(string)")
self.init(hex: 0xFF00FF, opacity: opacity)
}
}
}
// DesignSystem/Tokens/Theme.swift
// Tier 2 — semantic intent. This is the swappable layer.
protocol Theme: Sendable {
// Surfaces
var background: Color { get } // the page
var surface: Color { get } // cards, sheets
var surfaceElevated: Color { get } // popovers, menus
// Content — must meet contrast against the surface it sits on
var textPrimary: Color { get }
var textSecondary: Color { get }
var textOnAccent: Color { get }
// Intent
var accent: Color { get }
var accentPressed: Color { get }
var destructive: Color { get }
var success: Color { get }
var warning: Color { get }
// Separators and elevation
var separator: Color { get }
var shadow: Color { get }
}
struct OceanTheme: Theme {
// Apple's semantic colors already resolve light/dark AND increased contrast.
// Prefer them for surfaces and text; reserve custom hex for brand accents.
var background = Color(.systemBackground)
var surface = Color(.secondarySystemBackground)
var surfaceElevated = Color(.tertiarySystemBackground)
var textPrimary = Color(.label)
var textSecondary = Color(.secondaryLabel)
var textOnAccent = Color.white
var accent = Primitive.blue500
var accentPressed = Primitive.blue600
var destructive = Color(.systemRed)
var success = Color(.systemGreen)
var warning = Color(.systemOrange)
var separator = Color(.separator)
var shadow = Color.black.opacity(0.08)
}
// DesignSystem/Tokens/Spacing.swift — Tier 2 for layout
enum Space {
static let hairline = Primitive.space1 // icon-to-label
static let tight = Primitive.space2 // within a control
static let element = Primitive.space3 // between related elements
static let contentInset = Primitive.space4 // card padding, screen margins
static let section = Primitive.space5 // between sections
static let major = Primitive.space6 // above a page title
}
enum Radius {
static let control = Primitive.radiusS // buttons, chips
static let card = Primitive.radiusM // cards, tiles
static let sheet = Primitive.radiusL // modals
}
Injecting the theme
private struct ThemeKey: EnvironmentKey {
static let defaultValue: any Theme = OceanTheme()
}
extension EnvironmentValues {
var theme: any Theme {
get { self[ThemeKey.self] }
set { self[ThemeKey.self] = newValue }
}
}
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
RootView().environment(\.theme, OceanTheme())
}
}
}
Tier 3 — component tokens as ViewModifiers
struct CardStyle: ViewModifier {
@Environment(\.theme) private var theme
func body(content: Content) -> some View {
content
.padding(Space.contentInset)
.background(theme.surface, in: .rect(cornerRadius: Radius.card))
.shadow(color: theme.shadow, radius: 8, y: 4)
}
}
extension View {
func cardStyle() -> some View { modifier(CardStyle()) }
}
// Usage — no raw values anywhere.
VStack(alignment: .leading, spacing: Space.element) {
Text("Monthly total").font(.headline).foregroundStyle(theme.textPrimary)
Text("$1,240").font(.largeTitle.bold()).foregroundStyle(theme.accent)
}
.cardStyle()
Anti-patterns
// WRONG — raw values at the call site. Changing the brand means grepping.
.padding(16)
.background(Color(red: 0.04, green: 0.52, blue: 1.0))
.cornerRadius(16)
// WRONG — a Tier 1 primitive leaking into a view.
.foregroundStyle(Primitive.blue500)
// WRONG — a semantic name that describes appearance, not intent.
var lightGray: Color { … } // what happens in dark mode?
var textSecondary: Color { … } // correct
// RIGHT
.padding(Space.contentInset)
.background(theme.accent, in: .rect(cornerRadius: Radius.card))
Acceptance and failure review
Checkpoint
What to inspect
If it does not match
Primitive palette
Confirm the input and environment
Preserve the failure and return to this step
Semantic roles
Inspect the intermediate artifact
Preserve the failure and return to this step
Component usage
Run the focused check
Preserve the failure and return to this step
Appearance checks
Record the observed result
Preserve the failure and return to this step
Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.
Evidence and limits
Generated asset structure has evidence; visual quality still needs checks in the actual app.
This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The
series evidence record
separates executed checks from exercises and blocked environments.
Inspect the source used in this lesson
.
Related reading
How can design tokens generate light, dark and high-contrast color assets?
Generate a real xcassets catalog from explicit color tokens, inspect its four variants, and compile it with actool.Generate light and dark asset catalogs without a paid design tool
Turn explicit color tokens into Assets.xcassets, then inspect the result in your app.
What to do next
Next: SwiftUI prompt practice: 25 scoped exercises and acceptance checks
---
# SwiftUI prompt practice: 25 scoped exercises and…
https://nagarjuna2997.github.io/ios-agent-skill/series/2.7.html
← Guided series
·
All articlesLesson 2.7 · Guided learningSwiftUI prompt practice: 25 scoped exercises and acceptance checksOn this page
Work through the example
Implementation reference
Prompt Template
Acceptance and failure review
Twenty-five practice prompts
Evidence and limits
Related reading
A prompt library is useful when each prompt names inputs, boundaries and a check. Without recorded outputs, it is a set of exercises rather than evidence that a phrase reliably improves SwiftUI.
Choose one behavior → Supply project context → Request bounded change → Inspect result1
Choose one behavior
↓2
Supply project context
↓3
Request bounded change
↓4
Inspect result
Work through the example
Adapt the supplied prompts to a known scheme and one screen. Preserve failed attempts rather than deleting them from a success story.
Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.
Implementation reference
The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.
Prompt Template
Use this exact template when turning a description into an executable prompt:
You are building a native iOS app from this user description:
"<USER_DESCRIPTION>"
Create a production-ready SwiftUI implementation with:
- iOS deployment target: iOS 17+ unless the user asks otherwise
- Architecture: MVVM with protocol-based dependencies
- State: @MainActor @Observable view models for UI-rendered state
- Navigation: typed routes and deep-link-ready structure
- Design: tokenized colors, spacing, typography, radius, shadows
- Accessibility: Dynamic Type, VoiceOver labels, 44pt targets, contrast checks
- Previews: every main state must render without network or disk
- Testing: unit-testable use cases and injected repositories
Derived product brief:
- Name:
- Audience:
- Problem:
- Primary user journey:
- Secondary flows:
- Data model:
- Offline behavior:
Derived visual direction:
- Personality:
- Color tokens:
- Typography:
- Component style:
- Motion:
Implement:
1. App entry point
2. Theme/design tokens
3. Models
4. Repository protocols and mock repositories
5. View models
6. Screens
7. Reusable components
8. Previews
9. Tests where practical
Do not:
- Use raw colors, spacing, or font sizes in views
- Create live dependencies inside view models
- Use @Observable without @MainActor for UI state
- Claim the app works without build/test/screenshot evidence
Acceptance and failure review
Checkpoint
What to inspect
If it does not match
Choose one behavior
Confirm the input and environment
Preserve the failure and return to this step
Supply project context
Inspect the intermediate artifact
Preserve the failure and return to this step
Request bounded change
Run the focused check
Preserve the failure and return to this step
Inspect result
Record the observed result
Preserve the failure and return to this step
Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.
Twenty-five practice prompts
Use the request column as a starting sentence; supply the actual file and behavior. These are not labelled tested because this chapter does not contain 25 recorded model sessions.
Topic
Request
Check
Empty library
Describe the first useful action
No items is distinct from failure
Empty search
Add a clear-query action
Stored data remains intact
Loading
Show progress without duplicate requests
One request per intended action
Error
Preserve useful data on failure
Retry has a defined effect
Add form
Reject blank titles
Whitespace-only input fails
Edit form
Support cancellation
Original data is unchanged
Deletion
Explain recovery behavior
Deletion affects only selected item
Persistence
Use an injected store
Relaunch preserves data
Search
Define matching rules
Case behavior is intentional
Navigation
Use stable route identity
Missing item has a fallback
Deep link
Validate input
Unknown route does not crash
Dark mode
Use semantic tokens
Text remains readable
High contrast
Use explicit assets
Variants resolve correctly
Dynamic Type
Allow content to grow
Long labels do not clip
VoiceOver label
Name the action meaningfully
Label describes the control
Focus
Preserve a sensible destination
Dismissal restores context
Cancellation
Stop obsolete work
Stale result cannot replace current data
Networking
Validate status before decoding
Error payload is not success
Previews
Inject deterministic fixtures
No production disk or network access
Unit test
Assert one domain transition
Test fails for a broken transition
UI test
Use stable identifiers
Wait for observable state
Screenshots
Name state and destination
Image matches requested state
Refactor
Preserve public behavior
Same acceptance suite passes
Review
Locate one finding
Supporting source is included
Completion
Separate executed from unverified
No unsupported success claim
Evidence and limits
The prompts below are practice prompts, not 25 completed comparative runs.
This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The
series evidence record
separates executed checks from exercises and blocked environments.
Inspect the source used in this lesson
.
Related reading
SwiftUI: review state transitions before polishing the screen
Make loading, empty, success and error states part of the implementation brief.Gemini CLI: connect once and review one iOS feature
Avoid duplicate connections and keep setup evidence separate from app evidence.
What to do next
Next: When a SwiftUI agent task fails: ten failure categories to distinguish
---
# When a SwiftUI agent task fails: ten failure categories…
https://nagarjuna2997.github.io/ios-agent-skill/series/2.8.html
← Guided series
·
All articlesLesson 2.8 · Guided learningWhen a SwiftUI agent task fails: ten failure categories to distinguishOn this page
Work through the example
Implementation reference
6. Reporting failure
Acceptance and failure review
Ten failure categories
Evidence and limits
Related reading
A failed task should describe the environment, observed behavior and unresolved condition. It should not become a universal claim that all agents are unable to perform that task.
Expected behavior → Actual output → Reproduction → Bounded conclusion1
Expected behavior
↓2
Actual output
↓3
Reproduction
↓4
Bounded conclusion
Work through the example
Write a failure note that another developer can reproduce using synthetic data. Separate missing access from incorrect implementation.
Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.
Implementation reference
The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.
6. Reporting failure
Report outcomes faithfully. Specifically:
If tests fail, say so and paste the failure.
If you skipped a step, say which and why.
If you could not reproduce a bug, say that — do not ship a speculative fix as
a confirmed one.
If part of the scope is blocked, finish everything else in full and state
exactly what you left out.
A partial result honestly labelled is more useful than a complete-looking result
that is wrong, because the human can act on the first and will be misled by the
second.
Acceptance and failure review
Checkpoint
What to inspect
If it does not match
Expected behavior
Confirm the input and environment
Preserve the failure and return to this step
Actual output
Inspect the intermediate artifact
Preserve the failure and return to this step
Reproduction
Run the focused check
Preserve the failure and return to this step
Bounded conclusion
Record the observed result
Preserve the failure and return to this step
Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.
Ten failure categories
These categories distinguish causes; they are not a prevalence ranking or a list of impossible tasks.
Observed category
First diagnostic step
Executable missing
Check the installed command path
Expired authentication
Use the client’s local sign-in flow
Missing runtime
Inspect available simulator runtimes
Wrong scheme
List schemes in the selected project
Unavailable API
Inspect SDK and deployment target
Isolation mismatch
Trace ownership and mutation sites
Stale response
Exercise cancellation and replacement
Lost persistence
Terminate and reopen a test store
UI clipping
Use long text and larger type
False-positive finding
Reduce to positive and negative fixtures
Evidence and limits
No evidence supports a universal list of ten things agents cannot do; the lesson teaches failure classification instead.
This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The
series evidence record
separates executed checks from exercises and blocked environments.
Inspect the source used in this lesson
.
Related reading
Review AI-generated Swift before you trust it
A focused review, a small patch and a real test beat a confident completion message.Muse Code: what our integration actually verifies
Discovery and hooks are useful milestones, but they are not a finished app.
What to do next
Next: Local persistence contracts: Reading List JSON storage and a SwiftData alternative
---
# Local persistence contracts: Reading List JSON storage…
https://nagarjuna2997.github.io/ios-agent-skill/series/3.1.html
← Guided series
·
All articlesLesson 3.1 · Guided learningLocal persistence contracts: Reading List JSON storage and a SwiftData alternativeOn this page
Work through the example
Implementation reference
@Model Macro and Schema Definition
Acceptance and failure review
Evidence and limits
Related reading
The repository's Reading List sample persists JSON, not SwiftData. Use it to study the persistence contract, then treat a SwiftData implementation as a separate storage adapter with its own tests.
View model → Store protocol → SwiftData adapter → Relaunch verification1
View model
↓2
Store protocol
↓3
SwiftData adapter
↓4
Relaunch verification
Work through the example
Test create, edit, delete and reopen against a temporary store. Inject storage rather than letting previews access production data.
Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.
Implementation reference
The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.
@Model Macro and Schema Definition
import SwiftData
@Model
class Task {
var id: UUID
var title: String
var notes: String
var isCompleted: Bool
var priority: Int
var createdAt: Date
var dueDate: Date?
// Relationship
var category: Category?
var tags: [Tag]
// Transient (not persisted)
@Transient var isSelected = false
// Unique constraint
#Unique<Task>([\.id])
init(title: String, priority: Int = 0) {
self.id = UUID()
self.title = title
self.notes = ""
self.isCompleted = false
self.priority = priority
self.createdAt = Date()
self.tags = []
}
}
@Model
class Category {
var id: UUID
var name: String
var color: String
// Inverse relationship with cascade delete
@Relationship(deleteRule: .cascade, inverse: \Task.category)
var tasks: [Task]
init(name: String, color: String = "blue") {
self.id = UUID()
self.name = name
self.color = color
self.tasks = []
}
}
@Model
class Tag {
var id: UUID
var name: String
@Relationship(inverse: \Task.tags)
var tasks: [Task]
init(name: String) {
self.id = UUID()
self.name = name
self.tasks = []
}
}
Acceptance and failure review
Checkpoint
What to inspect
If it does not match
View model
Confirm the input and environment
Preserve the failure and return to this step
Store protocol
Inspect the intermediate artifact
Preserve the failure and return to this step
SwiftData adapter
Run the focused check
Preserve the failure and return to this step
Relaunch verification
Record the observed result
Preserve the failure and return to this step
Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.
Evidence and limits
The Reading List acceptance suite does not verify a SwiftData migration.
This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The
series evidence record
separates executed checks from exercises and blocked environments.
Inspect the source used in this lesson
.
Related reading
Five checks for an AI-built SwiftUI app
Use persistence, search and accessible states to make “it works” a testable claim.SwiftUI: review state transitions before polishing the screen
Make loading, empty, success and error states part of the implementation brief.
What to do next
Next: Search, filtering and empty states that pass accessibility review
---
# Localization with an agent: what to check before it…
https://nagarjuna2997.github.io/ios-agent-skill/series/3.10.html
← Guided series
·
All articlesLesson 3.10 · Guided learningLocalization with an agent: what to check before it "translates" everythingOn this page
Work through the example
Implementation reference
6. Localization Approach
String Catalogs Setup
Date and Number Formatting
RTL and Layout Considerations
Acceptance and failure review
Evidence and limits
Related reading
Localization changes layout and meaning, not just strings. Preserve placeholders and plural rules, then exercise long text, right-to-left presentation and missing translations.
Source string → Context and placeholders → Translation review → Layout checks1
Source string
↓2
Context and placeholders
↓3
Translation review
↓4
Layout checks
Work through the example
Give the agent the screen context for each string. Compare accessibility labels and visible labels after translation.
Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.
Implementation reference
The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.
6. Localization Approach
String Catalogs Setup
All user-facing strings must use
String(localized:)
. Never hardcode display text.
Xcode creates a
Localizable.xcstrings
file (String Catalog) that automatically extracts strings.
// MARK: - Correct: Localized Strings
let title = String(localized: "welcome.title")
let message = String(localized: "welcome.message")
// With default value
let greeting = String(localized: "greeting", defaultValue: "Hello there!")
// MARK: - String Interpolation
let itemCount = 5
let label = String(localized: "\(itemCount) items remaining")
// MARK: - Pluralization (handled in .xcstrings catalog)
// In the String Catalog, define plural variants:
// "item_count" -> one: "%lld item", other: "%lld items"
let countLabel = String(localized: "\(itemCount) items")
// MARK: - Table-based organization
let settingsTitle = String(localized: "title", table: "Settings")
Date and Number Formatting
// MARK: - Locale-Aware Formatting
struct FormattingExamples: View {
let price: Decimal = 49.99
let eventDate = Date()
let progress = 0.756
var body: some View {
VStack(alignment: .leading) {
// Currency — adapts to user locale
Text(price, format: .currency(code: "USD"))
// Date — adapts to locale conventions
Text(eventDate, format: .dateTime.month(.wide).day().year())
// Relative date
Text(eventDate, format: .relative(presentation: .named))
// Percentage
Text(progress, format: .percent.precision(.fractionLength(1)))
// Measurement
Text(Measurement(value: 72, unit: UnitTemperature.fahrenheit),
format: .measurement(width: .abbreviated))
}
}
}
RTL and Layout Considerations
// MARK: - RTL-Safe Layout
struct RTLSafeView: View {
@Environment(\.layoutDirection) var layoutDirection
var body: some View {
HStack {
// Use .leading/.trailing, never .left/.right
Image(systemName: "arrow.forward")
.flipsForRightToLeftLayoutDirection(true)
Text(String(localized: "next"))
}
.frame(maxWidth: .infinity, alignment: .leading) // Flips automatically in RTL
}
}
Acceptance and failure review
Checkpoint
What to inspect
If it does not match
Source string
Confirm the input and environment
Preserve the failure and return to this step
Context and placeholders
Inspect the intermediate artifact
Preserve the failure and return to this step
Translation review
Run the focused check
Preserve the failure and return to this step
Layout checks
Record the observed result
Preserve the failure and return to this step
Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.
Evidence and limits
Machine-generated translations still need language review; no multi-language audit is claimed.
This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The
series evidence record
separates executed checks from exercises and blocked environments.
Inspect the source used in this lesson
.
Related reading
SwiftUI: review state transitions before polishing the screen
Make loading, empty, success and error states part of the implementation brief.Five checks for an AI-built SwiftUI app
Use persistence, search and accessible states to make “it works” a testable claim.
What to do next
Next: App icons in 2026: Liquid Glass, Icon Composer, and generating them from SVG layers
---
# Search, filtering and empty states that pass…
https://nagarjuna2997.github.io/ios-agent-skill/series/3.2.html
← Guided series
·
All articlesLesson 3.2 · Guided learningSearch, filtering and empty states that pass accessibility reviewOn this page
Work through the example
Implementation reference
@State
Acceptance and failure review
Evidence and limits
Recorded sample check
Related reading
An empty library and an empty search result need different explanations. The first invites creation; the second preserves the collection while helping the user recover from a query.
Stored collection → Search query → Filtered result → Clear to recover1
Stored collection
↓2
Search query
↓3
Filtered result
↓4
Clear to recover
Work through the example
Add a known item, search for a missing title, clear the query and verify that the item returns. Check the labels separately from appearance.
Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.
Implementation reference
The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.
@State
Owns mutable state local to a view. SwiftUI manages storage; the view re-renders when the value changes.
struct CounterView: View {
@State private var count = 0
@State private var items: [String] = []
var body: some View {
VStack {
Text("Count: \(count)")
Button("Increment") { count += 1 }
Button("Add Item") { items.append("Item \(items.count)") }
}
}
}
Rules:
- Always mark
@State
properties
private
.
- Do not initialize
@State
from an initializer parameter when the view may be recreated -- use
@Binding
or a model instead.
- Works with value types (structs, enums, primitives) and, on iOS 17+, with
@Observable
classes.
Acceptance and failure review
Checkpoint
What to inspect
If it does not match
Stored collection
Confirm the input and environment
Preserve the failure and return to this step
Search query
Inspect the intermediate artifact
Preserve the failure and return to this step
Filtered result
Run the focused check
Preserve the failure and return to this step
Clear to recover
Record the observed result
Preserve the failure and return to this step
Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.
Evidence and limits
Reading List contains corresponding checks; a full accessibility audit is a separate task.
This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The
series evidence record
separates executed checks from exercises and blocked environments.
Inspect the source used in this lesson
.
Recorded sample check
On Xcode 26.6 with an iOS 26.5 simulator, the Reading List verification script passed 3 unit tests and 2 UI tests. It exported six screenshots. This image is one of those outputs, not an AI-generated mockup. The sample uses JSON persistence and does not establish all-client app generation or a complete accessibility audit.
Related reading
SwiftUI: review state transitions before polishing the screen
Make loading, empty, success and error states part of the implementation brief.Five checks for an AI-built SwiftUI app
Use persistence, search and accessible states to make “it works” a testable claim.
What to do next
Next: Networking and Codable with an agent: the concurrency mistakes to catch
---
# Networking and Codable with an agent: the concurrency…
https://nagarjuna2997.github.io/ios-agent-skill/series/3.3.html
← Guided series
·
All articlesLesson 3.3 · Guided learningNetworking and Codable with an agent: the concurrency mistakes to catchOn this page
Work through the example
Implementation reference
URLSession Async/Await Patterns
Acceptance and failure review
Evidence and limits
Related reading
Networking introduces transport failure, decoding failure, cancellation and stale results. A successful response path alone cannot explain what the screen does after a request is replaced or cancelled.
Request → Validate response → Decode model → Publish current result1
Request
↓2
Validate response
↓3
Decode model
↓4
Publish current result
Work through the example
Inject a network service and exercise a delayed response, malformed payload and cancellation. Keep UI mutations on the intended actor.
Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.
Implementation reference
The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.
URLSession Async/Await Patterns
// GET request
func fetchUsers() async throws -> [User] {
let url = URL(string: "https://api.example.com/users")!
let (data, response) = try await URLSession.shared.data(from: url)
guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else {
throw APIError.badResponse
}
return try JSONDecoder().decode([User].self, from: data)
}
// POST request
func createUser(_ user: CreateUserRequest) async throws -> User {
var request = URLRequest(url: URL(string: "https://api.example.com/users")!)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONEncoder().encode(user)
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse, http.statusCode == 201 else {
throw APIError.badResponse
}
return try JSONDecoder().decode(User.self, from: data)
}
// Download with progress using AsyncBytes
func downloadWithProgress(from url: URL) async throws -> Data {
let (bytes, response) = try await URLSession.shared.bytes(from: url)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
throw APIError.badResponse
}
let totalBytes = Int(http.expectedContentLength)
var data = Data(capacity: totalBytes)
for try await byte in bytes {
data.append(byte)
let progress = Double(data.count) / Double(totalBytes)
await MainActor.run { self.downloadProgress = progress }
}
return data
}
Acceptance and failure review
Checkpoint
What to inspect
If it does not match
Request
Confirm the input and environment
Preserve the failure and return to this step
Validate response
Inspect the intermediate artifact
Preserve the failure and return to this step
Decode model
Run the focused check
Preserve the failure and return to this step
Publish current result
Record the observed result
Preserve the failure and return to this step
Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.
Evidence and limits
This chapter's networking exercise is not a new recorded network integration test.
This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The
series evidence record
separates executed checks from exercises and blocked environments.
Inspect the source used in this lesson
.
Related reading
How can I catch Swift concurrency review findings before accepting an agent’s changes?
A real before-and-after MCP review with file and line evidence, plus the limits of text-based concurrency checks.
What to do next
Next: Navigation in SwiftUI: what agents get wrong with NavigationStack
---
# Navigation in SwiftUI: what agents get wrong with…
https://nagarjuna2997.github.io/ios-agent-skill/series/3.4.html
← Guided series
·
All articlesLesson 3.4 · Guided learningNavigation in SwiftUI: what agents get wrong with NavigationStackOn this page
Work through the example
Implementation reference
NavigationStack
Value-Based NavigationLink with .navigationDestination
NavigationPath (Programmatic Navigation)
Typed path (homogeneous)
Acceptance and failure review
Evidence and limits
Related reading
Navigation state should identify destinations rather than duplicate entire mutable models. A deep link, restored route and tapped row should converge on a coherent destination model.
User action or URL → Validated route → Navigation state → Destination1
User action or URL
↓2
Validated route
↓3
Navigation state
↓4
Destination
Work through the example
Describe what happens when the requested item no longer exists. Test back navigation after search and restoration.
Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.
Implementation reference
The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.
NavigationStack
The primary navigation container (iOS 16+). Replaces the deprecated
NavigationView
.
struct ContentView: View {
var body: some View {
NavigationStack {
List(items) { item in
NavigationLink(item.title) {
DetailView(item: item)
}
}
.navigationTitle("Items")
.navigationBarTitleDisplayMode(.large) // .inline, .large, .automatic
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button("Add", systemImage: "plus") { addItem() }
}
}
}
}
}
Value-Based NavigationLink with .navigationDestination
The preferred pattern -- decouples the link from its destination.
struct ContentView: View {
var body: some View {
NavigationStack {
List {
NavigationLink("Show Profile", value: Route.profile("user-123"))
NavigationLink("Settings", value: Route.settings)
ForEach(items) { item in
NavigationLink(value: item) {
ItemRow(item: item)
}
}
}
.navigationDestination(for: Item.self) { item in
ItemDetailView(item: item)
}
.navigationDestination(for: Route.self) { route in
switch route {
case .profile(let id):
ProfileView(userId: id)
case .settings:
SettingsView()
}
}
}
}
}
enum Route: Hashable {
case profile(String)
case settings
case detail(Item)
}
NavigationPath (Programmatic Navigation)
A type-erased path that supports heterogeneous value types.
@Observable
class Router {
var path = NavigationPath()
func goToProfile(_ id: String) {
path.append(Route.profile(id))
}
func goToDetail(_ item: Item) {
path.append(item)
}
func popToRoot() {
path = NavigationPath()
}
func pop() {
if !path.isEmpty {
path.removeLast()
}
}
}
struct AppView: View {
@State private var router = Router()
var body: some View {
NavigationStack(path: $router.path) {
HomeView()
.navigationDestination(for: Route.self) { route in
routeView(for: route)
}
.navigationDestination(for: Item.self) { item in
ItemDetailView(item: item)
}
}
.environment(router)
}
@ViewBuilder
func routeView(for route: Route) -> some View {
switch route {
case .profile(let id): ProfileView(userId: id)
case .settings: SettingsView()
case .detail(let item): ItemDetailView(item: item)
}
}
}
// Deep push from anywhere
struct SomeChildView: View {
@Environment(Router.self) private var router
var body: some View {
Button("Go to Profile") {
router.goToProfile("user-456")
}
}
}
Typed path (homogeneous)
@State private var path: [Item] = []
NavigationStack(path: $path) {
List(items) { item in
NavigationLink(value: item) { Text(item.title) }
}
.navigationDestination(for: Item.self) { item in
DetailView(item: item)
}
}
Acceptance and failure review
Checkpoint
What to inspect
If it does not match
User action or URL
Confirm the input and environment
Preserve the failure and return to this step
Validated route
Inspect the intermediate artifact
Preserve the failure and return to this step
Navigation state
Run the focused check
Preserve the failure and return to this step
Destination
Record the observed result
Preserve the failure and return to this step
Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.
Evidence and limits
The source guide supplies patterns; no blanket claim is made that a navigation review catches every routing bug.
This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The
series evidence record
separates executed checks from exercises and blocked environments.
Inspect the source used in this lesson
.
Related reading
SwiftUI: review state transitions before polishing the screen
Make loading, empty, success and error states part of the implementation brief.
What to do next
Next: Adding widgets with App Intents: a timeline and verification plan
---
# Adding widgets with App Intents: a timeline and…
https://nagarjuna2997.github.io/ios-agent-skill/series/3.5.html
← Guided series
·
All articlesLesson 3.5 · Guided learningAdding widgets with App Intents: a timeline and verification planOn this page
Work through the example
Implementation reference
TimelineProvider (Placeholder, Snapshot, Timeline)
Acceptance and failure review
Evidence and limits
Related reading
A widget reads a constrained snapshot of app state and updates through its timeline and supported interactions. It is not the full app view running indefinitely in the background.
Shared snapshot → Timeline entry → Widget view → Intent-triggered change1
Shared snapshot
↓2
Timeline entry
↓3
Widget view
↓4
Intent-triggered change
Work through the example
Separate storage shared with the app from preview fixtures. Validate size families and empty data before adding styling.
Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.
Implementation reference
The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.
TimelineProvider (Placeholder, Snapshot, Timeline)
struct SimpleEntry: TimelineEntry {
let date: Date
let title: String
let value: Int
let icon: String
}
struct SimpleProvider: TimelineProvider {
// Shown while widget is loading. Must return synchronously.
func placeholder(in context: Context) -> SimpleEntry {
SimpleEntry(date: .now, title: "Loading...", value: 0, icon: "star")
}
// Shown in the widget gallery and transient situations.
func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> Void) {
if context.isPreview {
// Return sample data for the gallery preview
completion(SimpleEntry(date: .now, title: "Steps Today", value: 8432, icon: "figure.walk"))
} else {
// Fetch real data for transient display
let entry = SimpleEntry(date: .now, title: "Steps Today", value: fetchStepCount(), icon: "figure.walk")
completion(entry)
}
}
// Provides the timeline of entries that drive the widget's display.
func getTimeline(in context: Context, completion: @escaping (Timeline<SimpleEntry>) -> Void) {
var entries: [SimpleEntry] = []
let currentDate = Date()
// Create entries for the next 5 hours
for hourOffset in 0..<5 {
let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)!
let entry = SimpleEntry(
date: entryDate,
title: "Steps Today",
value: fetchStepCount() + (hourOffset * 500),
icon: "figure.walk"
)
entries.append(entry)
}
// Timeline reload policies:
// .atEnd - reload after the last entry's date passes
// .after(d) - reload after a specific date
// .never - only reload when the app explicitly requests it
let timeline = Timeline(entries: entries, policy: .atEnd)
completion(timeline)
}
private func fetchStepCount() -> Int { return 8432 }
}
// Async provider using AppIntentTimelineProvider (cleaner async/await API)
struct ConfigurableProvider: AppIntentTimelineProvider {
typealias Entry = SimpleEntry
typealias Intent = SelectCategoryIntent
func placeholder(in context: Context) -> SimpleEntry {
SimpleEntry(date: .now, title: "Loading...", value: 0, icon: "star")
}
func snapshot(for configuration: SelectCategoryIntent, in context: Context) async -> SimpleEntry {
SimpleEntry(date: .now, title: configuration.category?.name ?? "All", value: 42, icon: "star")
}
func timeline(for configuration: SelectCategoryIntent, in context: Context) async -> Timeline<SimpleEntry> {
let entries = [
SimpleEntry(
date: .now,
title: configuration.category?.name ?? "All",
value: 42,
icon: "star"
)
]
return Timeline(entries: entries, policy: .after(.now.addingTimeInterval(3600)))
}
}
Acceptance and failure review
Checkpoint
What to inspect
If it does not match
Shared snapshot
Confirm the input and environment
Preserve the failure and return to this step
Timeline entry
Inspect the intermediate artifact
Preserve the failure and return to this step
Widget view
Run the focused check
Preserve the failure and return to this step
Intent-triggered change
Record the observed result
Preserve the failure and return to this step
Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.
Evidence and limits
The installed Xcode 26.6 cannot verify iOS 27-only widget styling; that lab is blocked.
This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The
series evidence record
separates executed checks from exercises and blocked environments.
Inspect the source used in this lesson
.
Related reading
How should I review availability guards when moving a Swift app toward iOS 27?
A measured guard-review example that separates SDK availability, runtime readiness, and static-analysis limits.SwiftUI: review state transitions before polishing the screen
Make loading, empty, success and error states part of the implementation brief.
What to do next
Next: Designing Siri actions with App Intents and testable domain operations
---
# Designing Siri actions with App Intents and testable…
https://nagarjuna2997.github.io/ios-agent-skill/series/3.6.html
← Guided series
·
All articlesLesson 3.6 · Guided learningDesigning Siri actions with App Intents and testable domain operationsOn this page
Work through the example
Implementation reference
AppIntent Protocol and perform()
Acceptance and failure review
Evidence and limits
Related reading
An app intent exposes a meaningful action with parameters and a result. Start from the user action and entity identity, then validate the declarations against the installed SDK.
User request → Intent parameters → Domain operation → Result and presentation1
User request
↓2
Intent parameters
↓3
Domain operation
↓4
Result and presentation
Work through the example
Keep the domain operation testable outside the intent. Test missing entities and permission denial rather than assuming invocation succeeds.
Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.
Implementation reference
The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.
AppIntent Protocol and perform()
Every App Intent conforms to the
AppIntent
protocol and implements a
perform()
method that returns an
IntentResult
.
import AppIntents
struct OpenArticleIntent: AppIntent {
// Title shown in Shortcuts and Siri
static var title: LocalizedStringResource = "Open Article"
static var description: IntentDescription = "Opens a specific article in the app."
// The system can open your app when this intent runs
static var openAppWhenRun: Bool = true
@Parameter(title: "Article Name")
var articleName: String
@MainActor
func perform() async throws -> some IntentResult & ProvidesDialog {
// Look up the article and navigate to it
guard let article = ArticleStore.shared.find(byName: articleName) else {
throw ArticleError.notFound(articleName)
}
NavigationManager.shared.navigate(to: article)
return .result(dialog: "Opening \"\(article.title)\"")
}
}
enum ArticleError: Error, CustomLocalizedStringResourceConvertible {
case notFound(String)
var localizedStringResource: LocalizedStringResource {
switch self {
case .notFound(let name):
return "Could not find article \"\(name)\""
}
}
}
Acceptance and failure review
Checkpoint
What to inspect
If it does not match
User request
Confirm the input and environment
Preserve the failure and return to this step
Intent parameters
Inspect the intermediate artifact
Preserve the failure and return to this step
Domain operation
Run the focused check
Preserve the failure and return to this step
Result and presentation
Record the observed result
Preserve the failure and return to this step
Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.
Evidence and limits
Do not infer Siri behavior from a static review or claim new SDK deprecations without checking Apple documentation.
This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The
series evidence record
separates executed checks from exercises and blocked environments.
Inspect the source used in this lesson
.
Related reading
How should I review availability guards when moving a Swift app toward iOS 27?
A measured guard-review example that separates SDK availability, runtime readiness, and static-analysis limits.Review AI-generated Swift before you trust it
A focused review, a small patch and a real test beat a confident completion message.
What to do next
Next: On-device AI: Foundation Models readiness and fallback design
---
# On-device AI: Foundation Models readiness and fallback…
https://nagarjuna2997.github.io/ios-agent-skill/series/3.7.html
← Guided series
·
All articlesLesson 3.7 · Guided learningOn-device AI: Foundation Models readiness and fallback designOn this page
Work through the example
Implementation reference
10. Availability
Acceptance and failure review
Evidence and limits
Related reading
Model-backed features need both an API availability check and a runtime readiness decision. A deployment guard alone does not establish that a model is usable on the current device.
SDK guard → Runtime readiness → Model session → Useful fallback1
SDK guard
↓2
Runtime readiness
↓3
Model session
↓4
Useful fallback
Work through the example
Define the non-AI path first. Keep tool arguments validated and distinguish generated output from app-owned facts.
Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.
Implementation reference
The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.
10. Availability
Two versions are in play. Do not collapse them.
// Baseline framework + on-device model.
@available(iOS 26.0, macOS 26.0, *)
// PCC model, Dynamic Profiles, image attachments, custom LanguageModel providers.
@available(iOS 27.0, *)
func makeSession() -> LanguageModelSession? {
if #available(iOS 27.0, *) {
return LanguageModelSession(profile: CookingProfile(state: state))
} else if #available(iOS 26.0, *) {
return LanguageModelSession(instructions: fallbackInstructions)
} else {
return nil // feature hidden entirely below iOS 26
}
}
An app supporting iOS 17+ (this skill's baseline) must treat every Foundation
Models feature as additive. The non-AI path is the product; the AI path is an
enhancement.
Acceptance and failure review
Checkpoint
What to inspect
If it does not match
SDK guard
Confirm the input and environment
Preserve the failure and return to this step
Runtime readiness
Inspect the intermediate artifact
Preserve the failure and return to this step
Model session
Run the focused check
Preserve the failure and return to this step
Useful fallback
Record the observed result
Preserve the failure and return to this step
Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.
Evidence and limits
A simulator or static sample cannot establish every supported physical-device model configuration.
This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The
series evidence record
separates executed checks from exercises and blocked environments.
Inspect the source used in this lesson
.
Related reading
How should I review availability guards when moving a Swift app toward iOS 27?
A measured guard-review example that separates SDK availability, runtime readiness, and static-analysis limits.
What to do next
Next: Notifications, background tasks and the permissions agents forget
---
# Notifications, background tasks and the permissions…
https://nagarjuna2997.github.io/ios-agent-skill/series/3.8.html
← Guided series
·
All articlesLesson 3.8 · Guided learningNotifications, background tasks and the permissions agents forgetOn this page
Work through the example
Implementation reference
BGTaskScheduler Registration
Acceptance and failure review
Evidence and limits
Related reading
Background execution is a system-granted opportunity, not an unlimited timer. Permissions and scheduling behavior belong in the acceptance plan before an agent writes the happy path.
User permission → Register capability → Schedule work → Handle expiration1
User permission
↓2
Register capability
↓3
Schedule work
↓4
Handle expiration
Work through the example
Test cancellation and expiration through a controlled seam. Make the foreground app explain the last known state instead of promising exact execution time.
Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.
Implementation reference
The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.
BGTaskScheduler Registration
Register task identifiers in
Info.plist
under
BGTaskSchedulerPermittedIdentifiers
:
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>com.yourapp.refresh</string>
<string>com.yourapp.db-cleanup</string>
<string>com.yourapp.sync</string>
</array>
Register handlers at app launch (before the end of the first
applicationDidFinishLaunching
call or in the
@main
App
init
):
import BackgroundTasks
@main
struct MyApp: App {
init() {
registerBackgroundTasks()
}
var body: some Scene {
WindowGroup {
ContentView()
}
}
private func registerBackgroundTasks() {
BGTaskScheduler.shared.register(
forTaskWithIdentifier: "com.yourapp.refresh",
using: nil // nil = main queue
) { task in
guard let task = task as? BGAppRefreshTask else { return }
handleAppRefresh(task: task)
}
BGTaskScheduler.shared.register(
forTaskWithIdentifier: "com.yourapp.db-cleanup",
using: nil
) { task in
guard let task = task as? BGProcessingTask else { return }
handleDatabaseCleanup(task: task)
}
BGTaskScheduler.shared.register(
forTaskWithIdentifier: "com.yourapp.sync",
using: nil
) { task in
guard let task = task as? BGProcessingTask else { return }
handleSync(task: task)
}
}
}
Acceptance and failure review
Checkpoint
What to inspect
If it does not match
User permission
Confirm the input and environment
Preserve the failure and return to this step
Register capability
Inspect the intermediate artifact
Preserve the failure and return to this step
Schedule work
Run the focused check
Preserve the failure and return to this step
Handle expiration
Record the observed result
Preserve the failure and return to this step
Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.
Evidence and limits
A source review cannot prove that the operating system will schedule a task at a specific time.
This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The
series evidence record
separates executed checks from exercises and blocked environments.
Inspect the source used in this lesson
.
Related reading
How should I review availability guards when moving a Swift app toward iOS 27?
A measured guard-review example that separates SDK availability, runtime readiness, and static-analysis limits.Review AI-generated Swift before you trust it
A focused review, a small patch and a real test beat a confident completion message.
What to do next
Next: Deep links and universal links, and using them for automated screenshots
---
# Deep links and universal links, and using them for…
https://nagarjuna2997.github.io/ios-agent-skill/series/3.9.html
← Guided series
·
All articlesLesson 3.9 · Guided learningDeep links and universal links, and using them for automated screenshotsOn this page
Work through the example
Implementation reference
Routing, Deep Links, and State Restoration
Acceptance and failure review
Evidence and limits
Related reading
A deep link is a reproducible route to app state only when inputs are validated and test data exists. Keep routing separate from parsing and from data loading.
Incoming URL → Validate fields → Resolve route → Render state1
Incoming URL
↓2
Validate fields
↓3
Resolve route
↓4
Render state
Work through the example
Use a synthetic identifier and an explicit not-found state. Do not place account tokens in screenshot URLs.
Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.
Implementation reference
The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.
Routing, Deep Links, and State Restoration
Load this when:
adding navigation to more than two screens, handling a URL
scheme or universal link, restoring navigation state across launches, or
reviewing anything that mutates a
NavigationPath
.
docs/swiftui/navigation.md
covers the navigation
APIs
.
patterns/coordinator.md
covers the coordinator
pattern
.
This document covers the piece that breaks in production: making a single typed
route model the only way navigation state changes, so a deep link, a button tap,
and a restored session all go through the same code.
Acceptance and failure review
Checkpoint
What to inspect
If it does not match
Incoming URL
Confirm the input and environment
Preserve the failure and return to this step
Validate fields
Inspect the intermediate artifact
Preserve the failure and return to this step
Resolve route
Run the focused check
Preserve the failure and return to this step
Render state
Record the observed result
Preserve the failure and return to this step
Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.
Evidence and limits
Custom URL routing and universal-link association are different checks; one does not validate the other.
This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The
series evidence record
separates executed checks from exercises and blocked environments.
Inspect the source used in this lesson
.
Related reading
Five checks for an AI-built SwiftUI app
Use persistence, search and accessible states to make “it works” a testable claim.
What to do next
Next: Localization with an agent: what to check before it "translates" everything
---
# App icons in 2026: Liquid Glass, Icon Composer, and…
https://nagarjuna2997.github.io/ios-agent-skill/series/4.1.html
← Guided series
·
All articlesLesson 4.1 · Guided learningApp icons in 2026: Liquid Glass, Icon Composer, and generating them from SVG layersOn this page
Work through the example
Implementation reference
Pattern
Make the layer plan first
Compose and annotate
Integrate with the app
What an agent must report
Acceptance and failure review
Evidence and limits
Related reading
Editable icon layers and a flattened PNG solve different needs. Keep the layers as source, inspect the final silhouette at small sizes and validate the catalog consumed by the app.
Layer sources → Composite preview → Catalog asset → Device inspection1
Layer sources
↓2
Composite preview
↓3
Catalog asset
↓4
Device inspection
Work through the example
Separate a generated placeholder from a release icon. Review transparency, safe composition and the installed toolchain's accepted format.
Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.
Implementation reference
The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.
Pattern
Make the layer plan first
Define the app’s recognizable symbol and a single visual idea. Separate the background, supporting shape, primary symbol, and optional accent. Use semantic names and stable ordering. For example, a reading app can use a background fill, a book silhouette, a page mark, and a small bookmark accent; each foreground shape remains editable independently.
Prefer clean vector foregrounds. Use SVG or transparent PNG assets for import, exported on the same canvas so positions remain aligned. Keep text outlined. For raster art, retain transparent backgrounds around foreground artwork. Keep imported background art opaque and full bleed. Do not rasterize all pieces into one image.
A useful project handoff contains:
IconLayers/
01-base.svg
02-symbol.svg
03-accent.svg
manifest.json
README.md
These filenames are a recommended project convention, not an Apple file-format requirement. The CLI’s
--xcodegen
starter creates editable layers as a starting point; replace their shapes and colors for the actual brand.
Compose and annotate
Launch Icon Composer from Xcode’s developer tools menu or the standalone app. Start a new document, choose the supported platforms, and set its background fill. Import foreground artwork, organize it into no more than four groups, and arrange depth from back to front. Use its material controls for highlights, refraction, translucency, and shadow rather than painting those effects into every asset.
Tune Default, Dark, and Mono appearances. Inspect at small sizes and against different surrounding backgrounds. Keep the silhouette recognizable when decorative detail disappears. Save the native
.icon
document, reopen it, and check that its layers and appearance settings remain editable.
Integrate with the app
Add the saved icon document to the Xcode project and associate it with the app target’s icon setting. Build with the actual SDK and inspect the installed icon. Use Apple’s asset-catalog image-stack workflow for platforms whose icon format differs, including tvOS and visionOS; do not assume the same Composer workflow applies to every platform.
Export flattened images only for marketing, previews, or compatibility workflows that specifically require them. Retain the editable source and native document alongside those exports.
What an agent must report
List the artwork files, layer ordering, appearance variants checked, native document path if created, and Xcode verification performed. If Icon Composer is unavailable, deliver the SVG/PNG layer pack plus import instructions and explicitly leave native
.icon
verification pending. Do not invent an undocumented
.icon
schema or rename a JSON file to make it appear native.
Acceptance and failure review
Checkpoint
What to inspect
If it does not match
Layer sources
Confirm the input and environment
Preserve the failure and return to this step
Composite preview
Inspect the intermediate artifact
Preserve the failure and return to this step
Catalog asset
Run the focused check
Preserve the failure and return to this step
Device inspection
Record the observed result
Preserve the failure and return to this step
Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.
Evidence and limits
A PNG export is not automatically a native Icon Composer bundle or App Review approval.
This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The
series evidence record
separates executed checks from exercises and blocked environments.
Inspect the source used in this lesson
.
Related reading
Generate light and dark asset catalogs without a paid design tool
Turn explicit color tokens into Assets.xcassets, then inspect the result in your app.How can design tokens generate light, dark and high-contrast color assets?
Generate a real xcassets catalog from explicit color tokens, inspect its four variants, and compile it with actool.
What to do next
Next: Color systems that survive dark mode and high contrast, generated from tokens
---
# Color systems that survive dark mode and high contrast,…
https://nagarjuna2997.github.io/ios-agent-skill/series/4.2.html
← Guided series
·
All articlesLesson 4.2 · Guided learningColor systems that survive dark mode and high contrast, generated from tokensOn this page
Work through the example
Implementation reference
Evidence and limits
Acceptance and failure review
Evidence and limits
Related reading
A token transform can be deterministic even when color choices need human judgment. Supply explicit light, dark and high-contrast values and inspect the generated catalog before merging it.
Explicit tokens → Validate names → Generate catalog → Compile and inspect1
Explicit tokens
↓2
Validate names
↓3
Generate catalog
↓4
Compile and inspect
Work through the example
Use a new output directory because generation refuses to overwrite an existing catalog. Compare all four appearances.
Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.
Implementation reference
The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.
Evidence and limits
The CLI tests verify schema rejection, appearance slots, alpha conversion,
non-overwrite behavior, ordered raster pixels, RGB output and 1024×1024 dimensions.
On macOS, generated colors and the iOS app-icon set were compiled using
xcrun
actool
against the installed simulator SDK. This does not verify a native
.icon
,
a full screenshot capture pipeline, symbol availability or visual accessibility.
Catalog format:
Apple named colors
and
appearance variants
.
Acceptance and failure review
Checkpoint
What to inspect
If it does not match
Explicit tokens
Confirm the input and environment
Preserve the failure and return to this step
Validate names
Inspect the intermediate artifact
Preserve the failure and return to this step
Generate catalog
Run the focused check
Preserve the failure and return to this step
Compile and inspect
Record the observed result
Preserve the failure and return to this step
Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.
Evidence and limits
The existing asset walkthrough includes actool evidence; it does not establish contrast in every screen.
This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The
series evidence record
separates executed checks from exercises and blocked environments.
Inspect the source used in this lesson
.
Related reading
How can design tokens generate light, dark and high-contrast color assets?
Generate a real xcassets catalog from explicit color tokens, inspect its four variants, and compile it with actool.Generate light and dark asset catalogs without a paid design tool
Turn explicit color tokens into Assets.xcassets, then inspect the result in your app.
What to do next
Next: Typography in SwiftUI that respects Dynamic Type: rules an agent can follow
---
# Typography in SwiftUI that respects Dynamic Type: rules…
https://nagarjuna2997.github.io/ios-agent-skill/series/4.3.html
← Guided series
·
All articlesLesson 4.3 · Guided learningTypography in SwiftUI that respects Dynamic Type: rules an agent can followOn this page
Work through the example
Implementation reference
5. Dynamic Type Support
@ScaledMetric
minimumScaleFactor
Acceptance and failure review
Evidence and limits
Related reading
Dynamic Type is a layout requirement as much as a font choice. A scalable font inside a fixed-height container can still clip at accessibility sizes.
Semantic text style → Scaled content → Flexible layout → Largest-size check1
Semantic text style
↓2
Scaled content
↓3
Flexible layout
↓4
Largest-size check
Work through the example
Test the longest label at the largest supported text setting. Prefer wrapping or a changed layout to shrinking meaningful text.
Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.
Implementation reference
The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.
5. Dynamic Type Support
@ScaledMetric
Scale arbitrary numeric values proportionally to the user's Dynamic Type setting.
struct ScaledMetricDemo: View {
@ScaledMetric(relativeTo: .title) var iconSize: CGFloat = 28
@ScaledMetric(relativeTo: .body) var spacing: CGFloat = 12
@ScaledMetric(relativeTo: .body) var cardPadding: CGFloat = 16
var body: some View {
HStack(spacing: spacing) {
Image(systemName: "person.circle.fill")
.font(.system(size: iconSize))
.foregroundStyle(.blue)
VStack(alignment: .leading, spacing: 4) {
Text("John Appleseed")
.font(.headline)
Text("iOS Developer")
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
.padding(cardPadding)
.background(Color(.secondarySystemBackground))
.cornerRadius(16)
}
}
minimumScaleFactor
Prevent text from being clipped while still supporting Dynamic Type.
struct ScaleFactorDemo: View {
var body: some View {
Text("This very long title will shrink instead of truncating")
.font(.title)
.minimumScaleFactor(0.5)
.lineLimit(1)
.padding()
}
}
Acceptance and failure review
Checkpoint
What to inspect
If it does not match
Semantic text style
Confirm the input and environment
Preserve the failure and return to this step
Scaled content
Inspect the intermediate artifact
Preserve the failure and return to this step
Flexible layout
Run the focused check
Preserve the failure and return to this step
Largest-size check
Record the observed result
Preserve the failure and return to this step
Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.
Evidence and limits
Source examples are guidance; screenshots and VoiceOver checks must come from the actual app.
This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The
series evidence record
separates executed checks from exercises and blocked environments.
Inspect the source used in this lesson
.
Related reading
Five checks for an AI-built SwiftUI app
Use persistence, search and accessible states to make “it works” a testable claim.
What to do next
Next: Motion and haptics: what to ask an agent for, and what reduced-motion users need
---
# Motion and haptics: what to ask an agent for, and what…
https://nagarjuna2997.github.io/ios-agent-skill/series/4.4.html
← Guided series
·
All articlesLesson 4.4 · Guided learningMotion and haptics: what to ask an agent for, and what reduced-motion users needOn this page
Work through the example
Implementation reference
1. Animation Standards
Default Curves and Durations
When to Use Which Animation
Transition Standards
Acceptance and failure review
Evidence and limits
Related reading
Motion should explain a state change, and haptics should reinforce a meaningful event. Reduced-motion settings require a deliberate alternative rather than disabling unrelated feedback.
User action → State change → Motion preference → Appropriate feedback1
User action
↓2
State change
↓3
Motion preference
↓4
Appropriate feedback
Work through the example
Compare the same interaction with reduced motion enabled. Preserve the result and focus order even if the transition changes.
Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.
Implementation reference
The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.
1. Animation Standards
Default Curves and Durations
Category
Duration
Curve
Usage
Micro interaction
0.2s
.easeOut
Toggles, button presses, icon changes
Navigation transition
0.35s
.spring(response: 0.35, dampingFraction: 0.85)
Push, pop, tab switches
Content loading
0.3s
.easeInOut
Skeleton to content, fade-in
Dismissal
0.25s
.easeIn
Sheet dismiss, alert close, toast exit
Bouncy spring
--
.bouncy
Playful UI: reactions, badges, celebrations
Snappy spring
--
.snappy
Responsive controls: sliders, toggles
Smooth spring
--
.smooth
Elegant reveals: cards, overlays
When to Use Which Animation
Use Case
Animation
Rationale
Button tap feedback
.easeOut, 0.2s
Quick acknowledgment, no lingering
Toggle switch
.snappy
Responsive mechanical feel
Card expand/collapse
.spring(response: 0.35, dampingFraction: 0.85)
Natural, physical motion
Pull-to-refresh
.bouncy
Playful rubber-band feel
Modal presentation
.smooth
Elegant, unhurried entrance
Error shake
.default.repeatCount(3)
Attention-grabbing without being jarring
Skeleton shimmer
.easeInOut, 1.2s, repeat
Smooth continuous loop
Item deletion
.easeIn, 0.25s
Quick exit, attention moves forward
List reorder
.snappy
Keeps up with the finger
Hero transition
matchedGeometryEffect
Spatial continuity between screens
Transition Standards
// MARK: - Sheet Presentation (use system default)
.sheet(isPresented: $showSettings) {
SettingsView()
}
// MARK: - Full Screen Cover with Custom Transition
.fullScreenCover(isPresented: $showOnboarding) {
OnboardingView()
.transition(.opacity.combined(with: .move(edge: .bottom)))
}
// MARK: - Navigation Push (system default)
NavigationStack {
List(items) { item in
NavigationLink(value: item) {
ItemRow(item: item)
}
}
.navigationDestination(for: Item.self) { item in
ItemDetailView(item: item)
}
}
// MARK: - Hero Transition with matchedGeometryEffect
struct HeroTransitionExample: View {
@Namespace private var heroNamespace
@State private var isExpanded = false
var body: some View {
if isExpanded {
DetailCard(namespace: heroNamespace)
.onTapGesture {
withAnimation(.spring(response: 0.35, dampingFraction: 0.85)) {
isExpanded = false
}
}
} else {
ThumbnailCard(namespace: heroNamespace)
.onTapGesture {
withAnimation(.spring(response: 0.35, dampingFraction: 0.85)) {
isExpanded = true
}
}
}
}
}
struct ThumbnailCard: View {
var namespace: Namespace.ID
var body: some View {
RoundedRectangle(cornerRadius: 12)
.fill(.blue.gradient)
.matchedGeometryEffect(id: "card", in: namespace)
.frame(width: 120, height: 120)
.overlay {
Text("Tap")
.matchedGeometryEffect(id: "title", in: namespace)
}
}
}
struct DetailCard: View {
var namespace: Namespace.ID
var body: some View {
RoundedRectangle(cornerRadius: 24)
.fill(.blue.gradient)
.matchedGeometryEffect(id: "card", in: namespace)
.frame(maxWidth: .infinity, maxHeight: 400)
.overlay {
Text("Detail View")
.matchedGeometryEffect(id: "title", in: namespace)
}
.padding()
}
}
// MARK: - Custom Asymmetric Transition
extension AnyTransition {
static var slideAndFade: AnyTransition {
.asymmetric(
insertion: .move(edge: .trailing).combined(with: .opacity),
removal: .move(edge: .leading).combined(with: .opacity)
)
}
}
// Usage:
struct CustomTransitionExample: View {
@State private var showContent = false
var body: some View {
VStack {
if showContent {
ContentView()
.transition(.slideAndFade)
}
Button("Toggle") {
withAnimation(.easeInOut(duration: 0.3)) {
showContent.toggle()
}
}
}
}
}
// MARK: - Phased Animation for Multi-Step Effects
struct PhasedAnimationExample: View {
@State private var trigger = false
var body: some View {
Image(systemName: "bell.fill")
.font(.system(size: 32))
.phaseAnimator([false, true], trigger: trigger) { content, phase in
content
.scaleEffect(phase ? 1.2 : 1.0)
.rotationEffect(.degrees(phase ? 15 : 0))
} animation: { phase in
phase ? .bouncy : .snappy
}
.onTapGesture { trigger.toggle() }
}
}
Acceptance and failure review
Checkpoint
What to inspect
If it does not match
User action
Confirm the input and environment
Preserve the failure and return to this step
State change
Inspect the intermediate artifact
Preserve the failure and return to this step
Motion preference
Run the focused check
Preserve the failure and return to this step
Appropriate feedback
Record the observed result
Preserve the failure and return to this step
Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.
Evidence and limits
No physical-device haptic evaluation is included in the simulator baseline.
This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The
series evidence record
separates executed checks from exercises and blocked environments.
Inspect the source used in this lesson
.
Related reading
SwiftUI: review state transitions before polishing the screen
Make loading, empty, success and error states part of the implementation brief.Five checks for an AI-built SwiftUI app
Use persistence, search and accessible states to make “it works” a testable claim.
What to do next
Next: Making an agent-built app look intentional, not templated
---
# Making an agent-built app look intentional, not templated
https://nagarjuna2997.github.io/ios-agent-skill/series/4.5.html
← Guided series
·
All articlesLesson 4.5 · Guided learningMaking an agent-built app look intentional, not templatedOn this page
Work through the example
Implementation reference
Review Order
Acceptance and failure review
Evidence and limits
Related reading
An intentional interface has a clear hierarchy, stable spacing and coherent states. Adding gradients to an unresolved flow makes it decorated rather than understandable.
Task hierarchy → Content and controls → Tokens and spacing → State review1
Task hierarchy
↓2
Content and controls
↓3
Tokens and spacing
↓4
State review
Work through the example
Choose one primary action per screen and explain where secondary actions belong. Compare empty and content states together.
Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.
Implementation reference
The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.
Review Order
Primary task: can the user see what to do in two seconds?
Hierarchy: do size, weight, color, and position match importance?
Layout: are alignment, rhythm, gutters, and touch targets consistent?
Type: semantic styles first; fixed sizes only with a clear reason.
State: loading, empty, error, success, offline, disabled.
Adaptation: compact/regular widths, iPad resizability, keyboard, pointer.
Accessibility: VoiceOver labels, focus order, Dynamic Type, contrast, Reduce Motion.
Acceptance and failure review
Checkpoint
What to inspect
If it does not match
Task hierarchy
Confirm the input and environment
Preserve the failure and return to this step
Content and controls
Inspect the intermediate artifact
Preserve the failure and return to this step
Tokens and spacing
Run the focused check
Preserve the failure and return to this step
State review
Record the observed result
Preserve the failure and return to this step
Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.
Evidence and limits
The pattern library is a starting point, not evidence that a particular redesign improved usability.
This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The
series evidence record
separates executed checks from exercises and blocked environments.
Inspect the source used in this lesson
.
Related reading
SwiftUI: review state transitions before polishing the screen
Make loading, empty, success and error states part of the implementation brief.How can design tokens generate light, dark and high-contrast color assets?
Generate a real xcassets catalog from explicit color tokens, inspect its four variants, and compile it with actool.
What to do next
Next: Preparing repeatable simulator captures for App Store screenshots
---
# Preparing repeatable simulator captures for App Store…
https://nagarjuna2997.github.io/ios-agent-skill/series/4.6.html
← Guided series
·
All articlesLesson 4.6 · Guided learningPreparing repeatable simulator captures for App Store screenshotsOn this page
Work through the example
Implementation reference
Artifact Naming
Acceptance and failure review
Evidence and limits
Related reading
Screenshot delivery needs a repeatable state, device destination and naming scheme. An attractive image at the wrong size or with synthetic content presented as real is not a submission-ready asset.
Select device → Seed safe content → Navigate state → Capture and inspect1
Select device
↓2
Seed safe content
↓3
Navigate state
↓4
Capture and inspect
Work through the example
Record the runtime and dimensions for each output. Verify current App Store display requirements before exporting release media.
Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.
Implementation reference
The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.
Artifact Naming
Use stable paths so iterations can be compared:
artifacts/visual/home-pass-01-compact.png
artifacts/visual/home-pass-01-accessibility-xxl.png
artifacts/visual/home-pass-02-compact.png
artifacts/visual/home-pass-02.mov
When visual diffing is added, compare the same route, device, appearance, locale, and content-size category. A screenshot from a different simulator configuration is not a clean comparison.
Acceptance and failure review
Checkpoint
What to inspect
If it does not match
Select device
Confirm the input and environment
Preserve the failure and return to this step
Seed safe content
Inspect the intermediate artifact
Preserve the failure and return to this step
Navigate state
Run the focused check
Preserve the failure and return to this step
Capture and inspect
Record the observed result
Preserve the failure and return to this step
Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.
Evidence and limits
The baseline screenshot is demonstration evidence, not a complete App Store screenshot set.
This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The
series evidence record
separates executed checks from exercises and blocked environments.
Inspect the source used in this lesson
.
Related reading
Five checks for an AI-built SwiftUI app
Use persistence, search and accessible states to make “it works” a testable claim.
What to do next
Next: From Figma to SwiftUI with an agent: variables to tokens to code
---
# From Figma to SwiftUI with an agent: variables to tokens…
https://nagarjuna2997.github.io/ios-agent-skill/series/4.7.html
← Guided series
·
All articlesLesson 4.7 · Guided learningFrom Figma to SwiftUI with an agent: variables to tokens to codeOn this page
Work through the example
Implementation reference
Optional Figma handoff
Acceptance and failure review
Evidence and limits
Related reading
A design handoff works best when variable meaning survives the transfer. Map design variables to semantic tokens, validate the token schema and inspect the app instead of copying raw colors everywhere.
Design variables → Semantic token mapping → Asset catalog → SwiftUI usage1
Design variables
↓2
Semantic token mapping
↓3
Asset catalog
↓4
SwiftUI usage
Work through the example
Document one variable's role and four appearances. Resolve missing variants explicitly rather than guessing a dark conversion.
Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.
Implementation reference
The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.
Optional Figma handoff
Use Figma's own integration to read variables if you already use it. Map semantic
color names and light/dark/high-contrast modes to the JSON fields above. Resolve
aliases to explicit sRGB hex values first. The same JSON can be authored by hand;
Figma and its MCP are optional. There is no maintained Figma parser here.
Acceptance and failure review
Checkpoint
What to inspect
If it does not match
Design variables
Confirm the input and environment
Preserve the failure and return to this step
Semantic token mapping
Inspect the intermediate artifact
Preserve the failure and return to this step
Asset catalog
Run the focused check
Preserve the failure and return to this step
SwiftUI usage
Record the observed result
Preserve the failure and return to this step
Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.
Evidence and limits
No live Figma import session was run; this is the supported handoff contract, not a custom parser.
This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The
series evidence record
separates executed checks from exercises and blocked environments.
Inspect the source used in this lesson
.
Related reading
Generate light and dark asset catalogs without a paid design tool
Turn explicit color tokens into Assets.xcassets, then inspect the result in your app.How can design tokens generate light, dark and high-contrast color assets?
Generate a real xcassets catalog from explicit color tokens, inspect its four variants, and compile it with actool.
What to do next
Next: Swift Testing vs XCTest: what to have the agent generate in 2026
---
# Swift Testing vs XCTest: what to have the agent generate…
https://nagarjuna2997.github.io/ios-agent-skill/series/5.1.html
← Guided series
·
All articlesLesson 5.1 · Guided learningSwift Testing vs XCTest: what to have the agent generate in 2026On this page
Work through the example
Implementation reference
Unit Testing ViewModels with Mocks
Acceptance and failure review
Choose a test layer before a framework
Evidence and limits
Related reading
Choose test tools by the layer under test. Pure model behavior and UI automation have different lifecycles; migrating names alone does not improve the assertions.
Behavior contract → Test layer → Controlled dependency → Assertion1
Behavior contract
↓2
Test layer
↓3
Controlled dependency
↓4
Assertion
Work through the example
Keep existing XCTest coverage while considering Swift Testing for suitable tests. Do not replace UI automation with a model-level assertion.
Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.
Implementation reference
The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.
Unit Testing ViewModels with Mocks
[ ] Define protocols for all dependencies (repository, service, API client)
[ ] Create mock implementations that allow stubbing return values and tracking calls
[ ] Test each ViewModel method in isolation
[ ] Verify state changes (loading, error, data) after each action
[ ] Test edge cases: empty data, nil values, concurrent calls
// MARK: - Mock Repository
final class MockArticleRepository: ArticleRepositoryProtocol, @unchecked Sendable {
var stubbedArticles: [Article] = []
var shouldFail = false
var createCallCount = 0
var lastCreatedArticle: Article?
func getAll() async throws -> [Article] {
if shouldFail { throw RepositoryError.offline }
return stubbedArticles
}
func getById(_ id: String) async throws -> Article? {
if shouldFail { throw RepositoryError.notFound }
return stubbedArticles.first { $0.id == id }
}
func create(_ entity: Article) async throws -> Article {
if shouldFail { throw RepositoryError.invalidResponse }
createCallCount += 1
lastCreatedArticle = entity
stubbedArticles.append(entity)
return entity
}
func update(_ entity: Article) async throws -> Article {
if shouldFail { throw RepositoryError.invalidResponse }
if let index = stubbedArticles.firstIndex(where: { $0.id == entity.id }) {
stubbedArticles[index] = entity
}
return entity
}
func delete(_ id: String) async throws {
if shouldFail { throw RepositoryError.notFound }
stubbedArticles.removeAll { $0.id == id }
}
}
// MARK: - ViewModel Tests
@Suite("Create Article Flow")
struct CreateArticleViewModelTests {
@Test("creates article and resets form")
func createSuccess() async {
let mock = MockArticleRepository()
let vm = CreateArticleViewModel(repository: mock)
vm.title = "New Article"
vm.body = "Content here"
await vm.save()
#expect(mock.createCallCount == 1)
#expect(mock.lastCreatedArticle?.title == "New Article")
#expect(vm.title.isEmpty) // form reset
#expect(vm.isSaved)
}
@Test("shows validation error when title is empty")
func validationError() async {
let mock = MockArticleRepository()
let vm = CreateArticleViewModel(repository: mock)
vm.title = ""
vm.body = "Content"
await vm.save()
#expect(mock.createCallCount == 0)
#expect(vm.titleError == .emptyField(fieldName: "Title"))
}
}
Acceptance and failure review
Checkpoint
What to inspect
If it does not match
Behavior contract
Confirm the input and environment
Preserve the failure and return to this step
Test layer
Inspect the intermediate artifact
Preserve the failure and return to this step
Controlled dependency
Run the focused check
Preserve the failure and return to this step
Assertion
Record the observed result
Preserve the failure and return to this step
Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.
Choose a test layer before a framework
Apple explains that Swift Testing and XCTest can coexist in a project in its
Swift Testing overview
. Keep UI automation separate from pure model assertions. A storage round-trip can be tested without tapping a screen; a save button’s interaction with the form needs a UI-level check.
The example mock above is source guidance, not a production concurrency guarantee. Its mutable
@unchecked Sendable
state requires externally controlled access; do not use that annotation to bypass races in parallel tests. Prefer isolation that matches the dependency contract and create independent fixtures per test.
Evidence and limits
Apple documents coexistence; no complete project migration is claimed here.
This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The
series evidence record
separates executed checks from exercises and blocked environments.
Inspect the source used in this lesson
.
Related reading
Review AI-generated Swift before you trust it
A focused review, a small patch and a real test beat a confident completion message.How can Claude Code hooks protect generated files and verify a project before stopping?
A tested generated-file guard and Stop check, with configuration, reproducible exit codes, and enforcement limits.
What to do next
Next: UI tests an agent can write and maintain
---
# UI tests an agent can write and maintain
https://nagarjuna2997.github.io/ios-agent-skill/series/5.2.html
← Guided series
·
All articlesLesson 5.2 · Guided learningUI tests an agent can write and maintainOn this page
Work through the example
Implementation reference
Pattern
Acceptance and failure review
Evidence and limits
Recorded sample check
Related reading
A maintainable UI test drives stable identifiers and waits for observable state. Arbitrary sleeps can hide timing assumptions while making the suite slower and less reliable.
Launch isolated state → Perform action → Wait for condition → Assert outcome1
Launch isolated state
↓2
Perform action
↓3
Wait for condition
↓4
Assert outcome
Work through the example
Give test data a separate store and ensure repeated runs do not depend on a previous test's items.
Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.
Implementation reference
The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.
Pattern
Stabilize UI tests with accessibility identifiers at meaningful boundaries:
Button("Continue") {
viewModel.continueTapped()
}
.accessibilityIdentifier("onboarding.continue")
Then test the user path, not the view hierarchy:
import XCTest
final class OnboardingUITests: XCTestCase {
func testContinueOpensHome() {
let app = XCUIApplication()
app.launchArguments = ["--ui-testing", "--mock-state=first-run"]
app.launch()
app.buttons["onboarding.continue"].tap()
XCTAssertTrue(app.navigationBars["Home"].waitForExistence(timeout: 2))
}
}
Acceptance and failure review
Checkpoint
What to inspect
If it does not match
Launch isolated state
Confirm the input and environment
Preserve the failure and return to this step
Perform action
Inspect the intermediate artifact
Preserve the failure and return to this step
Wait for condition
Run the focused check
Preserve the failure and return to this step
Assert outcome
Record the observed result
Preserve the failure and return to this step
Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.
Evidence and limits
The Reading List suite provides concrete UI coverage; it does not prove the absence of all flakes.
This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The
series evidence record
separates executed checks from exercises and blocked environments.
Inspect the source used in this lesson
.
Recorded sample check
On Xcode 26.6 with an iOS 26.5 simulator, the Reading List verification script passed 3 unit tests and 2 UI tests. It exported six screenshots. This image is one of those outputs, not an AI-generated mockup. The sample uses JSON persistence and does not establish all-client app generation or a complete accessibility audit.
Related reading
Five checks for an AI-built SwiftUI app
Use persistence, search and accessible states to make “it works” a testable claim.
What to do next
Next: Accessibility review of an agent-built app: the checks that matter
---
# Accessibility review of an agent-built app: the checks…
https://nagarjuna2997.github.io/ios-agent-skill/series/5.3.html
← Guided series
·
All articlesLesson 5.3 · Guided learningAccessibility review of an agent-built app: the checks that matterOn this page
Work through the example
Implementation reference
VoiceOver Labels, Hints, Values, and Traits
Acceptance and failure review
Evidence and limits
Recorded sample check
Related reading
Accessibility review combines semantics, navigation, contrast and adaptable layout. A screenshot can expose clipping but cannot tell you the full spoken experience.
Semantic labels → Navigation order → Adaptive layout → Device review1
Semantic labels
↓2
Navigation order
↓3
Adaptive layout
↓4
Device review
Work through the example
Check an empty state and an error recovery action with accessibility tooling. Keep identifiers for automation distinct from user-facing labels.
Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.
Implementation reference
The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.
VoiceOver Labels, Hints, Values, and Traits
import SwiftUI
struct AccessibleCardView: View {
let title: String
let rating: Double
let isFavorite: Bool
var body: some View {
VStack {
Image("product")
.resizable()
.aspectRatio(contentMode: .fit)
// Label: concise description of what the element IS (read first)
.accessibilityLabel("Product photo of \(title)")
Text(title)
HStack {
ForEach(1...5, id: \.self) { star in
Image(systemName: star <= Int(rating) ? "star.fill" : "star")
}
}
// Combine child elements into a single accessible element
.accessibilityElement(children: .ignore)
.accessibilityLabel("\(Int(rating)) out of 5 stars")
// Value: current state of a dynamic element
.accessibilityValue("\(rating, specifier: "%.1f") rating")
Button(action: { /* toggle favorite */ }) {
Image(systemName: isFavorite ? "heart.fill" : "heart")
}
.accessibilityLabel(isFavorite ? "Remove from favorites" : "Add to favorites")
// Hint: describes what happens when the element is activated (read after a pause)
.accessibilityHint("Double tap to \(isFavorite ? "remove from" : "add to") favorites")
// Traits: describe the behavior and purpose of the element
.accessibilityAddTraits(.isButton)
.accessibilityRemoveTraits(.isImage)
}
}
}
Acceptance and failure review
Checkpoint
What to inspect
If it does not match
Semantic labels
Confirm the input and environment
Preserve the failure and return to this step
Navigation order
Inspect the intermediate artifact
Preserve the failure and return to this step
Adaptive layout
Run the focused check
Preserve the failure and return to this step
Device review
Record the observed result
Preserve the failure and return to this step
Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.
Evidence and limits
A successful UI test is not a complete VoiceOver audit.
This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The
series evidence record
separates executed checks from exercises and blocked environments.
Inspect the source used in this lesson
.
Recorded sample check
On Xcode 26.6 with an iOS 26.5 simulator, the Reading List verification script passed 3 unit tests and 2 UI tests. It exported six screenshots. This image is one of those outputs, not an AI-generated mockup. The sample uses JSON persistence and does not establish all-client app generation or a complete accessibility audit.
Related reading
Five checks for an AI-built SwiftUI app
Use persistence, search and accessible states to make “it works” a testable claim.SwiftUI: review state transitions before polishing the screen
Make loading, empty, success and error states part of the implementation brief.
What to do next
Next: Performance: hangs, launch time and the patterns agents introduce
---
# Performance: hangs, launch time and the patterns agents…
https://nagarjuna2997.github.io/ios-agent-skill/series/5.4.html
← Guided series
·
All articlesLesson 5.4 · Guided learningPerformance: hangs, launch time and the patterns agents introduceOn this page
Work through the example
Implementation reference
Instruments Profiling
Time Profiler
Allocations
Leaks
Network (Instruments)
Animation Hitches (Core Animation / RenderServer)
Acceptance and failure review
Evidence and limits
Related reading
Performance investigation starts with a reproducible symptom and a representative workload. Static patterns point to possible problems; runtime measurements establish whether the change helps.
Reproduce workload → Record baseline → Change one cause → Compare trace1
Reproduce workload
↓2
Record baseline
↓3
Change one cause
↓4
Compare trace
Work through the example
Separate cold launch, warm launch and interaction responsiveness. Record device conditions alongside measurements.
Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.
Implementation reference
The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.
Instruments Profiling
Time Profiler
[ ] Profile the app with Time Profiler to find CPU hot spots
[ ] Look for methods consuming >10% of total CPU time
[ ] Check for work on the main thread that should be on a background queue
[ ] Verify no synchronous I/O (file reads, database queries) on the main thread
[ ] Identify redundant or repeated calculations that can be cached
How to run: Xcode > Product > Profile > Time Profiler
Focus: Check "Hide System Libraries" to see only your code
Tip: Use Call Tree > Invert Call Tree to find leaf functions consuming the most time
Allocations
[ ] Profile with Allocations to track memory growth over time
[ ] Check for unbounded memory growth during scrolling or navigation
[ ] Identify transient allocations that could be reduced (e.g., creating objects in tight loops)
[ ] Look for large allocations (images, data buffers) that are not released
How to run: Xcode > Product > Profile > Allocations
Focus: Mark Generation snapshots before and after user flows to isolate growth
Tip: Filter by "Persistent" to find objects that never get deallocated
Leaks
[ ] Run Leaks instrument to detect retain cycles
[ ] Investigate any leaked objects (especially closures capturing
self
)
[ ] Verify delegates are declared
weak
[ ] Verify closures use
[weak self]
when capturing view models or coordinators
[ ] Check Combine subscriptions are stored and cancelled properly
Network (Instruments)
[ ] Profile network calls with the Network instrument
[ ] Identify redundant or duplicate API requests
[ ] Check for requests that could be batched or deduplicated
[ ] Verify responses are cached appropriately (HTTP cache headers)
Animation Hitches (Core Animation / RenderServer)
[ ] Profile with the Animation Hitches instrument
[ ] Target zero commit hitches (frame drops during layout/render)
[ ] Target zero render hitches (GPU cannot complete frame in time)
[ ] Investigate any hitch duration > 8ms on 120Hz devices
Acceptance and failure review
Checkpoint
What to inspect
If it does not match
Reproduce workload
Confirm the input and environment
Preserve the failure and return to this step
Record baseline
Inspect the intermediate artifact
Preserve the failure and return to this step
Change one cause
Run the focused check
Preserve the failure and return to this step
Compare trace
Record the observed result
Preserve the failure and return to this step
Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.
Evidence and limits
No invented launch-time or hang-rate numbers are supplied.
This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The
series evidence record
separates executed checks from exercises and blocked environments.
Inspect the source used in this lesson
.
Related reading
Review AI-generated Swift before you trust it
A focused review, a small patch and a real test beat a confident completion message.
What to do next
Next: Memory: retain cycles agents create in closures and Combine
---
# Memory: retain cycles agents create in closures and Combine
https://nagarjuna2997.github.io/ios-agent-skill/series/5.5.html
← Guided series
·
All articlesLesson 5.5 · Guided learningMemory: retain cycles agents create in closures and CombineOn this page
Work through the example
Implementation reference
Common retain-cycle shapes
Acceptance and failure review
Evidence and limits
Related reading
A retain cycle is an ownership graph that cannot release itself. Weak references are useful only when they match the desired lifetime; adding weak everywhere can hide missing ownership.
Owner → Stored closure or subscription → Captured owner → Break intended edge1
Owner
↓2
Stored closure or subscription
↓3
Captured owner
↓4
Break intended edge
Work through the example
Draw the ownership graph before changing a capture list. Confirm deallocation after the screen or task ends.
Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.
Implementation reference
The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.
Common retain-cycle shapes
Delegate cycles:
protocol PlayerCoordinatorDelegate: AnyObject {
func playerDidFinish()
}
final class PlayerCoordinator {
weak var delegate: PlayerCoordinatorDelegate?
}
Closure cycles:
final class Loader {
private var onComplete: (() -> Void)?
func start() {
onComplete = { [weak self] in
self?.finish()
}
}
private func finish() {}
}
Timer cycles:
final class Poller {
private var timer: Timer?
func start() {
timer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { [weak self] _ in
self?.tick()
}
}
func stop() {
timer?.invalidate()
timer = nil
}
deinit {
timer?.invalidate()
}
private func tick() {}
}
Combine cycles:
final class SearchModel {
private var cancellables = Set<AnyCancellable>()
func bind(_ publisher: AnyPublisher<String, Never>) {
publisher
.sink { [weak self] query in
self?.runSearch(query)
}
.store(in: &cancellables)
}
private func runSearch(_ query: String) {}
}
Acceptance and failure review
Checkpoint
What to inspect
If it does not match
Owner
Confirm the input and environment
Preserve the failure and return to this step
Stored closure or subscription
Inspect the intermediate artifact
Preserve the failure and return to this step
Captured owner
Run the focused check
Preserve the failure and return to this step
Break intended edge
Record the observed result
Preserve the failure and return to this step
Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.
Evidence and limits
Static review findings require runtime confirmation when lifetime depends on navigation or cancellation.
This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The
series evidence record
separates executed checks from exercises and blocked environments.
Inspect the source used in this lesson
.
Related reading
How can I catch Swift concurrency review findings before accepting an agent’s changes?
A real before-and-after MCP review with file and line evidence, plus the limits of text-based concurrency checks.Review AI-generated Swift before you trust it
A focused review, a small patch and a real test beat a confident completion message.
What to do next
Next: Security review of an agent-built app: keychain, ATS, secrets in source
---
# Security review of an agent-built app: keychain, ATS,…
https://nagarjuna2997.github.io/ios-agent-skill/series/5.6.html
← Guided series
·
All articlesLesson 5.6 · Guided learningSecurity review of an agent-built app: keychain, ATS, secrets in sourceOn this page
Work through the example
Implementation reference
Keychain for Sensitive Data
Acceptance and failure review
Evidence and limits
Related reading
Security review follows data through storage, transport and diagnostics. A secret kept out of source can still leak through logs, screenshots or an overly broad tool connection.
Sensitive input → Storage boundary → Network boundary → Redacted diagnostics1
Sensitive input
↓2
Storage boundary
↓3
Network boundary
↓4
Redacted diagnostics
Work through the example
Use synthetic credentials when testing failures. Check what leaves the device and who can read stored values.
Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.
Implementation reference
The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.
Keychain for Sensitive Data
[ ] Store authentication tokens in Keychain, never in
UserDefaults
or files
[ ] Store API keys and secrets in Keychain (or better, fetch from server at runtime)
[ ] Set appropriate Keychain accessibility level for each item
[ ] Use
kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
for tokens needed in background
[ ] Use
kSecAttrAccessibleWhenUnlockedThisDeviceOnly
for highly sensitive data
[ ] Never use
kSecAttrAccessibleAlways
(deprecated and insecure)
[ ] Set
kSecAttrAccessControl
with biometric requirement for high-value secrets
[ ] Delete Keychain items on user logout
import Security
enum KeychainHelper {
static func save(
key: String,
data: Data,
accessibility: CFString = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecValueData as String: data,
kSecAttrAccessible as String: accessibility
]
// Delete any existing item first
SecItemDelete(query as CFDictionary)
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else {
throw KeychainError.saveFailed(status: status)
}
}
static func load(key: String) throws -> Data? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
switch status {
case errSecSuccess:
return result as? Data
case errSecItemNotFound:
return nil
default:
throw KeychainError.loadFailed(status: status)
}
}
static func delete(key: String) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key
]
SecItemDelete(query as CFDictionary)
}
}
enum KeychainError: Error {
case saveFailed(status: OSStatus)
case loadFailed(status: OSStatus)
}
Acceptance and failure review
Checkpoint
What to inspect
If it does not match
Sensitive input
Confirm the input and environment
Preserve the failure and return to this step
Storage boundary
Inspect the intermediate artifact
Preserve the failure and return to this step
Network boundary
Run the focused check
Preserve the failure and return to this step
Redacted diagnostics
Record the observed result
Preserve the failure and return to this step
Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.
Evidence and limits
This checklist is not a penetration test or a guarantee of security.
This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The
series evidence record
separates executed checks from exercises and blocked environments.
Inspect the source used in this lesson
.
Related reading
Review AI-generated Swift before you trust it
A focused review, a small patch and a real test beat a confident completion message.
What to do next
Next: Static review vs the compiler: what heuristics catch and what they miss
---
# Static review vs the compiler: what heuristics catch and…
https://nagarjuna2997.github.io/ios-agent-skill/series/5.7.html
← Guided series
·
All articlesLesson 5.7 · Guided learningStatic review vs the compiler: what heuristics catch and what they missOn this page
Work through the example
Implementation reference
2. Every claim carries a label
Acceptance and failure review
Evidence and limits
Related reading
A heuristic can flag a suspicious pattern without proving a compiler error, and a clean compile can leave behavior wrong. Treat each result as evidence for a specific question.
Static finding → Inspect context → Compile → Exercise behavior1
Static finding
↓2
Inspect context
↓3
Compile
↓4
Exercise behavior
Work through the example
Keep a real finding and a false-positive fixture beside a rule. Explain which context the scanner cannot model.
Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.
Implementation reference
The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.
2. Every claim carries a label
Each factual claim in a report falls into exactly one bucket, and the report
says which:
Label
Means
Requires
VERIFIED
A command was run; this is its real output
The command and its output, verbatim
INSPECTED
The code was read and reasoned about
The file:line that was read
UNVERIFIED
Could not be checked
The reason (no scheme, no simulator, no network)
A report with zero VERIFIED claims and no explanation of why is a failed report,
regardless of how confident it sounds.
UNVERIFIED is a legitimate, useful result.
"I could not build this — there is
no Xcode on this machine, so the isolation fix is INSPECTED only" is honest and
actionable. Quietly implying it built is not.
Acceptance and failure review
Checkpoint
What to inspect
If it does not match
Static finding
Confirm the input and environment
Preserve the failure and return to this step
Inspect context
Inspect the intermediate artifact
Preserve the failure and return to this step
Compile
Run the focused check
Preserve the failure and return to this step
Exercise behavior
Record the observed result
Preserve the failure and return to this step
Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.
Evidence and limits
Do not count a suppressed warning as a repaired defect.
This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The
series evidence record
separates executed checks from exercises and blocked environments.
Inspect the source used in this lesson
.
Related reading
How can I catch Swift concurrency review findings before accepting an agent’s changes?
A real before-and-after MCP review with file and line evidence, plus the limits of text-based concurrency checks.Review AI-generated Swift before you trust it
A focused review, a small patch and a real test beat a confident completion message.
What to do next
Next: App Store readiness: the checks before you upload an agent-built app
---
# App Store readiness: the checks before you upload an…
https://nagarjuna2997.github.io/ios-agent-skill/series/6.1.html
← Guided series
·
All articlesLesson 6.1 · Guided learningApp Store readiness: the checks before you upload an agent-built appOn this page
Work through the example
Implementation reference
Privacy
Acceptance and failure review
Evidence and limits
Related reading
Release readiness joins product behavior, metadata, permissions and distribution configuration. A local review can organize checks, but only the real submission process establishes acceptance.
Working build → Privacy and metadata → Signed archive → Submission review1
Working build
↓2
Privacy and metadata
↓3
Signed archive
↓4
Submission review
Work through the example
Create an evidence checklist with owners and unresolved items. Keep unsupported or untested capabilities out of release claims.
Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.
Implementation reference
The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.
Privacy
[ ] Privacy policy URL provided (required for all apps)
[ ] Privacy policy is accessible and clearly written
[ ] App Privacy labels configured in App Store Connect (Data Types questionnaire)
[ ] Each data type categorized correctly (collected vs. tracked vs. linked)
[ ] App Tracking Transparency (ATT) prompt implemented if tracking users across apps
[ ] ATT prompt shown before any tracking begins
[ ]
NSUserTrackingUsageDescription
added to Info.plist if using ATT
[ ] Purpose strings (usage descriptions) provided for all permission requests:
[ ]
NSCameraUsageDescription
[ ]
NSPhotoLibraryUsageDescription
[ ]
NSLocationWhenInUseUsageDescription
[ ]
NSLocationAlwaysAndWhenInUseUsageDescription
(if applicable)
[ ]
NSMicrophoneUsageDescription
[ ]
NSContactsUsageDescription
[ ]
NSCalendarsUsageDescription
[ ]
NSBluetoothAlwaysUsageDescription
[ ]
NSFaceIDUsageDescription
[ ]
NSHealthShareUsageDescription
/
NSHealthUpdateUsageDescription
[ ] Each purpose string clearly explains why the permission is needed (in user-friendly language)
Acceptance and failure review
Checkpoint
What to inspect
If it does not match
Working build
Confirm the input and environment
Preserve the failure and return to this step
Privacy and metadata
Inspect the intermediate artifact
Preserve the failure and return to this step
Signed archive
Run the focused check
Preserve the failure and return to this step
Submission review
Record the observed result
Preserve the failure and return to this step
Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.
Evidence and limits
No app was submitted to App Review for this series.
This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The
series evidence record
separates executed checks from exercises and blocked environments.
Inspect the source used in this lesson
.
Related reading
Review AI-generated Swift before you trust it
A focused review, a small patch and a real test beat a confident completion message.Five checks for an AI-built SwiftUI app
Use persistence, search and accessible states to make “it works” a testable claim.
What to do next
Next: Privacy manifests, permissions strings and the things agents leave blank
---
# Privacy manifests, permissions strings and the things…
https://nagarjuna2997.github.io/ios-agent-skill/series/6.2.html
← Guided series
·
All articlesLesson 6.2 · Guided learningPrivacy manifests, permissions strings and the things agents leave blankOn this page
Work through the example
Implementation reference
Privacy
Acceptance and failure review
Evidence and limits
Related reading
A privacy manifest and a permission-purpose string are different declarations. Derive them from actual behavior and dependencies rather than filling fields with generic text.
Inventory data use → Inspect SDK behavior → Declare reasons → Review release archive1
Inventory data use
↓2
Inspect SDK behavior
↓3
Declare reasons
↓4
Review release archive
Work through the example
Trace one permission to the feature that asks for it and the user-visible explanation. Recheck dependencies when they change.
Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.
Implementation reference
The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.
Privacy
[ ] Privacy policy URL provided (required for all apps)
[ ] Privacy policy is accessible and clearly written
[ ] App Privacy labels configured in App Store Connect (Data Types questionnaire)
[ ] Each data type categorized correctly (collected vs. tracked vs. linked)
[ ] App Tracking Transparency (ATT) prompt implemented if tracking users across apps
[ ] ATT prompt shown before any tracking begins
[ ]
NSUserTrackingUsageDescription
added to Info.plist if using ATT
[ ] Purpose strings (usage descriptions) provided for all permission requests:
[ ]
NSCameraUsageDescription
[ ]
NSPhotoLibraryUsageDescription
[ ]
NSLocationWhenInUseUsageDescription
[ ]
NSLocationAlwaysAndWhenInUseUsageDescription
(if applicable)
[ ]
NSMicrophoneUsageDescription
[ ]
NSContactsUsageDescription
[ ]
NSCalendarsUsageDescription
[ ]
NSBluetoothAlwaysUsageDescription
[ ]
NSFaceIDUsageDescription
[ ]
NSHealthShareUsageDescription
/
NSHealthUpdateUsageDescription
[ ] Each purpose string clearly explains why the permission is needed (in user-friendly language)
Acceptance and failure review
Checkpoint
What to inspect
If it does not match
Inventory data use
Confirm the input and environment
Preserve the failure and return to this step
Inspect SDK behavior
Inspect the intermediate artifact
Preserve the failure and return to this step
Declare reasons
Run the focused check
Preserve the failure and return to this step
Review release archive
Record the observed result
Preserve the failure and return to this step
Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.
Evidence and limits
Current Apple requirements must be checked for the actual release; this is not legal advice or submission approval.
This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The
series evidence record
separates executed checks from exercises and blocked environments.
Inspect the source used in this lesson
.
Related reading
Review AI-generated Swift before you trust it
A focused review, a small patch and a real test beat a confident completion message.
What to do next
Next: Preparing for TestFlight: signing, builds and account boundaries
---
# Preparing for TestFlight: signing, builds and account…
https://nagarjuna2997.github.io/ios-agent-skill/series/6.3.html
← Guided series
·
All articlesLesson 6.3 · Guided learningPreparing for TestFlight: signing, builds and account boundariesOn this page
Work through the example
Prepare the distribution handoff
Keep a release handoff record
Acceptance and failure review
Prepare without pretending to upload
Evidence and limits
Related reading
TestFlight requires a real distribution workflow, account access and a signed build. An agent can help prepare artifacts, but a local simulator result does not establish signing or upload success.
Local acceptance → Distribution configuration → Upload build → Tester feedback1
Local acceptance
↓2
Distribution configuration
↓3
Upload build
↓4
Tester feedback
Work through the example
Separate account setup from code fixes. Record archive and upload outcomes without publishing signing material.
Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.
Prepare the distribution handoff
This is a preparation guide. It deliberately does not present an unperformed upload as an end-to-end TestFlight tutorial.
Record the intended app record, bundle identifier, version and build number. Make sure the identity belongs to the app you intend to distribute, rather than a sample identifier copied from a tutorial.
Confirm the account has the required program access and role for the distribution action. Keep sign-in in Apple's local or web interface; do not paste credentials into an agent transcript.
Run the app's acceptance suite before preparing a release artifact. Record unresolved failures separately from optional improvements.
In Xcode, inspect the intended target's signing and capability settings. A simulator build that disabled signing is useful development evidence, but it is not the distribution archive.
Use the distribution workflow supported by the installed Xcode and account. Inspect archive validation errors before retrying an upload; do not switch bundle identities simply to make an error disappear.
After an actual upload, confirm processing in App Store Connect, complete the required beta metadata, and select the intended testing audience. Internal and external testing have different review and access steps; consult the current Apple instructions for that audience.
Ask a tester to install the distributed build and repeat a small acceptance journey. A successful upload is not proof that the installed build starts correctly or uses the intended environment.
Keep a release handoff record
Field
What belongs here
Build identity
App identifier, version and build number
Source
Commit used for the archive
Validation
Commands and outcomes before distribution
Distribution
Actual processing or validation outcome, when performed
Tester check
Device/runtime, observed behavior and feedback
Unresolved items
Explicit blockers with an owner
Do not include signing private keys, provisioning files or account tokens in the public record. A private account operation cannot be reconstructed from a screenshot of a local simulator. The lack of distribution access is a legitimate blocker for the lab, while the preparation checklist remains useful.
Acceptance and failure review
Checkpoint
What to inspect
If it does not match
Local acceptance
Confirm the input and environment
Preserve the failure and return to this step
Distribution configuration
Inspect the intermediate artifact
Preserve the failure and return to this step
Upload build
Run the focused check
Preserve the failure and return to this step
Tester feedback
Record the observed result
Preserve the failure and return to this step
Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.
Prepare without pretending to upload
Apple’s
TestFlight overview
describes beta distribution. This lesson stops before account-specific distribution operations. Record the bundle identifier, intended audience and local acceptance results, then use the actual account’s distribution workflow. A simulator build with signing disabled cannot be uploaded as the release artifact.
Evidence and limits
No TestFlight upload is authorized or performed in this series; the end-to-end lab is blocked.
This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The
series evidence record
separates executed checks from exercises and blocked environments.
Inspect the source used in this lesson
.
Related reading
Five checks for an AI-built SwiftUI app
Use persistence, search and accessible states to make “it works” a testable claim.
What to do next
Next: Investigating App Review feedback without guessing the cause
---
# Investigating App Review feedback without guessing the…
https://nagarjuna2997.github.io/ios-agent-skill/series/6.4.html
← Guided series
·
All articlesLesson 6.4 · Guided learningInvestigating App Review feedback without guessing the causeOn this page
Work through the example
Implementation reference
App Metadata
Acceptance and failure review
Evidence and limits
Related reading
A useful rejection analysis quotes the actual reason and ties it to a reproducible app behavior. Avoid claiming that an app was rejected because it used AI unless the evidence says so.
Review message → Relevant behavior → Small correction → Resubmission evidence1
Review message
↓2
Relevant behavior
↓3
Small correction
↓4
Resubmission evidence
Work through the example
Use the current official guidelines to interpret a real case. Distinguish policy requirements from a maintainer's guess.
Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.
Implementation reference
The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.
App Metadata
[ ] App name finalized (30 character limit, no keyword stuffing)
[ ] Subtitle written (30 character limit, descriptive and compelling)
[ ] App description written (up to 4000 characters, most important info first)
[ ] Promotional text set (170 characters, can be updated without a new build)
[ ] Keywords optimized (100 character budget, comma-separated, no spaces after commas)
[ ] Primary and secondary categories selected
[ ] Support URL provided (must be a working webpage)
[ ] Marketing URL provided (optional but recommended)
[ ] Copyright field filled (e.g., "2026 Your Company Name")
[ ] Version number follows semantic versioning (e.g., 1.0.0)
Acceptance and failure review
Checkpoint
What to inspect
If it does not match
Review message
Confirm the input and environment
Preserve the failure and return to this step
Relevant behavior
Inspect the intermediate artifact
Preserve the failure and return to this step
Small correction
Run the focused check
Preserve the failure and return to this step
Resubmission evidence
Record the observed result
Preserve the failure and return to this step
Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.
Evidence and limits
No sourced rejection case collection has been assembled; no invented rejection stories are presented.
This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The
series evidence record
separates executed checks from exercises and blocked environments.
Inspect the source used in this lesson
.
Related reading
Review AI-generated Swift before you trust it
A focused review, a small patch and a real test beat a confident completion message.
What to do next
Next: Versioning, changelogs and release notes the agent can draft
---
# Versioning, changelogs and release notes the agent can…
https://nagarjuna2997.github.io/ios-agent-skill/series/6.5.html
← Guided series
·
All articlesLesson 6.5 · Guided learningVersioning, changelogs and release notes the agent can draftOn this page
Work through the example
Implementation reference
[Unreleased]
Acceptance and failure review
Evidence and limits
Related reading
Release notes should describe the behavior users receive, not every internal commit. Separate new capabilities, fixes and known limits, then verify that the package and source match the release.
Changes since tag → User-facing behavior → Version consistency → Release notes1
Changes since tag
↓2
User-facing behavior
↓3
Version consistency
↓4
Release notes
Work through the example
Compare the installed package version with the changelog before publishing. Batch compatible fixes rather than releasing every cosmetic edit.
Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.
Implementation reference
The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.
[Unreleased]
Source-only private feedback preview/approval tool and receiver for a private GitHub inbox. Explicit in-chat approval, fixed categories, private-destination checks and no public fallback. Hosting is not configured; no npm publication.
Acceptance and failure review
Checkpoint
What to inspect
If it does not match
Changes since tag
Confirm the input and environment
Preserve the failure and return to this step
User-facing behavior
Inspect the intermediate artifact
Preserve the failure and return to this step
Version consistency
Run the focused check
Preserve the failure and return to this step
Release notes
Record the observed result
Preserve the failure and return to this step
Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.
Evidence and limits
This writing task does not authorize a new npm release.
This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The
series evidence record
separates executed checks from exercises and blocked environments.
Inspect the source used in this lesson
.
Related reading
Review AI-generated Swift before you trust it
A focused review, a small patch and a real test beat a confident completion message.
What to do next
Next: CI for an iOS repo with agent-written code: what to run on every PR
---
# CI for an iOS repo with agent-written code: what to run…
https://nagarjuna2997.github.io/ios-agent-skill/series/6.6.html
← Guided series
·
All articlesLesson 6.6 · Guided learningCI for an iOS repo with agent-written code: what to run on every PROn this page
Work through the example
Implementation reference
1. Hook vs. CI vs. reviewer
Acceptance and failure review
Evidence and limits
Related reading
CI gives the repository an independent place to run checks. Local hooks improve feedback time, but they do not replace a clean runner using the committed files.
Checkout → Install pinned dependencies → Build and test → Report failures1
Checkout
↓2
Install pinned dependencies
↓3
Build and test
↓4
Report failures
Work through the example
Keep required checks reproducible and preserve nonzero exit codes through pipes. Run simulator jobs only where the runner supports them.
Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.
Implementation reference
The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.
1. Hook vs. CI vs. reviewer
Three enforcement layers. Use the cheapest one that can decide the question.
Layer
Decides
Cost
Feedback speed
Hook
Rules a script can evaluate
~free
Immediate — model self-corrects mid-turn
CI
Same, plus full build/test
minutes
After push
Reviewer subagent
Judgment: is this correct, does it match intent
tokens
End of task
The ordering matters. Spending a reviewer subagent on "did you use
DispatchQueue.main.async
" is waste — a grep answers that for free, instantly,
and cannot be argued with. Reserve model judgment for what rules cannot express.
Rule of thumb:
if you can write the check as a grep or an exit code, it is a
hook. If it needs to understand intent, it is a reviewer.
Acceptance and failure review
Checkpoint
What to inspect
If it does not match
Checkout
Confirm the input and environment
Preserve the failure and return to this step
Install pinned dependencies
Inspect the intermediate artifact
Preserve the failure and return to this step
Build and test
Run the focused check
Preserve the failure and return to this step
Report failures
Record the observed result
Preserve the failure and return to this step
Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.
Evidence and limits
A workflow file is not a successful run; inspect the actual job result.
This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The
series evidence record
separates executed checks from exercises and blocked environments.
Inspect the source used in this lesson
.
Related reading
How can Claude Code hooks protect generated files and verify a project before stopping?
A tested generated-file guard and Stop check, with configuration, reproducible exit codes, and enforcement limits.Review AI-generated Swift before you trust it
A focused review, a small patch and a real test beat a confident completion message.
What to do next
Next: Subagents for iOS: splitting review, build and design across agents
---
# Subagents for iOS: splitting review, build and design…
https://nagarjuna2997.github.io/ios-agent-skill/series/7.1.html
← Guided series
·
All articlesLesson 7.1 · Guided learningSubagents for iOS: splitting review, build and design across agentsOn this page
Work through the example
Implementation reference
4. Writing the delegation prompt
Acceptance and failure review
Evidence and limits
Related reading
Delegation is useful when a task has an independent input and a reviewable output. More agents do not automatically mean more progress when they compete for the same files or simulator.
Bounded task → Independent execution → Evidence report → Main-agent integration1
Bounded task
↓2
Independent execution
↓3
Evidence report
↓4
Main-agent integration
Work through the example
Give the reviewer a diff and an acceptance question. Keep the main agent responsible for resolving disagreements.
Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.
Implementation reference
The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.
4. Writing the delegation prompt
A subagent starts cold. This is the failure mode that makes delegation look
useless: an under-specified prompt produces a confident, irrelevant report.
Every delegation prompt carries:
The goal
, as an outcome rather than an activity.
The context it cannot see
— decisions already made, constraints, the
relevant file paths you already found.
The boundary
— what is explicitly out of scope.
The return format
— what you need back, and in what shape.
### WEAK
"Look at the networking code."
### STRONG
"Find every place that constructs a URLRequest in Sources/, and report which
ones set an Authorization header and which do not.
Context: we are adding a shared auth interceptor and need to know what would
be duplicated. The APIClient at Sources/Data/APIClient.swift:44 is already
known — I need the ones outside it.
Out of scope: test targets, and anything under Vendor/.
Return: a table of file:line, the endpoint, and whether it sets auth."
The
Out of scope
line matters more than it looks. Without it, subagents
reliably expand the task.
Acceptance and failure review
Checkpoint
What to inspect
If it does not match
Bounded task
Confirm the input and environment
Preserve the failure and return to this step
Independent execution
Inspect the intermediate artifact
Preserve the failure and return to this step
Evidence report
Run the focused check
Preserve the failure and return to this step
Main-agent integration
Record the observed result
Preserve the failure and return to this step
Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.
Evidence and limits
The repository's available roles are not proof that every role improves every task.
This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The
series evidence record
separates executed checks from exercises and blocked environments.
Inspect the source used in this lesson
.
Related reading
Review AI-generated Swift before you trust it
A focused review, a small patch and a real test beat a confident completion message.How can Claude Code hooks protect generated files and verify a project before stopping?
A tested generated-file guard and Stop check, with configuration, reproducible exit codes, and enforcement limits.
What to do next
Next: Coordinating parallel coding work with isolated checkouts
---
# Preparing to verify Xcode 27 agents with external skills
https://nagarjuna2997.github.io/ios-agent-skill/series/7.10.html
← Guided series
·
All articlesLesson 7.10 · Guided learningPreparing to verify Xcode 27 agents with external skillsOn this page
Work through the example
Implementation reference
3. The same verification contract applies
Acceptance and failure review
Evidence and limits
Related reading
An editor-integrated agent still needs a reproducible build and a clear evidence boundary. Local MCP discovery in another terminal client does not establish integration inside Xcode.
Editor capability → Project access → Tool discovery → Real task check1
Editor capability
↓2
Project access
↓3
Tool discovery
↓4
Real task check
Work through the example
Document the selected Xcode version and the actual in-editor setup surface. Verify one small operation before claiming support.
Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.
Implementation reference
The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.
3. The same verification contract applies
An agent in Xcode is still an agent. Everything in
docs/orchestration/verification.md
holds:
Never accept "done" without evidence.
In Xcode the evidence is right
there — the build result and the test navigator. Look at it.
The author does not grade the work.
If the agent wrote the code
and
the
test, both encode the same misunderstanding. Read the test yourself and ask
whether it would fail against the old behavior.
A green build is not a passing feature.
It compiles. That is one claim.
Agent says What you check
"Added tests, they pass." → Run them. Read them. Would they fail before?
"Fixed the layout issue." → Run it on the device size that broke.
"Localized to 8 languages." → Check pluralization and RTL, not just presence.
"Build succeeds." → True and insufficient.
Acceptance and failure review
Checkpoint
What to inspect
If it does not match
Editor capability
Confirm the input and environment
Preserve the failure and return to this step
Project access
Inspect the intermediate artifact
Preserve the failure and return to this step
Tool discovery
Run the focused check
Preserve the failure and return to this step
Real task check
Record the observed result
Preserve the failure and return to this step
Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.
Evidence and limits
Xcode 27 is not installed on the test Mac; this integration lab is blocked.
This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The
series evidence record
separates executed checks from exercises and blocked environments.
Inspect the source used in this lesson
.
Related reading
Choose an AI client for your iOS workflow
Understand the difference between a skill, a local MCP connection and ChatGPT web setup.How should I review availability guards when moving a Swift app toward iOS 27?
A measured guard-review example that separates SDK availability, runtime readiness, and static-analysis limits.
What to do next
Next: Keeping up with Apple: refreshing references after each beta without breaking rules
---
# Keeping up with Apple: refreshing references after each…
https://nagarjuna2997.github.io/ios-agent-skill/series/7.11.html
← Guided series
·
All articlesLesson 7.11 · Guided learningKeeping up with Apple: refreshing references after each beta without breaking rulesOn this page
Work through the example
Implementation reference
Pattern
Where the complete code lives
Apple guidance and ownership
Acceptance and failure review
Evidence and limits
Related reading
A reference refresh should preserve provenance and separate catalog coverage from compiled examples. New documentation can change guidance without proving that an implementation works on the new SDK.
Upstream change → Source comparison → Guide update → Compile affected sample1
Upstream change
↓2
Source comparison
↓3
Guide update
↓4
Compile affected sample
Work through the example
Track the source date and identify API claims that require the new toolchain. Keep old deployment targets in the verification matrix.
Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.
Implementation reference
The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.
Pattern
Use a clone for offline work after installation. Search runs locally with Node and does not call an AI or make network requests:
node scripts/query-library.mjs search "Persistence" source
node scripts/query-library.mjs outline docs/frameworks/swiftdata.md
node scripts/query-library.mjs read samples/SkillPatterns/Sources/SkillPatterns/Persistence.swift 0 6000
search
returns up to eight file paths, titles and sizes, without file bodies.
outline
returns headings with character offsets.
read
returns exact content, a content hash and
nextOffset
; continue from that offset until it is null when the complete source is needed. Offsets are JavaScript UTF-16 string indexes, not bytes. No excerpt is silently presented as a complete file.
The same library is bundled inside the knowledge MCP package:
Call
search_local_references
with a feature/API query and
kind: "source"
for reusable code, or
kind: "guide"
for explanations.
Use
get_reference_outline
to select a guide section.
Call
read_local_reference
with its exact returned path and offset. The default body budget is 6,000 characters; the maximum is 16,000 per call. Follow
nextOffset
only if necessary.
Read sibling package manifests, dependencies and tests before adapting a source file. Preserve error handling, actor isolation and availability requirements.
Build and test the resulting app with the installed SDK. A source file being indexed does not prove it builds independently.
get_apple_technology
now defaults to an overview with local guide/source routes. Request
view: "full"
only when the entire guide and topic map are needed. The repository's Markdown examples also contain explicitly labelled wrong patterns; retain their surrounding explanation and do not automatically extract every fenced block as production Swift.
Where the complete code lives
samples/SkillPatterns/
: Swift package containing persistence, streams, routing, observation, composition and signal-processing implementations with tests.
samples/AppleRecipes/
: original Apple API implementations with their own manifest, tests and build evidence. See its README for exact platform coverage.
templates/ios-app/
: an app's screens, models, repositories and test starting points.
templates/common-patterns/
: editable networking, persistence, authentication, design and navigation source; adaptation and app-level testing are required.
cli/src/
: complete source of the app scaffolder.
mcp-server/src/
: complete source of the analysis and local knowledge servers.
See
docs/apple/local-library.md
for the generated file inventory. The index contains metadata only. Content has one canonical source file; the MCP bundle stores identical content once by SHA-256. Plugin archives carry those same canonical files so they work without fetching individual guides.
Apple guidance and ownership
Original code in this repository is covered by the repository's MIT license, subject to any file-specific notices. Apple documentation links in the guides are attribution and freshness references. Apple SDK binaries, internal source and proprietary manuals are not relicensed as part of this project. Use the installed SDK and the linked official guidance for availability or behavior that changed after the snapshot.
Acceptance and failure review
Checkpoint
What to inspect
If it does not match
Upstream change
Confirm the input and environment
Preserve the failure and return to this step
Source comparison
Inspect the intermediate artifact
Preserve the failure and return to this step
Guide update
Run the focused check
Preserve the failure and return to this step
Compile affected sample
Record the observed result
Preserve the failure and return to this step
Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.
Evidence and limits
A successful index refresh is not a GA compatibility test.
This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The
series evidence record
separates executed checks from exercises and blocked environments.
Inspect the source used in this lesson
.
Related reading
How should I review availability guards when moving a Swift app toward iOS 27?
A measured guard-review example that separates SDK availability, runtime readiness, and static-analysis limits.
What to do next
Next: Planning a safe Swift 6 and iOS 27 migration
---
# Planning a safe Swift 6 and iOS 27 migration
https://nagarjuna2997.github.io/ios-agent-skill/series/7.12.html
← Guided series
·
All articlesLesson 7.12 · Guided learningPlanning a safe Swift 6 and iOS 27 migrationOn this page
Work through the example
Implementation reference
Sendable Protocol
Acceptance and failure review
Evidence and limits
Related reading
A migration is easier to diagnose when language-mode changes and feature changes are separate. Preserve behavior first, then fix isolated compiler failures with small checkpoints.
Baseline tests → Toolchain change → Scoped fixes → Same acceptance suite1
Baseline tests
↓2
Toolchain change
↓3
Scoped fixes
↓4
Same acceptance suite
Work through the example
Record the old and new build settings. Do not silence concurrency diagnostics by changing semantics without a test.
Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.
Implementation reference
The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.
Sendable Protocol
Sendable
marks types that are safe to transfer across concurrency domains.
// Value types with Sendable fields are implicitly Sendable
struct UserDTO: Sendable {
let id: String
let name: String
let email: String
}
// Classes must be final and have only immutable stored properties
final class Configuration: Sendable {
let apiKey: String
let baseURL: URL
init(apiKey: String, baseURL: URL) {
self.apiKey = apiKey
self.baseURL = baseURL
}
}
// @unchecked Sendable — when you manage thread safety manually
final class AtomicCounter: @unchecked Sendable {
private let lock = NSLock()
private var _value = 0
var value: Int {
lock.withLock { _value }
}
func increment() {
lock.withLock { _value += 1 }
}
}
// @Sendable closures
func performAsync(_ work: @Sendable @escaping () async -> Void) {
Task {
await work()
}
}
// Common Sendable conformances
// - All value types with Sendable properties
// - Actors (always Sendable)
// - Enums with Sendable associated values
// - Tuples of Sendable types
Acceptance and failure review
Checkpoint
What to inspect
If it does not match
Baseline tests
Confirm the input and environment
Preserve the failure and return to this step
Toolchain change
Inspect the intermediate artifact
Preserve the failure and return to this step
Scoped fixes
Run the focused check
Preserve the failure and return to this step
Same acceptance suite
Record the observed result
Preserve the failure and return to this step
Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.
Evidence and limits
A complete old-project migration to iOS 27 has not been executed here.
This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The
series evidence record
separates executed checks from exercises and blocked environments.
Inspect the source used in this lesson
.
Related reading
How should I review availability guards when moving a Swift app toward iOS 27?
A measured guard-review example that separates SDK availability, runtime readiness, and static-analysis limits.How can I catch Swift concurrency review findings before accepting an agent’s changes?
A real before-and-after MCP review with file and line evidence, plus the limits of text-based concurrency checks.
What to do next
Next: Ask for the finding, not the fix: why review-first prompts produce better Swift
---
# Coordinating parallel coding work with isolated checkouts
https://nagarjuna2997.github.io/ios-agent-skill/series/7.2.html
← Guided series
·
All articlesLesson 7.2 · Guided learningCoordinating parallel coding work with isolated checkoutsOn this page
Work through the example
Implementation reference
4. Failure handling at scale
Acceptance and failure review
Evidence and limits
Related reading
Parallel coding needs isolated checkouts and explicit ownership of shared resources. A worktree protects file edits, but not a shared database, simulator or external account.
Partition work → Isolate checkout → Coordinate shared resources → Integrate sequentially1
Partition work
↓2
Isolate checkout
↓3
Coordinate shared resources
↓4
Integrate sequentially
Work through the example
Assign one integration owner and require each task to report its starting commit and tests. Stop conflicting simulator operations.
Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.
Implementation reference
The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.
4. Failure handling at scale
At thirty units, some will fail. Decide the policy up front:
Policy
Behavior
Use when
Fail fast
Stop the whole run on first failure
Units share risk; a failure implies a bad plan
Isolate and continue
Mark it failed, keep going, report at the end
Units are genuinely independent
Retry once, then isolate
One retry with the failure fed back
Flaky infra, transient toolchain issues
Default to
isolate and continue
for independent units, with a summary at the
end that lists every failure. Twenty-eight successes and two clearly-reported
failures is a good run. Twenty-eight successes and two silently-skipped units is
a bad one that looks identical from the outside — which is exactly why the final
report must enumerate failures explicitly.
Never let a unit "succeed" by doing nothing.
A worker that finds no work
should say "no change needed" — that is a distinct outcome from "changed and
verified", and collapsing the two hides coverage gaps.
Acceptance and failure review
Checkpoint
What to inspect
If it does not match
Partition work
Confirm the input and environment
Preserve the failure and return to this step
Isolate checkout
Inspect the intermediate artifact
Preserve the failure and return to this step
Coordinate shared resources
Run the focused check
Preserve the failure and return to this step
Integrate sequentially
Record the observed result
Preserve the failure and return to this step
Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.
Evidence and limits
No four-client concurrency experiment was executed for this chapter.
This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The
series evidence record
separates executed checks from exercises and blocked environments.
Inspect the source used in this lesson
.
Related reading
ChatGPT and Codex: plan the app, then verify it locally
Separate a browser planning session from a connected development environment.How can Claude Code hooks protect generated files and verify a project before stopping?
A tested generated-file guard and Stop check, with configuration, reproducible exit codes, and enforcement limits.
What to do next
Next: Writing your own review rule for a Swift codebase
---
# Writing your own review rule for a Swift codebase
https://nagarjuna2997.github.io/ios-agent-skill/series/7.3.html
← Guided series
·
All articlesLesson 7.3 · Guided learningWriting your own review rule for a Swift codebaseOn this page
Work through the example
Implementation reference
review_swift_concurrency
Acceptance and failure review
Evidence and limits
Related reading
A review rule should have a narrow predicate, a useful explanation and fixtures that define its limits. Adding a pattern without a false-positive example makes future maintenance harder.
Pattern candidate → Positive fixture → Negative fixture → Located finding1
Pattern candidate
↓2
Positive fixture
↓3
Negative fixture
↓4
Located finding
Work through the example
Inspect a neighboring analyzer and preserve its result shape. Add a test for a context that looks similar but must not trigger.
Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.
Implementation reference
The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.
review_swift_concurrency
Rule
Severity
observable-without-mainactor
🔴
type-named-task
🔴
task-detached
🟠
dispatchqueue-main-async
🟠
unchecked-sendable
🟠
task-in-onappear
🟠
empty-catch
🟠
redundant-mainactor-run
🟡
nonisolated-unsafe
🟡
observable-not-final
🟡
Background:
../swift/swift-concurrency.md
,
../../patterns/mvvm.md
.
Acceptance and failure review
Checkpoint
What to inspect
If it does not match
Pattern candidate
Confirm the input and environment
Preserve the failure and return to this step
Positive fixture
Inspect the intermediate artifact
Preserve the failure and return to this step
Negative fixture
Run the focused check
Preserve the failure and return to this step
Located finding
Record the observed result
Preserve the failure and return to this step
Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.
Evidence and limits
An explanatory example is not a newly shipped analyzer; rule changes require their own tests.
This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The
series evidence record
separates executed checks from exercises and blocked environments.
Inspect the source used in this lesson
.
Related reading
How can I catch Swift concurrency review findings before accepting an agent’s changes?
A real before-and-after MCP review with file and line evidence, plus the limits of text-based concurrency checks.Review AI-generated Swift before you trust it
A focused review, a small patch and a real test beat a confident completion message.
What to do next
Next: Writing your own skill for a Swift project: SKILL.md structure that agents follow
---
# Writing your own skill for a Swift project: SKILL.md…
https://nagarjuna2997.github.io/ios-agent-skill/series/7.4.html
← Guided series
·
All articlesLesson 7.4 · Guided learningWriting your own skill for a Swift project: SKILL.md structure that agents followOn this page
Work through the example
Implementation reference
Apple app engineering
Acceptance and failure review
Evidence and limits
Related reading
A skill should state when to use it, what inputs it needs and how success is checked. A giant list of technologies cannot substitute for a procedure an agent can follow.
Trigger → Relevant context → Bounded procedure → Evidence1
Trigger
↓2
Relevant context
↓3
Bounded procedure
↓4
Evidence
Work through the example
Keep the entry point short enough to route to focused references. Test discovery separately from whether the model follows the workflow.
Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.
Implementation reference
The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.
name: ios-agent-skill
description: Expert iOS/Swift developer behavior for AI coding agents. Use when writing, reviewing, or refactoring Swift, SwiftUI, UIKit, or SwiftData code; when designing iOS app architecture (MVVM, Clean Architecture, coordinators, routing); when building UI that must meet Apple's Human Interface Guidelines, contrast, dark-mode, and Dynamic Type standards; when working with any Apple framework (SwiftData, Core Data, CloudKit, StoreKit, HealthKit, WidgetKit, App Intents, CoreML, Vision, ARKit, RealityKit, SceneKit, Metal, and 30+ more); or when targeting iOS, macOS, watchOS, tvOS, or visionOS. Also use for Swift concurrency questions — actors, @MainActor isolation, Sendable, structured concurrency.
version: "3.5.0"
license: MIT
allowed-tools: Read, Write, Edit, Glob, Grep, Bash
metadata:
author: Nagarjuna Reddy
homepage: https://nagarjuna2997.github.io/ios-agent-skill/
languages: [swift]
platforms: [ios, macos, watchos, tvos, visionos]
entry: SKILL.md
# Toolchain this skill is written against.
swift-version: "6.4"
xcode-version: "27"
ios-sdk-version: "27"
# Deployment floor the generated code must still support.
minimum-swift: "5.9"
minimum-ios: "17.0"
supports:
- Foundation Models
- Apple Intelligence
- Private Cloud Compute
- Xcode Coding Agents
- Device Hub
- Liquid Glass
- SwiftData
- Swift 6 strict concurrency
Apple app engineering
Use the current project and the user's requested feature as the scope. Build working Swift implementations with explicit errors, injected dependencies, preview data and meaningful verification.
Acceptance and failure review
Checkpoint
What to inspect
If it does not match
Trigger
Confirm the input and environment
Preserve the failure and return to this step
Relevant context
Inspect the intermediate artifact
Preserve the failure and return to this step
Bounded procedure
Run the focused check
Preserve the failure and return to this step
Evidence
Record the observed result
Preserve the failure and return to this step
Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.
Evidence and limits
Do not claim a skill improves quality without a controlled comparison.
This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The
series evidence record
separates executed checks from exercises and blocked environments.
Inspect the source used in this lesson
.
Related reading
Choose an AI client for your iOS workflow
Understand the difference between a skill, a local MCP connection and ChatGPT web setup.How can Claude Code hooks protect generated files and verify a project before stopping?
A tested generated-file guard and Stop check, with configuration, reproducible exit codes, and enforcement limits.
What to do next
Next: Building a Claude Code plugin from a skill
---
# Building a Claude Code plugin from a skill
https://nagarjuna2997.github.io/ios-agent-skill/series/7.5.html
← Guided series
·
All articlesLesson 7.5 · Guided learningBuilding a Claude Code plugin from a skillOn this page
Work through the example
Package the skill separately from your app
Check the package boundary
Acceptance and failure review
Evidence and limits
Related reading
Packaging a skill for Claude adds a distribution boundary. Validate the manifest, relative paths and installed tool connection rather than assuming a folder that works in a checkout will work after installation.
Plugin folder → Manifest validation → Clean install → Tool check1
Plugin folder
↓2
Manifest validation
↓3
Clean install
↓4
Tool check
Work through the example
Inspect the repository's Claude package layout and verify which files are included. Keep machine-specific paths out of the distributable.
Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.
Package the skill separately from your app
A Claude plugin exercise starts with a package root containing
.claude-plugin/plugin.json
and the skill directory. Keep application source outside that distribution folder unless it is an intentional example.
my-swift-review/
.claude-plugin/
plugin.json
skills/
swift-review/
SKILL.md
A minimal manifest supplies an identity and description:
{
"name": "my-swift-review",
"version": "0.1.0",
"description": "A focused Swift review procedure."
}
The skill should identify its trigger and required inputs, then tell the agent to inspect a bounded diff, locate findings and report evidence. Avoid filling the skill with installation commands for unrelated clients.
Check the package boundary
Parse the manifest with
python3 -m json.tool .claude-plugin/plugin.json
, confirm the skill file exists, and inspect the package inventory before testing installation. Follow Claude's current plugin documentation for the installed version rather than assuming another client's ZIP layout or manifest schema applies.
After installation, run a synthetic review that actually triggers the skill. Confirm both discovery and the relevant behavior. If the plugin also configures MCP or hooks, test those surfaces independently: the skill loading does not prove the server started, and the server starting does not prove the hook dispatched.
Keep one installation route active during diagnosis. A direct MCP registration plus a plugin-provided registration can make duplicate tools look like a packaging defect. Record the client version, package version and the exact scope of the test before sharing the plugin.
Acceptance and failure review
Checkpoint
What to inspect
If it does not match
Plugin folder
Confirm the input and environment
Preserve the failure and return to this step
Manifest validation
Inspect the intermediate artifact
Preserve the failure and return to this step
Clean install
Run the focused check
Preserve the failure and return to this step
Tool check
Record the observed result
Preserve the failure and return to this step
Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.
Evidence and limits
A marketplace submission is not acceptance or an installed-client test.
This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The
series evidence record
separates executed checks from exercises and blocked environments.
Inspect the source used in this lesson
.
Related reading
How can Claude Code hooks protect generated files and verify a project before stopping?
A tested generated-file guard and Stop check, with configuration, reproducible exit codes, and enforcement limits.Claude Code: turn a Swift finding into a tested patch
A small review-to-test loop for an existing iOS project.
What to do next
Next: Building a Codex plugin from the same skill
---
# Building a Codex plugin from the same skill
https://nagarjuna2997.github.io/ios-agent-skill/series/7.6.html
← Guided series
·
All articlesLesson 7.6 · Guided learningBuilding a Codex plugin from the same skillOn this page
Work through the example
Build the package directory
Validate before installing
Acceptance and failure review
Check the current package contract
Evidence and limits
Related reading
A Codex plugin needs a valid package layout and a clear relationship between skills and tool connections. Keep local execution requirements visible to users before they install.
Manifest → Bundled guidance → Tool configuration → Installed verification1
Manifest
↓2
Bundled guidance
↓3
Tool configuration
↓4
Installed verification
Work through the example
Validate the .codex-plugin manifest and resolve its paths from the package root. Check the official packaging documentation before release.
Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.
Build the package directory
Use a separate package root so the manifest's relative paths resolve consistently:
my-swift-plugin/
.codex-plugin/
plugin.json
.mcp.json
skills/
swift-review/
SKILL.md
The repository's existing package uses this same separation between plugin metadata, skill content and MCP configuration. A small manifest for the exercise is:
{
"name": "my-swift-plugin",
"version": "0.1.0",
"description": "Focused Swift review guidance for this project.",
"skills": "./skills/",
"mcpServers": "./.mcp.json"
}
Keep the public name lowercase and stable. The version belongs to your plugin; it does not have to match the server version. The MCP file can pin the released package used in the existing setup:
{
"mcpServers": {
"ios-agent": {
"command": "npx",
"args": ["-y", "ios-agent-mcp@2.7.0"]
}
}
}
Create
skills/swift-review/SKILL.md
with a name, a task-specific description and a short review procedure. Start with a synthetic Swift file, request located findings and require an explicit distinction between static inspection and a build result.
Validate before installing
Run JSON parsing and path checks from the package root:
python3 -m json.tool .codex-plugin/plugin.json >/dev/null
python3 -m json.tool .mcp.json >/dev/null
test -f skills/swift-review/SKILL.md
These commands validate syntax and file presence, not the entire client schema. Then follow the current official plugin installation/development workflow for your client. Confirm the installed package exposes the skill and exactly one server connection. Remove or disable a duplicate direct MCP registration before judging duplicate tools as a server bug.
For a reproducible artifact, create an archive from the package contents rather than accidentally nesting the root twice:
zip -r ../my-swift-plugin.zip .codex-plugin .mcp.json skills
unzip -l ../my-swift-plugin.zip
Inspect the archive inventory for local paths, logs or account files. A distributable package should contain the declared inputs, not your whole development checkout. This example is a package construction exercise; installation and marketplace review remain separate validation steps.
Acceptance and failure review
Checkpoint
What to inspect
If it does not match
Manifest
Confirm the input and environment
Preserve the failure and return to this step
Bundled guidance
Inspect the intermediate artifact
Preserve the failure and return to this step
Tool configuration
Run the focused check
Preserve the failure and return to this step
Installed verification
Record the observed result
Preserve the failure and return to this step
Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.
Check the current package contract
Use the official
plugin architecture documentation
for the package contract. A local manifest check and an accepted directory listing are distinct results. Keep the plugin’s executable requirements visible, and test the installed package rather than only a source checkout.
Evidence and limits
No new marketplace acceptance is claimed by this chapter.
This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The
series evidence record
separates executed checks from exercises and blocked environments.
Inspect the source used in this lesson
.
Related reading
ChatGPT and Codex: plan the app, then verify it locally
Separate a browser planning session from a connected development environment.Choose an AI client for your iOS workflow
Understand the difference between a skill, a local MCP connection and ChatGPT web setup.
What to do next
Next: Cross-client packaging: verified Muse scope and an Antigravity test boundary
---
# Cross-client packaging: verified Muse scope and an…
https://nagarjuna2997.github.io/ios-agent-skill/series/7.7.html
← Guided series
·
All articlesLesson 7.7 · Guided learningCross-client packaging: verified Muse scope and an Antigravity test boundaryOn this page
Work through the example
Implementation reference
Acceptance and failure review
Evidence and limits
Related reading
Compatibility should be demonstrated through the actual client, not inferred from a familiar filename. Muse's existing record has a narrow scope and cannot certify another client's plugin format.
Documented format → Client install → Discovery → Bounded task1
Documented format
↓2
Client install
↓3
Discovery
↓4
Bounded task
Work through the example
Preserve positive and negative fields in the verification record. Leave unsupported client capabilities explicitly unknown.
Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.
Implementation reference
The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.
{
"checkedAt": "2026-09-16T23:07:13.850Z",
"museVersion": "Muse Code 1.3.0 (1.3.0-R3233.1)",
"serverPackage": "ios-agent-mcp",
"serverVersion": "2.7.0",
"toolCount": 36,
"tools": [
"review_app_intents",
"review_swift_concurrency",
"review_swift_architecture",
"review_swiftui",
"check_availability_guards",
"audit_app_store_readiness",
"analyze_swift_project",
"lint_skill",
"review_swift_memory",
"review_swift_security",
"review_swift_testing",
"review_swift_performance",
"search_apple_technologies",
"get_apple_technology",
"get_apple_updates",
"plan_ios_app",
"plan_app_icon",
"search_local_references",
"read_local_reference",
"get_reference_outline",
"simulator_list",
"simulator_boot",
"simulator_shutdown",
"install_app",
"launch_app",
"terminate_app",
"open_deep_link",
"screenshot",
"build_project",
"run_tests",
"simulator_environment",
"simulator_show",
"simulator_preview_start",
"simulator_preview_stop",
"create_app",
"prepare_issue_report"
],
"stopHookExecuted": true,
"provider": "echo",
"modelSessionVerified": false,
"prePostHooksVerified": false,
"observerVerified": false
}
Acceptance and failure review
Checkpoint
What to inspect
If it does not match
Documented format
Confirm the input and environment
Preserve the failure and return to this step
Client install
Inspect the intermediate artifact
Preserve the failure and return to this step
Discovery
Run the focused check
Preserve the failure and return to this step
Bounded task
Record the observed result
Preserve the failure and return to this step
Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.
Evidence and limits
Antigravity packaging and a Muse model-driven session are blocked pending real client tests.
This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The
series evidence record
separates executed checks from exercises and blocked environments.
Inspect the source used in this lesson
.
Related reading
Which Muse Code MCP settings actually work with a local Swift review server?
A reproducible Muse 1.3.0 connection check, with the exact configuration and clear limits on what discovery proves.Gemini CLI: connect once and review one iOS feature
Avoid duplicate connections and keep setup evidence separate from app evidence.
What to do next
Next: Verifying a skill across clients: the client verification record method
---
# Verifying a skill across clients: the client verification…
https://nagarjuna2997.github.io/ios-agent-skill/series/7.8.html
← Guided series
·
All articlesLesson 7.8 · Guided learningVerifying a skill across clients: the client verification record methodOn this page
Work through the example
Implementation reference
5. The report format
Acceptance and failure review
Evidence and limits
Related reading
A client record is most useful when it says what did not run. Keep discovery, hook execution, model behavior and app acceptance as separate fields.
Environment → Command → Observed result → Explicit limits1
Environment
↓2
Command
↓3
Observed result
↓4
Explicit limits
Work through the example
Record versions and artifact paths without credentials or personal source. A boolean should name a concrete check, not vague compatibility.
Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.
Implementation reference
The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.
5. The report format
Every agent in this repository returns this shape:
VERDICT: <done | blocked | partial>
EVIDENCE
$ <command>
<real output>
$ <command>
<real output>
WHAT CHANGED
- path/to/File.swift:88 — <what and why>
NOT VERIFIED
- <claim> — <why it could not be checked>
FOLLOW-UPS
- <anything deliberately left, so nobody assumes it is covered>
NOT VERIFIED
is not optional. An empty section is fine; omitting the section
suggests everything was verified, which is rarely true.
Acceptance and failure review
Checkpoint
What to inspect
If it does not match
Environment
Confirm the input and environment
Preserve the failure and return to this step
Command
Inspect the intermediate artifact
Preserve the failure and return to this step
Observed result
Run the focused check
Preserve the failure and return to this step
Explicit limits
Record the observed result
Preserve the failure and return to this step
Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.
Evidence and limits
Existing records are dated snapshots, not guarantees for later versions.
This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The
series evidence record
separates executed checks from exercises and blocked environments.
Inspect the source used in this lesson
.
Related reading
Which Muse Code MCP settings actually work with a local Swift review server?
A reproducible Muse 1.3.0 connection check, with the exact configuration and clear limits on what discovery proves.Muse Code: what our integration actually verifies
Discovery and hooks are useful milestones, but they are not a finished app.
What to do next
Next: Measuring whether a skill helps: a paired benchmark protocol
---
# Measuring whether a skill helps: a paired benchmark…
https://nagarjuna2997.github.io/ios-agent-skill/series/7.9.html
← Guided series
·
All articlesLesson 7.9 · Guided learningMeasuring whether a skill helps: a paired benchmark protocolOn this page
Work through the example
Implementation reference
Protocol
Acceptance and failure review
Evidence and limits
Related reading
A paired benchmark compares a defined treatment against a baseline on the same tasks. Hidden acceptance checks must be independent of the agent's output, and failed runs must remain in the results.
Fixed tasks → Alternating conditions → Independent scorer → Report all outcomes1
Fixed tasks
↓2
Alternating conditions
↓3
Independent scorer
↓4
Report all outcomes
Work through the example
Compare within a client before comparing providers. Keep missing token counts unknown and report sample size and retries.
Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.
Implementation reference
The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.
Protocol
20 fixed prompts; one trial per client × prompt × condition.
Clients: Claude Code and Codex, using each account's default model without a model override.
Baseline gets the task and source-file constraints. Treatment additionally gets
the skill entry point plus local guides, templates, examples and retrieval scripts.
No MCP server or subagent bundle is supplied to the model. This tests the
guidance treatment, not the entire product.
Each task gets an empty
Sources/Solution.swift
, Swift 6 and Foundation.
No secrets or personal app source is used.
Hidden assertions are introduced after the client exits, compiled separately
with the generated file, and executed independently. The client cannot edit them.
Alternate baseline-first and skill-first by task. Each call has a timeout.
Claude uses safe-mode, no session persistence and file tools only; Codex uses
ephemeral mode, ignores user config and disables automatic project docs.
Client tools/default prompts still differ, so compare conditions within a client.
Report compilation, assertions, static review blockers and client-reported token
usage. Cache/input token accounting differs by client; do not compare raw token
totals across providers or convert them into savings/cost without pricing data.
Provider failures/timeouts are recorded separately from acceptance failures.
A missing token field or failed scorer is unknown, not zero.
Acceptance and failure review
Checkpoint
What to inspect
If it does not match
Fixed tasks
Confirm the input and environment
Preserve the failure and return to this step
Alternating conditions
Inspect the intermediate artifact
Preserve the failure and return to this step
Independent scorer
Run the focused check
Preserve the failure and return to this step
Report all outcomes
Record the observed result
Preserve the failure and return to this step
Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.
Evidence and limits
No new full benchmark run is completed here; no quality or token-saving percentage is invented.
This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The
series evidence record
separates executed checks from exercises and blocked environments.
Inspect the source used in this lesson
.
Related reading
Muse Code: what our integration actually verifies
Discovery and hooks are useful milestones, but they are not a finished app.Review AI-generated Swift before you trust it
A focused review, a small patch and a real test beat a confident completion message.
What to do next
Next: Preparing to verify Xcode 27 agents with external skills
---
# Ask for the finding, not the fix: why review-first…
https://nagarjuna2997.github.io/ios-agent-skill/series/8.1.html
← Guided series
·
All articlesLesson 8.1 · Guided learningAsk for the finding, not the fix: why review-first prompts produce better SwiftOn this page
Work through the example
One practical example
Apply it to your project
Evidence and limits
Related reading
Ask for a located finding before asking for a rewrite. The first useful question is what source behavior violates the requirement, not how many lines the agent can change.
Requirement → Finding location → Context → Minimal patch1
Requirement
↓2
Finding location
↓3
Context
↓4
Minimal patch
Work through the example
Prompt: Show one finding, its file and line, and the smallest test that would distinguish a real bug from a false positive.
One practical example
Use the prompt above on one small, named part of your app. Before accepting an edit, ask the agent to point to the source or configuration that supports its answer. If it cannot locate that context, resolve the missing input before expanding the task.
In the diagram, each arrow represents a handoff you can inspect. Keep the intermediate result rather than jumping directly to the final claim. For example, a reported finding should retain its location, and an executed check should retain its outcome. This gives the next attempt a concrete starting point and makes a misleading completion message easier to challenge.
Apply it to your project
Prompt: Show one finding, its file and line, and the smallest test that would distinguish a real bug from a false positive. Then compare the resulting diff with the original request. If it changed a neighboring concern, ask why that change was necessary. Save the result only after the relevant check has run, and record any part that remains unverified.
Evidence and limits
A review-first prompt is a technique, not a measured universal quality gain.
This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The
series evidence record
separates executed checks from exercises and blocked environments.
Inspect the source used in this lesson
.
Related reading
Review AI-generated Swift before you trust it
A focused review, a small patch and a real test beat a confident completion message.Claude Code: turn a Swift finding into a tested patch
A small review-to-test loop for an existing iOS project.
What to do next
Next: Ask about actor ownership before adding MainActor
---
# When the agent says "done", run the tests yourself once
https://nagarjuna2997.github.io/ios-agent-skill/series/8.10.html
← Guided series
·
All articlesLesson 8.10 · Guided learningWhen the agent says "done", run the tests yourself onceOn this page
Work through the example
One practical example
Apply it to your project
Evidence and limits
Related reading
An independent rerun tests whether the completion claim survives outside the agent's narrative. Use the committed command and inspect the actual result.
Completion claim → Exact command → Fresh result → Accept or reopen1
Completion claim
↓2
Exact command
↓3
Fresh result
↓4
Accept or reopen
Work through the example
Prompt: List the commands you ran and their outcomes. I will rerun the relevant acceptance check before accepting the change.
One practical example
Use the prompt above on one small, named part of your app. Before accepting an edit, ask the agent to point to the source or configuration that supports its answer. If it cannot locate that context, resolve the missing input before expanding the task.
In the diagram, each arrow represents a handoff you can inspect. Keep the intermediate result rather than jumping directly to the final claim. For example, a reported finding should retain its location, and an executed check should retain its outcome. This gives the next attempt a concrete starting point and makes a misleading completion message easier to challenge.
Apply it to your project
Prompt: List the commands you ran and their outcomes. I will rerun the relevant acceptance check before accepting the change. Then compare the resulting diff with the original request. If it changed a neighboring concern, ask why that change was necessary. Save the result only after the relevant check has run, and record any part that remains unverified.
Evidence and limits
Passing selected tests does not prove untested paths.
This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The
series evidence record
separates executed checks from exercises and blocked environments.
Inspect the source used in this lesson
.
Related reading
Review AI-generated Swift before you trust it
A focused review, a small patch and a real test beat a confident completion message.Five checks for an AI-built SwiftUI app
Use persistence, search and accessible states to make “it works” a testable claim.
What to do next
Next: Small prompts, one screen at a time, commit between each
---
# Small prompts, one screen at a time, commit between each
https://nagarjuna2997.github.io/ios-agent-skill/series/8.11.html
← Guided series
·
All articlesLesson 8.11 · Guided learningSmall prompts, one screen at a time, commit between eachOn this page
Work through the example
One practical example
Apply it to your project
Evidence and limits
Related reading
A small prompt with one observable goal makes the repair loop easier to control. A checkpoint preserves what worked before the next change.
One goal → Small diff → Acceptance check → Checkpoint1
One goal
↓2
Small diff
↓3
Acceptance check
↓4
Checkpoint
Work through the example
Prompt: Implement only the empty-state recovery action. Keep navigation and storage unchanged unless required and explained.
One practical example
Use the prompt above on one small, named part of your app. Before accepting an edit, ask the agent to point to the source or configuration that supports its answer. If it cannot locate that context, resolve the missing input before expanding the task.
In the diagram, each arrow represents a handoff you can inspect. Keep the intermediate result rather than jumping directly to the final claim. For example, a reported finding should retain its location, and an executed check should retain its outcome. This gives the next attempt a concrete starting point and makes a misleading completion message easier to challenge.
Apply it to your project
Prompt: Implement only the empty-state recovery action. Keep navigation and storage unchanged unless required and explained. Then compare the resulting diff with the original request. If it changed a neighboring concern, ask why that change was necessary. Save the result only after the relevant check has run, and record any part that remains unverified.
Evidence and limits
Small work units can still require careful integration testing.
This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The
series evidence record
separates executed checks from exercises and blocked environments.
Inspect the source used in this lesson
.
Related reading
Claude Code: turn a Swift finding into a tested patch
A small review-to-test loop for an existing iOS project.Gemini CLI: connect once and review one iOS feature
Avoid duplicate connections and keep setup evidence separate from app evidence.
What to do next
Next: Keep signing and account changes separate from code repairs
---
# Keep signing and account changes separate from code repairs
https://nagarjuna2997.github.io/ios-agent-skill/series/8.12.html
← Guided series
·
All articlesLesson 8.12 · Guided learningKeep signing and account changes separate from code repairsOn this page
Work through the example
One practical example
Apply it to your project
Evidence and limits
Related reading
Keep account authority separate from code editing. Signing changes should be deliberate, reviewed and performed through the appropriate local account flow.
Code task → Need account change? → Explicit review → Local configuration1
Code task
↓2
Need account change?
↓3
Explicit review
↓4
Local configuration
Work through the example
Prompt: Do not change signing or provisioning to hide this build error. Explain whether the selected simulator destination needs signing.
One practical example
Use the prompt above on one small, named part of your app. Before accepting an edit, ask the agent to point to the source or configuration that supports its answer. If it cannot locate that context, resolve the missing input before expanding the task.
In the diagram, each arrow represents a handoff you can inspect. Keep the intermediate result rather than jumping directly to the final claim. For example, a reported finding should retain its location, and an executed check should retain its outcome. This gives the next attempt a concrete starting point and makes a misleading completion message easier to challenge.
Apply it to your project
Prompt: Do not change signing or provisioning to hide this build error. Explain whether the selected simulator destination needs signing. Then compare the resulting diff with the original request. If it changed a neighboring concern, ask why that change was necessary. Save the result only after the relevant check has run, and record any part that remains unverified.
Evidence and limits
An absolute ban is not necessary for every authorized workflow; secret values still stay out of chat and source.
This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The
series evidence record
separates executed checks from exercises and blocked environments.
Inspect the source used in this lesson
.
Related reading
ChatGPT and Codex: plan the app, then verify it locally
Separate a browser planning session from a connected development environment.
What to do next
Next: Reading agent transcripts: the three phrases that mean it guessed
---
# Reading agent transcripts: the three phrases that mean it…
https://nagarjuna2997.github.io/ios-agent-skill/series/8.13.html
← Guided series
·
All articlesLesson 8.13 · Guided learningReading agent transcripts: the three phrases that mean it guessedOn this page
Work through the example
One practical example
Apply it to your project
Evidence and limits
Related reading
Phrases such as “should work” and “likely fixed” express uncertainty, not execution. Ask what observation supports the conclusion rather than treating confidence as proof.
Claim → Supporting artifact → Missing check → Follow-up1
Claim
↓2
Supporting artifact
↓3
Missing check
↓4
Follow-up
Work through the example
Prompt: Separate what you inspected from what you executed, and list the unresolved checks.
One practical example
Use the prompt above on one small, named part of your app. Before accepting an edit, ask the agent to point to the source or configuration that supports its answer. If it cannot locate that context, resolve the missing input before expanding the task.
In the diagram, each arrow represents a handoff you can inspect. Keep the intermediate result rather than jumping directly to the final claim. For example, a reported finding should retain its location, and an executed check should retain its outcome. This gives the next attempt a concrete starting point and makes a misleading completion message easier to challenge.
Apply it to your project
Prompt: Separate what you inspected from what you executed, and list the unresolved checks. Then compare the resulting diff with the original request. If it changed a neighboring concern, ask why that change was necessary. Save the result only after the relevant check has run, and record any part that remains unverified.
Evidence and limits
Wording alone cannot prove deception or correctness; inspect the evidence.
This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The
series evidence record
separates executed checks from exercises and blocked environments.
Inspect the source used in this lesson
.
Related reading
Muse Code: what our integration actually verifies
Discovery and hooks are useful milestones, but they are not a finished app.Review AI-generated Swift before you trust it
A focused review, a small patch and a real test beat a confident completion message.
What to do next
Next: Turn every failure into a rule: how findings become reviews
---
# Turn every failure into a rule: how findings become reviews
https://nagarjuna2997.github.io/ios-agent-skill/series/8.14.html
← Guided series
·
All articlesLesson 8.14 · Guided learningTurn every failure into a rule: how findings become reviewsOn this page
Work through the example
One practical example
Apply it to your project
Evidence and limits
Related reading
A repeated failure can become a regression fixture before it becomes a broad rule. Start with the smallest reproduction and a nearby example that should remain valid.
Failure → Minimal fixture → Negative case → Review rule1
Failure
↓2
Minimal fixture
↓3
Negative case
↓4
Review rule
Work through the example
Prompt: Reduce this finding to synthetic source and identify a similar pattern that must not trigger.
One practical example
Use the prompt above on one small, named part of your app. Before accepting an edit, ask the agent to point to the source or configuration that supports its answer. If it cannot locate that context, resolve the missing input before expanding the task.
In the diagram, each arrow represents a handoff you can inspect. Keep the intermediate result rather than jumping directly to the final claim. For example, a reported finding should retain its location, and an executed check should retain its outcome. This gives the next attempt a concrete starting point and makes a misleading completion message easier to challenge.
Apply it to your project
Prompt: Reduce this finding to synthetic source and identify a similar pattern that must not trigger. Then compare the resulting diff with the original request. If it changed a neighboring concern, ask why that change was necessary. Save the result only after the relevant check has run, and record any part that remains unverified.
Evidence and limits
Do not upload private app code to build a public fixture.
This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The
series evidence record
separates executed checks from exercises and blocked environments.
Inspect the source used in this lesson
.
Related reading
How can I catch Swift concurrency review findings before accepting an agent’s changes?
A real before-and-after MCP review with file and line evidence, plus the limits of text-based concurrency checks.
What to do next
Next: Verify Muse observer behavior before recommending it
---
# Verify Muse observer behavior before recommending it
https://nagarjuna2997.github.io/ios-agent-skill/series/8.15.html
← Guided series
·
All articlesLesson 8.15 · Guided learningVerify Muse observer behavior before recommending itOn this page
Work through the example
One practical example
Apply it to your project
Evidence and limits
Related reading
A verification observer is useful only when its actual behavior is known. The current Muse record explicitly does not establish observer behavior.
Feature claim → Official schema → Controlled failure → Observed response1
Feature claim
↓2
Official schema
↓3
Controlled failure
↓4
Observed response
Work through the example
Prompt: Identify the documented observer setting and demonstrate it on a failing synthetic task before recommending it.
One practical example
Use the prompt above on one small, named part of your app. Before accepting an edit, ask the agent to point to the source or configuration that supports its answer. If it cannot locate that context, resolve the missing input before expanding the task.
In the diagram, each arrow represents a handoff you can inspect. Keep the intermediate result rather than jumping directly to the final claim. For example, a reported finding should retain its location, and an executed check should retain its outcome. This gives the next attempt a concrete starting point and makes a misleading completion message easier to challenge.
Apply it to your project
Prompt: Identify the documented observer setting and demonstrate it on a failing synthetic task before recommending it. Then compare the resulting diff with the original request. If it changed a neighboring concern, ask why that change was necessary. Save the result only after the relevant check has run, and record any part that remains unverified.
Evidence and limits
Observer enablement instructions are blocked until verified; no setting is guessed.
This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The
series evidence record
separates executed checks from exercises and blocked environments.
Inspect the source used in this lesson
.
Related reading
Which Muse Code MCP settings actually work with a local Swift review server?
A reproducible Muse 1.3.0 connection check, with the exact configuration and clear limits on what discovery proves.Muse Code: what our integration actually verifies
Discovery and hooks are useful milestones, but they are not a finished app.
What to do next
Next: Codex worktrees for parallel screens
---
# Codex worktrees for parallel screens
https://nagarjuna2997.github.io/ios-agent-skill/series/8.16.html
← Guided series
·
All articlesLesson 8.16 · Guided learningCodex worktrees for parallel screensOn this page
Work through the example
One practical example
Apply it to your project
Use the documented client workflow
Evidence and limits
Related reading
Worktrees let independent code tasks use separate checkouts. They do not merge the results or coordinate every external resource for you.
Shared baseline → Separate checkout → Independent task → Reviewed integration1
Shared baseline
↓2
Separate checkout
↓3
Independent task
↓4
Reviewed integration
Work through the example
Prompt: Keep this screen's changes in its own worktree and report the base commit, diff and tests.
One practical example
Use the prompt above on one small, named part of your app. Before accepting an edit, ask the agent to point to the source or configuration that supports its answer. If it cannot locate that context, resolve the missing input before expanding the task.
In the diagram, each arrow represents a handoff you can inspect. Keep the intermediate result rather than jumping directly to the final claim. For example, a reported finding should retain its location, and an executed check should retain its outcome. This gives the next attempt a concrete starting point and makes a misleading completion message easier to challenge.
Apply it to your project
Prompt: Keep this screen's changes in its own worktree and report the base commit, diff and tests. Then compare the resulting diff with the original request. If it changed a neighboring concern, ask why that change was necessary. Save the result only after the relevant check has run, and record any part that remains unverified.
Use the documented client workflow
For the client-specific setup, consult
OpenAI’s worktree documentation
. The important integration step is reviewing the finished changes back against the intended base. An isolated checkout does not automatically settle competing edits to shared design tokens.
Evidence and limits
Resolve shared simulator and data-store ownership separately.
This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The
series evidence record
separates executed checks from exercises and blocked environments.
Inspect the source used in this lesson
.
Related reading
ChatGPT and Codex: plan the app, then verify it locally
Separate a browser planning session from a connected development environment.
What to do next
Next: Treat background builds as running work, not completed tasks
---
# Treat background builds as running work, not completed…
https://nagarjuna2997.github.io/ios-agent-skill/series/8.17.html
← Guided series
·
All articlesLesson 8.17 · Guided learningTreat background builds as running work, not completed tasksOn this page
Work through the example
One practical example
Apply it to your project
Evidence and limits
Related reading
A background build needs clear ownership, logs and a completion signal. Do not equate generic process execution with a specific client's subagent feature.
Start build → Retain logs → Wait for completion → Inspect exit status1
Start build
↓2
Retain logs
↓3
Wait for completion
↓4
Inspect exit status
Work through the example
Prompt: Report the running command and final exit status; do not mark the task complete while the build is still running.
One practical example
Use the prompt above on one small, named part of your app. Before accepting an edit, ask the agent to point to the source or configuration that supports its answer. If it cannot locate that context, resolve the missing input before expanding the task.
In the diagram, each arrow represents a handoff you can inspect. Keep the intermediate result rather than jumping directly to the final claim. For example, a reported finding should retain its location, and an executed check should retain its outcome. This gives the next attempt a concrete starting point and makes a misleading completion message easier to challenge.
Apply it to your project
Prompt: Report the running command and final exit status; do not mark the task complete while the build is still running. Then compare the resulting diff with the original request. If it changed a neighboring concern, ask why that change was necessary. Save the result only after the relevant check has run, and record any part that remains unverified.
Evidence and limits
Antigravity background-subagent behavior is unverified here, so no client-specific command is provided.
This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The
series evidence record
separates executed checks from exercises and blocked environments.
Inspect the source used in this lesson
.
Related reading
How can Claude Code hooks protect generated files and verify a project before stopping?
A tested generated-file guard and Stop check, with configuration, reproducible exit codes, and enforcement limits.Muse Code: what our integration actually verifies
Discovery and hooks are useful milestones, but they are not a finished app.
What to do next
Next: Claude Code plan mode before any multi-file Swift change
---
# Claude Code plan mode before any multi-file Swift change
https://nagarjuna2997.github.io/ios-agent-skill/series/8.18.html
← Guided series
·
All articlesLesson 8.18 · Guided learningClaude Code plan mode before any multi-file Swift changeOn this page
Work through the example
One practical example
Apply it to your project
Evidence and limits
Related reading
Planning before a multi-file edit exposes dependencies and unresolved choices. A plan is valuable when it narrows the next action, not when it repeats the prompt in more words.
Inspect files → Identify dependencies → Agree criteria → Edit one slice1
Inspect files
↓2
Identify dependencies
↓3
Agree criteria
↓4
Edit one slice
Work through the example
Prompt: Before editing, list the files involved, the behavior to preserve and the checks that determine completion.
One practical example
Use the prompt above on one small, named part of your app. Before accepting an edit, ask the agent to point to the source or configuration that supports its answer. If it cannot locate that context, resolve the missing input before expanding the task.
In the diagram, each arrow represents a handoff you can inspect. Keep the intermediate result rather than jumping directly to the final claim. For example, a reported finding should retain its location, and an executed check should retain its outcome. This gives the next attempt a concrete starting point and makes a misleading completion message easier to challenge.
Apply it to your project
Prompt: Before editing, list the files involved, the behavior to preserve and the checks that determine completion. Then compare the resulting diff with the original request. If it changed a neighboring concern, ask why that change was necessary. Save the result only after the relevant check has run, and record any part that remains unverified.
Evidence and limits
Planning does not itself validate implementation or guarantee lower cost.
This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The
series evidence record
separates executed checks from exercises and blocked environments.
Inspect the source used in this lesson
.
Related reading
Claude Code: turn a Swift finding into a tested patch
A small review-to-test loop for an existing iOS project.
What to do next
Next: Ask the agent to search local Apple references before the web
---
# Ask the agent to search local Apple references before the…
https://nagarjuna2997.github.io/ios-agent-skill/series/8.19.html
← Guided series
·
All articlesLesson 8.19 · Guided learningAsk the agent to search local Apple references before the webOn this page
Work through the example
One practical example
Apply it to your project
Evidence and limits
Related reading
Local references provide project-specific context and reproducible examples. When they lack an answer, disclose the gap and distinguish external research from repository guidance.
Focused local search → Read source section → Identify gap → Check primary source1
Focused local search
↓2
Read source section
↓3
Identify gap
↓4
Check primary source
Work through the example
Prompt: Search the local library for this API first; cite the relevant file and say if the answer needs current Apple documentation.
One practical example
Use the prompt above on one small, named part of your app. Before accepting an edit, ask the agent to point to the source or configuration that supports its answer. If it cannot locate that context, resolve the missing input before expanding the task.
In the diagram, each arrow represents a handoff you can inspect. Keep the intermediate result rather than jumping directly to the final claim. For example, a reported finding should retain its location, and an executed check should retain its outcome. This gives the next attempt a concrete starting point and makes a misleading completion message easier to challenge.
Apply it to your project
Prompt: Search the local library for this API first; cite the relevant file and say if the answer needs current Apple documentation. Then compare the resulting diff with the original request. If it changed a neighboring concern, ask why that change was necessary. Save the result only after the relevant check has run, and record any part that remains unverified.
Evidence and limits
A local catalog is dated and is not Apple's proprietary framework implementation.
This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The
series evidence record
separates executed checks from exercises and blocked environments.
Inspect the source used in this lesson
.
Related reading
Choose an AI client for your iOS workflow
Understand the difference between a skill, a local MCP connection and ChatGPT web setup.Gemini CLI: connect once and review one iOS feature
Avoid duplicate connections and keep setup evidence separate from app evidence.
What to do next
Next: What to put in .gitignore for agent-built Xcode projects
---
# Ask about actor ownership before adding MainActor
https://nagarjuna2997.github.io/ios-agent-skill/series/8.2.html
← Guided series
·
All articlesLesson 8.2 · Guided learningAsk about actor ownership before adding MainActorOn this page
Work through the example
One practical example
Apply it to your project
Evidence and limits
Related reading
MainActor is an isolation choice, not a universal concurrency repair. Inspect ownership and work placement before applying an annotation.
State owner → Mutation sites → Isolation decision → Compiler check1
State owner
↓2
Mutation sites
↓3
Isolation decision
↓4
Compiler check
Work through the example
Prompt: Identify who owns this UI-observed state and where it is mutated. Explain the actor boundary before changing annotations.
One practical example
Use the prompt above on one small, named part of your app. Before accepting an edit, ask the agent to point to the source or configuration that supports its answer. If it cannot locate that context, resolve the missing input before expanding the task.
In the diagram, each arrow represents a handoff you can inspect. Keep the intermediate result rather than jumping directly to the final claim. For example, a reported finding should retain its location, and an executed check should retain its outcome. This gives the next attempt a concrete starting point and makes a misleading completion message easier to challenge.
Apply it to your project
Prompt: Identify who owns this UI-observed state and where it is mutated. Explain the actor boundary before changing annotations. Then compare the resulting diff with the original request. If it changed a neighboring concern, ask why that change was necessary. Save the result only after the relevant check has run, and record any part that remains unverified.
Evidence and limits
No evidence supports the original claim that one prompt fixes half of all concurrency errors.
This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The
series evidence record
separates executed checks from exercises and blocked environments.
Inspect the source used in this lesson
.
Related reading
How can I catch Swift concurrency review findings before accepting an agent’s changes?
A real before-and-after MCP review with file and line evidence, plus the limits of text-based concurrency checks.
What to do next
Next: "Show me the screenshot" as a standing instruction
---
# What to put in .gitignore for agent-built Xcode projects
https://nagarjuna2997.github.io/ios-agent-skill/series/8.20.html
← Guided series
·
All articlesLesson 8.20 · Guided learningWhat to put in .gitignore for agent-built Xcode projectsOn this page
Work through the example
One practical example
Apply it to your project
Evidence and limits
Related reading
A useful gitignore keeps generated artifacts and private local state out of commits without hiding reproducible inputs. Treat every new ignore pattern as a scope decision.
Classify file → Source or generated? → Ignore narrowly → Inspect git status1
Classify file
↓2
Source or generated?
↓3
Ignore narrowly
↓4
Inspect git status
Work through the example
Prompt: Propose ignore rules for build output and local evidence, but keep project specs, tests and shared configuration tracked.
One practical example
Use the prompt above on one small, named part of your app. Before accepting an edit, ask the agent to point to the source or configuration that supports its answer. If it cannot locate that context, resolve the missing input before expanding the task.
In the diagram, each arrow represents a handoff you can inspect. Keep the intermediate result rather than jumping directly to the final claim. For example, a reported finding should retain its location, and an executed check should retain its outcome. This gives the next attempt a concrete starting point and makes a misleading completion message easier to challenge.
Apply it to your project
Prompt: Propose ignore rules for build output and local evidence, but keep project specs, tests and shared configuration tracked. Then compare the resulting diff with the original request. If it changed a neighboring concern, ask why that change was necessary. Save the result only after the relevant check has run, and record any part that remains unverified.
Evidence and limits
Ignoring a secret after committing it does not remove it from history.
This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The
series evidence record
separates executed checks from exercises and blocked environments.
Inspect the source used in this lesson
.
Related reading
How can Claude Code hooks protect generated files and verify a project before stopping?
A tested generated-file guard and Stop check, with configuration, reproducible exit codes, and enforcement limits.
What to do next
Next: All levels
---
# "Show me the screenshot" as a standing instruction
https://nagarjuna2997.github.io/ios-agent-skill/series/8.3.html
← Guided series
·
All articlesLesson 8.3 · Guided learning"Show me the screenshot" as a standing instructionOn this page
Work through the example
One practical example
Apply it to your project
Evidence and limits
Related reading
Ask for the screenshot of a named state and a named run destination. An unspecified screenshot can show a launch screen, stale build or unrelated simulator.
Named state → Reproduction steps → Capture → Compare1
Named state
↓2
Reproduction steps
↓3
Capture
↓4
Compare
Work through the example
Prompt: Capture the empty-search state after entering a query with no matches. State the runtime and build you launched.
One practical example
Use the prompt above on one small, named part of your app. Before accepting an edit, ask the agent to point to the source or configuration that supports its answer. If it cannot locate that context, resolve the missing input before expanding the task.
In the diagram, each arrow represents a handoff you can inspect. Keep the intermediate result rather than jumping directly to the final claim. For example, a reported finding should retain its location, and an executed check should retain its outcome. This gives the next attempt a concrete starting point and makes a misleading completion message easier to challenge.
Apply it to your project
Prompt: Capture the empty-search state after entering a query with no matches. State the runtime and build you launched. Then compare the resulting diff with the original request. If it changed a neighboring concern, ask why that change was necessary. Save the result only after the relevant check has run, and record any part that remains unverified.
Evidence and limits
A screenshot is visual evidence, not proof of every behavior.
This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The
series evidence record
separates executed checks from exercises and blocked environments.
Inspect the source used in this lesson
.
Related reading
Five checks for an AI-built SwiftUI app
Use persistence, search and accessible states to make “it works” a testable claim.
What to do next
Next: Pin your Xcode version in AGENTS.md and stop the agent guessing APIs
---
# Pin your Xcode version in AGENTS.md and stop the agent…
https://nagarjuna2997.github.io/ios-agent-skill/series/8.4.html
← Guided series
·
All articlesLesson 8.4 · Guided learningPin your Xcode version in AGENTS.md and stop the agent guessing APIsOn this page
Work through the example
One practical example
Apply it to your project
Evidence and limits
Related reading
A toolchain version in project instructions gives the agent a concrete compatibility target. It does not prevent the model from guessing, so require compilation against that target.
Recorded toolchain → API choice → Build → Availability review1
Recorded toolchain
↓2
API choice
↓3
Build
↓4
Availability review
Work through the example
Prompt: Use the installed Xcode version recorded here. If an API needs a newer SDK, explain the fallback before editing.
One practical example
Use the prompt above on one small, named part of your app. Before accepting an edit, ask the agent to point to the source or configuration that supports its answer. If it cannot locate that context, resolve the missing input before expanding the task.
In the diagram, each arrow represents a handoff you can inspect. Keep the intermediate result rather than jumping directly to the final claim. For example, a reported finding should retain its location, and an executed check should retain its outcome. This gives the next attempt a concrete starting point and makes a misleading completion message easier to challenge.
Apply it to your project
Prompt: Use the installed Xcode version recorded here. If an API needs a newer SDK, explain the fallback before editing. Then compare the resulting diff with the original request. If it changed a neighboring concern, ask why that change was necessary. Save the result only after the relevant check has run, and record any part that remains unverified.
Evidence and limits
Do not label an API available solely because it appears in a newer guide.
This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The
series evidence record
separates executed checks from exercises and blocked environments.
Inspect the source used in this lesson
.
Related reading
How should I review availability guards when moving a Swift app toward iOS 27?
A measured guard-review example that separates SDK availability, runtime readiness, and static-analysis limits.
What to do next
Next: One deep link per screen makes every screenshot automatable
---
# One deep link per screen makes every screenshot automatable
https://nagarjuna2997.github.io/ios-agent-skill/series/8.5.html
← Guided series
·
All articlesLesson 8.5 · Guided learningOne deep link per screen makes every screenshot automatableOn this page
Work through the example
One practical example
Apply it to your project
Evidence and limits
Related reading
A deep link can shorten a screenshot workflow when it resolves to deterministic test state. It cannot create missing data or bypass legitimate access checks.
Validated URL → Seeded item → Destination → Screenshot1
Validated URL
↓2
Seeded item
↓3
Destination
↓4
Screenshot
Work through the example
Prompt: Add a test route to this screen using a synthetic identifier and an explicit not-found state.
One practical example
Use the prompt above on one small, named part of your app. Before accepting an edit, ask the agent to point to the source or configuration that supports its answer. If it cannot locate that context, resolve the missing input before expanding the task.
In the diagram, each arrow represents a handoff you can inspect. Keep the intermediate result rather than jumping directly to the final claim. For example, a reported finding should retain its location, and an executed check should retain its outcome. This gives the next attempt a concrete starting point and makes a misleading completion message easier to challenge.
Apply it to your project
Prompt: Add a test route to this screen using a synthetic identifier and an explicit not-found state. Then compare the resulting diff with the original request. If it changed a neighboring concern, ask why that change was necessary. Save the result only after the relevant check has run, and record any part that remains unverified.
Evidence and limits
Not every screen should expose a public route; keep test hooks appropriately scoped.
This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The
series evidence record
separates executed checks from exercises and blocked environments.
Inspect the source used in this lesson
.
Related reading
Five checks for an AI-built SwiftUI app
Use persistence, search and accessible states to make “it works” a testable claim.
What to do next
Next: Keep design tokens in one file and tell the agent it's the only source of color
---
# Keep design tokens in one file and tell the agent it's…
https://nagarjuna2997.github.io/ios-agent-skill/series/8.6.html
← Guided series
·
All articlesLesson 8.6 · Guided learningKeep design tokens in one file and tell the agent it's the only source of colorOn this page
Work through the example
One practical example
Apply it to your project
Evidence and limits
Related reading
A single token source reduces accidental divergence between screens. Semantic roles let a design change update meaning consistently instead of replacing random hex values.
Token source → Named asset → View use → Appearance check1
Token source
↓2
Named asset
↓3
View use
↓4
Appearance check
Work through the example
Prompt: Use the existing semantic color roles. If a role is missing, propose it with all appearances before adding a literal.
One practical example
Use the prompt above on one small, named part of your app. Before accepting an edit, ask the agent to point to the source or configuration that supports its answer. If it cannot locate that context, resolve the missing input before expanding the task.
In the diagram, each arrow represents a handoff you can inspect. Keep the intermediate result rather than jumping directly to the final claim. For example, a reported finding should retain its location, and an executed check should retain its outcome. This gives the next attempt a concrete starting point and makes a misleading completion message easier to challenge.
Apply it to your project
Prompt: Use the existing semantic color roles. If a role is missing, propose it with all appearances before adding a literal. Then compare the resulting diff with the original request. If it changed a neighboring concern, ask why that change was necessary. Save the result only after the relevant check has run, and record any part that remains unverified.
Evidence and limits
Centralized tokens do not guarantee contrast or visual quality.
This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The
series evidence record
separates executed checks from exercises and blocked environments.
Inspect the source used in this lesson
.
Related reading
How can design tokens generate light, dark and high-contrast color assets?
Generate a real xcassets catalog from explicit color tokens, inspect its four variants, and compile it with actool.
What to do next
Next: Review the XcodeGen spec before regenerating the project
---
# Review the XcodeGen spec before regenerating the project
https://nagarjuna2997.github.io/ios-agent-skill/series/8.7.html
← Guided series
·
All articlesLesson 8.7 · Guided learningReview the XcodeGen spec before regenerating the projectOn this page
Work through the example
One practical example
Apply it to your project
Evidence and limits
Related reading
A readable project specification makes generated changes easier to review. The project file still matters, but regeneration should be intentional and reproducible.
Spec change → Generator → Project diff → Build1
Spec change
↓2
Generator
↓3
Project diff
↓4
Build
Work through the example
Prompt: Explain the target change in project.yml, regenerate with the documented tool, then show the resulting project diff.
One practical example
Use the prompt above on one small, named part of your app. Before accepting an edit, ask the agent to point to the source or configuration that supports its answer. If it cannot locate that context, resolve the missing input before expanding the task.
In the diagram, each arrow represents a handoff you can inspect. Keep the intermediate result rather than jumping directly to the final claim. For example, a reported finding should retain its location, and an executed check should retain its outcome. This gives the next attempt a concrete starting point and makes a misleading completion message easier to challenge.
Apply it to your project
Prompt: Explain the target change in project.yml, regenerate with the documented tool, then show the resulting project diff. Then compare the resulting diff with the original request. If it changed a neighboring concern, ask why that change was necessary. Save the result only after the relevant check has run, and record any part that remains unverified.
Evidence and limits
Do not regenerate with an unrecorded tool version or overwrite unrelated manual changes.
This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The
series evidence record
separates executed checks from exercises and blocked environments.
Inspect the source used in this lesson
.
Related reading
ChatGPT and Codex: plan the app, then verify it locally
Separate a browser planning session from a connected development environment.
What to do next
Next: Use `#available` audits before every beta upgrade
---
# Use `#available` audits before every beta upgrade
https://nagarjuna2997.github.io/ios-agent-skill/series/8.8.html
← Guided series
·
All articlesLesson 8.8 · Guided learningUse `#available` audits before every beta upgradeOn this page
Work through the example
One practical example
Apply it to your project
Evidence and limits
Related reading
Availability audits should distinguish SDK introduction, deployment target and runtime capability. A guard can be syntactically present and still protect the wrong boundary.
API introduction → Deployment target → Runtime readiness → Fallback1
API introduction
↓2
Deployment target
↓3
Runtime readiness
↓4
Fallback
Work through the example
Prompt: Show the availability assumption and the fallback behavior for each newly introduced API.
One practical example
Use the prompt above on one small, named part of your app. Before accepting an edit, ask the agent to point to the source or configuration that supports its answer. If it cannot locate that context, resolve the missing input before expanding the task.
In the diagram, each arrow represents a handoff you can inspect. Keep the intermediate result rather than jumping directly to the final claim. For example, a reported finding should retain its location, and an executed check should retain its outcome. This gives the next attempt a concrete starting point and makes a misleading completion message easier to challenge.
Apply it to your project
Prompt: Show the availability assumption and the fallback behavior for each newly introduced API. Then compare the resulting diff with the original request. If it changed a neighboring concern, ask why that change was necessary. Save the result only after the relevant check has run, and record any part that remains unverified.
Evidence and limits
Text-based guard checks cannot model every control-flow path.
This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The
series evidence record
separates executed checks from exercises and blocked environments.
Inspect the source used in this lesson
.
Related reading
How should I review availability guards when moving a Swift app toward iOS 27?
A measured guard-review example that separates SDK availability, runtime readiness, and static-analysis limits.
What to do next
Next: Hooks beat reminders: enforce, don't request
---
# Hooks beat reminders: enforce, don't request
https://nagarjuna2997.github.io/ios-agent-skill/series/8.9.html
← Guided series
·
All articlesLesson 8.9 · Guided learningHooks beat reminders: enforce, don't requestOn this page
Work through the example
One practical example
Apply it to your project
Evidence and limits
Related reading
Use a hook for a repeatable event check and instructions for the reason behind it. Keep the handler small enough that a failure can be diagnosed from its exit status.
Lifecycle event → Handler → Exit status → Explain result1
Lifecycle event
↓2
Handler
↓3
Exit status
↓4
Explain result
Work through the example
Prompt: Demonstrate one passing and one failing hook case using synthetic files.
One practical example
Use the prompt above on one small, named part of your app. Before accepting an edit, ask the agent to point to the source or configuration that supports its answer. If it cannot locate that context, resolve the missing input before expanding the task.
In the diagram, each arrow represents a handoff you can inspect. Keep the intermediate result rather than jumping directly to the final claim. For example, a reported finding should retain its location, and an executed check should retain its outcome. This gives the next attempt a concrete starting point and makes a misleading completion message easier to challenge.
Apply it to your project
Prompt: Demonstrate one passing and one failing hook case using synthetic files. Then compare the resulting diff with the original request. If it changed a neighboring concern, ask why that change was necessary. Save the result only after the relevant check has run, and record any part that remains unverified.
Evidence and limits
Hook schemas and exit behavior are client-specific; copying JSON does not verify dispatch.
This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The
series evidence record
separates executed checks from exercises and blocked environments.
Inspect the source used in this lesson
.
Related reading
How can Claude Code hooks protect generated files and verify a project before stopping?
A tested generated-file guard and Stop check, with configuration, reproducible exit codes, and enforcement limits.
What to do next
Next: When the agent says "done", run the tests yourself once