skills/ios-rules/SKILL.md
38 battle-tested iOS development rules covering accessibility, navigation, architecture, dark mode, localization, App Review guidelines, and more. Targets the mistakes LLMs actually make when generating Swift/SwiftUI code.
npx skillsauth add abdullah4ai/apple-developer-toolkit ios-rulesInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
38 rules for writing production-quality iOS apps. Each rule targets common LLM mistakes with concrete fixes.
REDUCE MOTION:
withAnimation(reduceMotion ? .easeInOut(duration: 0.2) : .spring(response: 0.3)) {
// state change
}
.transition(reduceMotion ? .opacity : .slide)
REDUCE TRANSPARENCY:
.background(reduceTransparency ? Color(AppTheme.Colors.surface) : .ultraThinMaterial)
VOICEOVER LABELS:
Button(action: addItem) {
Image(systemName: "plus")
}
.accessibilityLabel("Add new item")
GROUPING & COMBINING:
HStack {
Image(systemName: "heart.fill")
Text("Favorites")
Spacer()
Text("12")
}
.accessibilityElement(children: .combine)
ACCESSIBILITY TRAITS:
FOCUS MANAGEMENT:
enum Field: Hashable { case name, email, password }
@FocusState private var focusedField: Field?
TextField("Name", text: $name)
.focused($focusedField, equals: .name)
.submitLabel(.next)
.onSubmit { focusedField = .email }
DYNAMIC TYPE:
COLOR & CONTRAST:
ACCESSIBLE CUSTOM CONTROLS:
APP CLIPS: SETUP: Requires separate App Clip target (kind: "app_clip" in plan extensions array). App Clips are a lightweight version of your app for quick, focused tasks.
INFO.PLIST (auto-configured on App Clip target in project.yml): NSAppClip dict with NSAppClipRequestEphemeralUserNotification and NSAppClipRequestLocationConfirmation is set automatically. No manual configuration needed.
ASSOCIATED DOMAINS (auto-configured in project.yml entitlements): appclips:{bundleID} and parent-application-identifiers are set automatically.
APP CLIP EXPERIENCE URL: Configure in App Store Connect. Users launch App Clip via NFC, QR code, Maps, etc.
APP CLIP INVOCATION (receive URL): struct AppClipApp: App { var body: some Scene { WindowGroup { ContentView() .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in guard let url = activity.webpageURL else { return } // Handle URL: extract parameters, show relevant content } } } }
SKOverlay (promote full app from within App Clip): import StoreKit @Environment(.requestAppStoreOverlay) var requestOverlay Button("Get Full App") { requestOverlay(AppStoreOverlay.AppClipCompletion(appIdentifier: "YOUR_APP_ID")) }
CONSTRAINTS:
APP REVIEW (StoreKit):
APPLE ON-DEVICE TRANSLATION (Translation framework): FRAMEWORK: import Translation (iOS 17.4+, on-device, NO internet required, NO API key)
KEY DISTINCTION: Translation is for translating USER CONTENT on demand (e.g. translating a message from French to English). It is NOT for app localization (.strings files). Do not confuse the two.
MODIFIER APPROACH (simplest — shows system translation sheet): @State private var showTranslation = false Text(userContent) .translationPresentation(isPresented: $showTranslation, text: userContent) Button("Translate") { showTranslation = true }
PROGRAMMATIC TRANSLATION (TranslationSession): @State private var translatedText = ""
func translateText(_ input: String) async { let config = TranslationSession.Configuration(source: .init(identifier: "fr"), target: .init(identifier: "en")) let session = TranslationSession(configuration: config) do { let response = try await session.translate(input) translatedText = response.targetText } catch { // Handle: language pair not supported on device, model not downloaded } }
// Call with .task or Button: .task { await translateText(originalText) }
SUPPORTED LANGUAGES: Check Translation.supportedLanguages for the device's available language pairs. AVAILABILITY: Some language pairs require a model download on first use. NO ENTITLEMENTS NEEDED: Translation framework requires no special entitlements or Info.plist keys.
BIOMETRIC AUTHENTICATION (Face ID / Touch ID):
CAMERA & PHOTOS:
SWIFT CHARTS:
CONTRAST RATIO REQUIREMENTS (WCAG 2.1 / Apple HIG):
SEMANTIC COLOR USAGE:
NEVER RELY ON COLOR ALONE:
SYSTEM ADAPTIVE COLORS:
DARK MODE COLOR PAIRING:
APPTHEME COLOR PATTERNS:
TEXT ON IMAGES/GRADIENTS:
OPACITY GUIDELINES:
BUTTON HIERARCHY (one primary per screen/section):
| Level | Style | Use Case | Code | |------------|----------------------|-----------------------------------|----------------------------------------------------| | Primary | .borderedProminent | Main action (Save, Submit, Start) | .buttonStyle(.borderedProminent).controlSize(.large)| | Secondary | .bordered | Alternative action (Cancel, Edit) | .buttonStyle(.bordered) | | Tertiary | .borderless | Low-emphasis (Skip, Learn More) | .buttonStyle(.borderless) | | Destructive| .borderedProminent | Delete, Remove | .buttonStyle(.borderedProminent).tint(.red) |
CARD DESIGN PATTERN:
VStack(alignment: .leading, spacing: AppTheme.Spacing.xSmall) {
HStack {
Image(systemName: "icon.name")
.font(.title3)
.foregroundStyle(AppTheme.Colors.primary)
Spacer()
Text("metadata")
.font(.caption)
.foregroundStyle(.secondary)
}
Text("Title")
.font(.headline)
Text("Description text goes here")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.padding(AppTheme.Spacing.medium)
.background(AppTheme.Colors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.Style.cornerRadius))
.shadow(color: .black.opacity(0.06), radius: 8, y: 4)
INPUT FIELD STATES:
TextField("Email", text: $email)
.textFieldStyle(.roundedBorder)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(emailError != nil ? .red : .clear, lineWidth: 1)
)
if let error = emailError {
Text(error)
.font(.caption)
.foregroundStyle(.red)
}
LOADING STATES:
| Pattern | Use Case | Code | |-------------------|-----------------------------------|-----------------------------------------| | Inline spinner | Button action, single item | ProgressView().controlSize(.small) | | Full-screen | Initial data load | ProgressView("Loading...") | | Pull-to-refresh | List refresh | .refreshable { await refresh() } | | Skeleton | Content placeholder | .redacted(reason: .placeholder) | | Overlay | Blocking operation | .overlay { if loading { ProgressView() } } |
BADGE/CHIP PATTERN:
Text("Label")
.font(.caption)
.fontWeight(.medium)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(AppTheme.Colors.primary.opacity(0.15))
.foregroundStyle(AppTheme.Colors.primary)
.clipShape(Capsule())
TOGGLE/SWITCH:
PICKER PATTERNS:
EMPTY STATES:
DIVIDERS:
DARK/LIGHT MODE:
ADAPTIVE THEME COLORS (no color assets needed):
Every app MUST use a centralized theme with nested enums for Colors, Fonts, and Spacing. Do NOT use a flat enum with top-level static properties.
// REQUIRED — always use nested enums
import SwiftUI
enum AppTheme {
enum Colors {
static let accent = Color.blue // one accent per app
static let textPrimary = Color.primary
static let textSecondary = Color.secondary
static let background = Color(.systemBackground)
static let surface = Color(.secondarySystemBackground)
static let cardBackground = Color(.secondarySystemGroupedBackground)
}
enum Fonts {
static let largeTitle = Font.largeTitle
static let title = Font.title
static let headline = Font.headline
static let body = Font.body
static let caption = Font.caption
}
enum Spacing {
static let small: CGFloat = 8
static let medium: CGFloat = 16
static let large: CGFloat = 24
static let cornerRadius: CGFloat = 12
}
}
// FORBIDDEN — never use flat structure
enum AppTheme {
static let accentColor = Color.blue // ❌ wrong
static let spacing: CGFloat = 8 // ❌ wrong
}
Reference as: AppTheme.Colors.accent, AppTheme.Fonts.headline, AppTheme.Spacing.medium
.largeTitle, .title, .headline, .body, .captionAppTheme.Fonts for consistent sizingImage(systemName: "symbol.name").symbolRenderingMode(.hierarchical) or .symbolRenderingMode(.palette) for visual depth.primary, .secondary, Color(.systemBackground)AppTheme.Spacing constants throughoutEvery list or collection MUST have an empty state. Use ContentUnavailableView (iOS 17+) for a polished look:
// Required — show when collection is empty
if items.isEmpty {
ContentUnavailableView(
"No Notes Yet",
systemImage: "note.text",
description: Text("Tap + to create your first note")
)
} else {
// Show the list
}
For custom empty states, use a styled VStack with SF Symbol + descriptive text:
VStack(spacing: 16) {
Image(systemName: "tray")
.font(.system(size: 48))
.foregroundStyle(.secondary)
Text("Nothing here yet")
.font(.title3)
Text("Add your first item to get started")
.font(.subheadline)
.foregroundStyle(.secondary)
}
Use subtle, purposeful animations for state changes and list mutations:
// Toggle/complete actions — spring animation
withAnimation(.spring) {
item.isComplete.toggle()
}
// List insertions/removals — combine opacity + scale
.transition(.opacity.combined(with: .scale))
// Numeric text changes
.contentTransition(.numericText())
// Filter/tab changes
.animation(.default, value: selectedFilter)
Rules:
withAnimation(.spring) for toggle/complete state changes.transition(.opacity.combined(with: .scale)) for list add/remove.spring and .default curves onlyLOADING PATTERNS:
Button {
Task { await save() }
} label: {
if isSaving {
ProgressView()
.controlSize(.small)
} else {
Text("Save")
}
}
.disabled(isSaving)
if isLoading {
ProgressView("Loading...")
} else {
ContentView()
}
ForEach(Item.sampleData) { item in
ItemRow(item: item)
}
.redacted(reason: .placeholder)
List { ... }
.refreshable { await viewModel.refresh() }
.overlay {
if isProcessing {
ZStack {
Color.black.opacity(0.3)
ProgressView()
.controlSize(.large)
.tint(.white)
}
.ignoresSafeArea()
}
}
LOADING RULES:
ERROR HANDLING UI:
if let error = emailError {
HStack(spacing: 4) {
Image(systemName: "exclamationmark.circle.fill")
Text(error)
}
.font(.caption)
.foregroundStyle(.red)
}
.alert("Error", isPresented: $showError) {
Button("Retry") { Task { await retry() } }
Button("Cancel", role: .cancel) { }
} message: {
Text(errorMessage)
}
if let error = bannerError {
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
Text(error)
.font(.subheadline)
Spacer()
Button("Dismiss") { bannerError = nil }
.font(.caption)
}
.padding(AppTheme.Spacing.small)
.background(.orange.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 8))
.padding(.horizontal, AppTheme.Spacing.medium)
}
ERROR HANDLING RULES:
SUCCESS FEEDBACK:
// Brief success animation
withAnimation(.spring(response: 0.3)) {
showSuccess = true
}
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
withAnimation { showSuccess = false }
}
DISABLED STATE:
NETWORK/SYSTEM ERROR PATTERN (ViewModel):
@MainActor @Observable
class ItemViewModel {
var items: [Item] = []
var isLoading = false
var error: String?
func loadItems() async {
isLoading = true
error = nil
do {
items = try await fetchItems()
} catch {
self.error = "Couldn't load items. Pull to refresh to try again."
}
isLoading = false
}
}
EMPTY VS ERROR VS LOADING:
View+Sections.swift)The body property should read like a table of contents — only referencing computed properties, not containing implementation:
// Good
var body: some View {
VStack {
headerSection
contentSection
footerSection
}
}
// Bad — implementation directly in body
var body: some View {
VStack {
HStack {
Image(systemName: "person")
Text(user.name)
.font(.headline)
Spacer()
Button("Edit") { showEdit = true }
}
// ... 80+ more lines
}
}
When a view grows beyond 150 lines, split into extensions by section:
// ProfileView.swift — main file
struct ProfileView: View {
@State var viewModel: ProfileViewModel
var body: some View {
ScrollView {
headerSection
statsSection
settingsSection
}
}
}
// ProfileView+Sections.swift — extracted sections
extension ProfileView {
var headerSection: some View { ... }
var statsSection: some View { ... }
var settingsSection: some View { ... }
}
Models/ → Data model structs (with static sampleData)
Theme/ → AppTheme.swift only
Features/<Name>/ → Co-locate View + ViewModel (e.g. Features/TodoList/TodoListView.swift)
Features/Common/ → Shared reusable views used by multiple features
App/ → @main app entry point only
Views/, ViewModels/, or Components/ top-level directoriesFeatures/<FeatureName>/Features/Common/ProfileView.swift for struct ProfileView{Feature}View.swift (e.g., NotesListView.swift){Feature}ViewModel.swift (e.g., NotesListViewModel.swift){ModelName}.swift (e.g., Note.swift)AppTheme.swift{AppName}App.swiftURLSession, no Alamofire, no REST clientsasync let URL fetchesUIKit imports are allowed only when a required feature has no viable SwiftUI equivalentUIViewRepresentable / UIViewControllerRepresentable are allowed only as minimal bridges for those required UIKit featuresNSManagedObject, no NSPersistentContainer.xcdatamodeld filesColor, CGPoint, Font, etc.)// BANNED — re-declaring Color enum that SwiftUI already provides
enum Color {
case red, blue, green
}
// BANNED — re-declaring a model that exists in Models/Note.swift
struct Note {
var title: String
}
// CORRECT — import and use the existing type
import SwiftUI // provides Color
// Note is already defined in Models/Note.swift, just reference it
APPLE ON-DEVICE AI (FoundationModels — iOS 26+): FRAMEWORK: import FoundationModels
AVAILABILITY CHECK (MANDATORY — model may not be available on all devices): guard case .available = SystemLanguageModel.default.availability else { // Show "This feature requires Apple Intelligence" message return }
BASIC TEXT GENERATION: let session = LanguageModelSession() let response = try await session.respond(to: "Summarize this text: (userText)") print(response.content)
STREAMING GENERATION: let stream = session.streamResponse(to: prompt) for try await partial in stream { displayText += partial.text }
STRUCTURED OUTPUT with @Generable: @Generable struct RecipeSuggestion { @Guide(description: "Name of the dish") var name: String @Guide(description: "Estimated prep time in minutes") var prepTime: Int @Guide(description: "Main ingredients") var ingredients: [String] }
let session = LanguageModelSession() let recipe: RecipeSuggestion = try await session.respond( to: "Suggest a quick pasta dish", generating: RecipeSuggestion.self )
SESSION INSTRUCTIONS (system prompt): let session = LanguageModelSession(instructions: "You are a helpful cooking assistant. Keep responses concise.")
GUARDRAILS:
STANDARD GESTURE TABLE:
| Gesture | SwiftUI API | Use Case | |------------|-----------------------|----------------------------------| | Tap | Button() | Primary actions, navigation | | Long press | .contextMenu | Secondary actions, previews | | Swipe | .swipeActions | List row actions (delete, edit) | | Drag | .draggable/.dropDest | Reorder, drag-and-drop | | Pull | .refreshable | Refresh content | | Pinch | MagnifyGesture | Zoom images/maps | | Rotate | RotateGesture | Rotate content |
BUTTON VS ONTAPGESTURE:
SWIPE ACTIONS (list rows):
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(role: .destructive) {
deleteItem(item)
} label: {
Label("Delete", systemImage: "trash")
}
}
.swipeActions(edge: .leading) {
Button {
toggleFavorite(item)
} label: {
Label(item.isFavorite ? "Unfavorite" : "Favorite",
systemImage: item.isFavorite ? "star.slash" : "star.fill")
}
.tint(.yellow)
}
CONTEXT MENUS:
.contextMenu {
Button("Edit", systemImage: "pencil") { edit(item) }
Button("Share", systemImage: "square.and.arrow.up") { share(item) }
Divider()
Button("Delete", systemImage: "trash", role: .destructive) { delete(item) }
}
GESTURE PRIORITY:
HAPTIC FEEDBACK PAIRING:
CONFIRMATION FOR DESTRUCTIVE ACTIONS:
.confirmationDialog("Delete Item?", isPresented: $showDelete) {
Button("Delete", role: .destructive) { delete(item) }
Button("Cancel", role: .cancel) { }
}
PULL-TO-REFRESH:
SCROLL GESTURES:
HAPTIC FEEDBACK:
HEALTHKIT:
LIVE ACTIVITIES (ActivityKit + Dynamic Island): FRAMEWORK: import ActivityKit
SETUP:
ATTRIBUTES (define in Shared/ directory so both app and extension compile it): struct DeliveryAttributes: ActivityAttributes { public struct ContentState: Codable, Hashable { var status: String // Mutable: changes during the activity var progress: Double } var orderID: String // Static: set at start, cannot change }
STARTING AN ACTIVITY (in app): let attributes = DeliveryAttributes(orderID: "123") let initialState = DeliveryAttributes.ContentState(status: "Preparing", progress: 0.0) let content = ActivityContent(state: initialState, staleDate: nil) let activity = try Activity.request(attributes: attributes, content: content)
UPDATING AN ACTIVITY: let updatedState = DeliveryAttributes.ContentState(status: "On the way", progress: 0.6) let updatedContent = ActivityContent(state: updatedState, staleDate: nil) await activity.update(updatedContent)
ENDING AN ACTIVITY: await activity.end(nil, dismissalPolicy: .immediate)
LOCK SCREEN / DYNAMIC ISLAND UI (in extension): struct DeliveryLiveActivityView: View { let context: ActivityViewContext<DeliveryAttributes> var body: some View { HStack { Text(context.state.status); ProgressView(value: context.state.progress) } } }
struct DeliveryWidget: Widget { var body: some WidgetConfiguration { ActivityConfiguration(for: DeliveryAttributes.self) { context in DeliveryLiveActivityView(context: context) // Lock Screen } dynamicIsland: { context in DynamicIsland { DynamicIslandExpandedRegion(.leading) { Text(context.state.status) } DynamicIslandExpandedRegion(.trailing) { ProgressView(value: context.state.progress) } } compactLeading: { Image(systemName: "bicycle") } compactTrailing: { Text("(Int(context.state.progress * 100))%") } minimal: { ProgressView(value: context.state.progress) } } } }
MANDATORY FILES (every live activity extension MUST have ALL of these):
SHARED TYPES (CRITICAL):
SWIFT 6 CONCURRENCY:
PUSH-TO-START: Use APNs with activity-update payload to start activities remotely (advanced, requires server).
LOCALIZATION (.strings files + RTL/LTR + Language Switching):
FORBIDDEN PATTERNS (CRITICAL — violation = broken app):
.strings FILE GENERATION:
CRITICAL — localization key usage rules:
Rule 1 — String LITERALS in views are auto-localized: Text("settings_title"), .navigationTitle("dashboard_title"), Label("tab_workouts", systemImage: "icon") SwiftUI treats these as LocalizedStringKey and looks them up using the environment locale.
Rule 2 — String VARIABLES are NOT auto-localized: let key = "settings_title"; Text(key) — shows raw key text. This is the #1 localization bug. FIX: Text(LocalizedStringKey(key))
Rule 3 — Computed properties returning keys for display: If a switch/computed property returns a key as String (e.g. "metric_steps"), and you pass it to Text(label), it will NOT be localized. FIX: Return LocalizedStringKey instead of String from the computed property.
Rule 4 — NEVER use String(localized:) in view parameters: Text(String(localized: "key")) resolves against SYSTEM locale, NOT the environment locale. Runtime language switching breaks.
Rule 5 — EVERY key in code must exist in EVERY .strings file. Missing key = raw key shown to user.
CONFIG_CHANGES FOR LOCALIZATIONS:
LANGUAGE SELECTION & SWITCHING:
RTL / LTR LAYOUT DIRECTION:
LOCALE-AWARE FORMATTING:
TESTING RTL IN PREVIEWS:
MAPS & LOCATION:
OVERLAY PATTERNS ON MAP VIEWS:
Every ViewModel follows this exact pattern:
import SwiftUI
@Observable
@MainActor
class NotesListViewModel {
var notes: [Note] = Note.sampleData
var searchText = ""
var filteredNotes: [Note] {
if searchText.isEmpty { return notes }
return notes.filter { $0.title.localizedCaseInsensitiveContains(searchText) }
}
func addNote(title: String) {
let note = Note(title: title)
notes.insert(note, at: 0)
}
func deleteNote(_ note: Note) {
notes.removeAll { $0.id == note.id }
}
}
@Observable and @MainActor@Observable class — no other type declarationsstatic sampleData so app looks alive on first launchSwiftUI and UIKit when needed for framework types, animation helpers, or required platform bridgesUIView/UIViewController declarations, body, or #Preview@State for local UI state (sheet presented, text field binding, animation flags)@State var viewModel = SomeViewModel()#Preview block with sample data| What | Where | How |
|------|-------|-----|
| App data (default) | ViewModel | In-memory arrays initialized from sampleData |
| Simple flags/settings | View or ViewModel | @AppStorage |
| Transient UI state | View | @State |
| Persistent data (only if user asks) | ViewModel | SwiftData @Query / modelContext |
| Pattern | When to Use |
|---------|-------------|
| NavigationStack | Hierarchical drill-down (list → detail → edit) |
| TabView with Tab API | 3+ distinct top-level peer sections |
| .sheet(item:) | Creation forms, secondary actions, settings |
| .fullScreenCover | Immersive experiences (media player, onboarding) |
| NavigationStack + .sheet | Most MVPs with 2-4 features |
Use for hierarchical navigation with a back button:
NavigationStack {
List(items) { item in
NavigationLink(value: item) {
ItemRow(item: item)
}
}
.navigationDestination(for: Item.self) { item in
ItemDetailView(item: item)
}
.navigationTitle("Items")
}
Use when the app has 3+ distinct, peer-level sections:
TabView {
Tab("Home", systemImage: "house") {
HomeView()
}
Tab("Search", systemImage: "magnifyingglass") {
SearchView()
}
Tab("Profile", systemImage: "person") {
ProfileView()
}
}
Choose the right sheet variant based on context:
.sheet(item:) — for editing or viewing an existing item (the item drives the sheet).sheet(isPresented:) — acceptable for creation forms and simple actions (no item yet)// Editing/viewing an existing item — use item-driven
@State private var editingItem: Item?
.sheet(item: $editingItem) { item in
EditItemView(item: item)
}
// Creating a new item — isPresented is fine
@State private var showAddItem = false
.sheet(isPresented: $showAddItem) {
AddItemView()
}
Use for immersive content that should cover the entire screen:
@State private var showOnboarding = false
.fullScreenCover(isPresented: $showOnboarding) {
OnboardingView()
}
Always use navigationDestination(for:) for type-safe routing:
// Define route types
.navigationDestination(for: Note.self) { note in
NoteDetailView(note: note)
}
.navigationDestination(for: Category.self) { category in
CategoryView(category: category)
}
NOTIFICATION SERVICE EXTENSION: SETUP: Requires separate extension target (kind: "notification_service" in plan extensions array). Used to modify rich push notification content before display (add images, decrypt payload, etc.).
PRINCIPAL CLASS (in extension): 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 bestAttemptContent else { contentHandler(request.content); return }
// Modify content here — e.g., download and attach image
if let urlString = request.content.userInfo["image_url"] as? String,
let url = URL(string: urlString) {
downloadAndAttach(url: url, to: bestAttemptContent) { modified in
contentHandler(modified)
}
} else {
bestAttemptContent.title = "[Modified] " + bestAttemptContent.title
contentHandler(bestAttemptContent)
}
}
override func serviceExtensionTimeWillExpire() {
// Called just before extension is killed — deliver best attempt
if let contentHandler, let bestAttemptContent {
contentHandler(bestAttemptContent)
}
}
}
ADDING ATTACHMENT: func downloadAndAttach(url: URL, to content: UNMutableNotificationContent, completion: @escaping (UNNotificationContent) -> Void) { URLSession.shared.downloadTask(with: url) { localURL, _, _ in if let localURL, let attachment = try? UNNotificationAttachment(identifier: "image", url: localURL) { content.attachments = [attachment] } completion(content) }.resume() }
NOTIFICATIONS (LOCAL — no server needed):
FRAMEWORK: UserNotifications — UNUserNotificationCenter.current()
PERMISSION STATES (CRITICAL — must handle ALL four states):
PERMISSION IS SYSTEM-CONTROLLED (CRITICAL):
UI THAT DISPLAYS NOTIFICATION STATUS (in ANY view — must handle ALL three states):
RE-CHECK ON FOREGROUND (CRITICAL): Any view displaying notification status MUST re-check when the app returns to foreground. The user may have changed permissions in System Settings while the app was backgrounded.
.onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { _ in Task { let settings = await UNUserNotificationCenter.current().notificationSettings() isNotificationsEnabled = (settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional) } }
SCHEDULING:
BADGE COUNT:
SAFARI WEB EXTENSION: SETUP: Requires separate extension target (kind: "safari" in plan extensions array). Safari Web Extensions use web technologies (JS/HTML/CSS) plus a native Swift wrapper.
SWIFT EXTENSION HANDLER (optional — for native communication): class SafariExtensionHandler: SFSafariExtensionHandler { // Called when toolbar button is clicked override func toolbarItemClicked(in window: SFSafariWindow) { window.getActiveTab { tab in tab?.getActivePage { page in page?.dispatchMessageToScript(withName: "buttonClicked", userInfo: [:]) } } }
// Receive messages from JavaScript content scripts
override func messageReceived(withName messageName: String, from page: SFSafariPage, userInfo: [String: Any]?) {
// Handle message from JS: page.dispatchMessageToExtension(...)
}
}
INFO.PLIST KEYS (extension): NSExtensionPrincipalClass: $(PRODUCT_MODULE_NAME).SafariExtensionHandler NSExtensionAttributes: SFSafariWebsiteAccess: { Level: All }
WEB EXTENSION RESOURCES (in extension bundle):
ENABLE IN SAFARI: User must enable in Safari → Settings → Extensions.
SHARE EXTENSION: SETUP: Requires separate extension target (kind: "share" in plan extensions array). The extension receives shared content (URLs, text, images) from other apps via the share sheet.
PRINCIPAL CLASS (in extension target): class ShareViewController: SLComposeServiceViewController { override func isContentValid() -> Bool { return contentText.count > 0 // Validate before enabling Post button }
override func didSelectPost() {
// Access shared items
guard let item = extensionContext?.inputItems.first as? NSExtensionItem,
let provider = item.attachments?.first else {
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
return
}
if provider.hasItemConformingToTypeIdentifier("public.url") {
provider.loadItem(forTypeIdentifier: "public.url") { [weak self] url, _ in
// Handle URL
self?.extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
}
}
}
override func configurationItems() -> [Any]! {
return [] // Return SLComposeSheetConfigurationItem array for optional UI
}
}
INFO.PLIST KEYS (in extension's Info.plist via XcodeGen): NSExtensionPrincipalClass: $(PRODUCT_MODULE_NAME).ShareViewController NSExtensionActivationRule: NSExtensionActivationSupportsWebURLWithMaxCount: 1 NSExtensionActivationSupportsText: true
APP GROUP: Use AppGroup entitlement to share data between main app and extension.
SIRI VOICE COMMANDS (App Intents): FRAMEWORK: import AppIntents (iOS 16+, replaces legacy SiriKit Intents)
BASIC INTENT: struct OpenNoteIntent: AppIntent { static var title: LocalizedStringResource = "Open Note" static var description = IntentDescription("Opens a specific note in the app")
@Parameter(title: "Note Name")
var noteName: String
func perform() async throws -> some IntentResult & ProvidesDialog {
// Navigate to note or perform action
return .result(dialog: "Opening \(noteName)")
}
}
APP SHORTCUTS (makes intent discoverable via Siri without user setup): struct MyAppShortcuts: AppShortcutsProvider { static var appShortcuts: [AppShortcut] { AppShortcut( intent: OpenNoteIntent(), phrases: ["Open (.$noteName) in (.applicationName)", "Show note (.$noteName)"], shortTitle: "Open Note", systemImageName: "note.text" ) } }
ENTITLEMENT: Add com.apple.developer.siri entitlement (CONFIG_CHANGES entitlements key). NO SEPARATE TARGET: App Intents run in-process — no extension target needed. DONATION: AppShortcutsProvider automatically donates shortcuts to Siri.
8-POINT GRID SYSTEM: All spacing values must be multiples of 4pt. Standard scale:
| Token | Value | Use Case | |-------------------|-------|---------------------------------------------| | Spacing.xxSmall | 4pt | Icon-to-label gap, tight inline spacing | | Spacing.xSmall | 8pt | Between related items (label + value) | | Spacing.small | 12pt | List row internal padding | | Spacing.medium | 16pt | Standard padding, outer margins | | Spacing.large | 24pt | Section spacing, card-to-card gap | | Spacing.xLarge | 32pt | Major section breaks | | Spacing.xxLarge | 48pt | Screen-level top/bottom breathing room |
NEVER use arbitrary values (5, 7, 10, 13, 15, 18, 25, etc.). Snap to the grid.
STANDARD MARGINS:
TAP TARGET SIZES:
VERTICAL RHYTHM:
DENSITY GUIDELINES (from plan's design.density):
CARD LAYOUT PATTERN:
VStack(alignment: .leading, spacing: AppTheme.Spacing.xSmall) {
// Card content
}
.padding(AppTheme.Spacing.medium)
.background(AppTheme.Colors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.Style.cornerRadius))
.shadow(color: .black.opacity(0.06), radius: 8, y: 4)
HORIZONTAL LAYOUTS:
SAFE AREA:
SPEECH RECOGNITION:
Unless the user explicitly requests persistence (words like "save", "persist", "database", "storage", "SwiftData"), use in-memory data with rich dummy data:
| Data Type | Storage | API |
|-----------|---------|-----|
| App data (notes, tasks, items) | In-memory (DEFAULT) | Plain struct, @Observable arrays |
| Simple flags and settings (sort order, preferred units) | UserDefaults | @AppStorage |
| Transient UI state (sheet shown, selected tab) | In-memory | @State |
Models are plain structs with a static sampleData array:
import Foundation
struct Note: Identifiable {
let id: UUID
var title: String
var content: String
var createdAt: Date
var isPinned: Bool
init(title: String, content: String = "", isPinned: Bool = false) {
self.id = UUID()
self.title = title
self.content = content
self.createdAt = Date()
self.isPinned = isPinned
}
static let sampleData: [Note] = [
Note(title: "Meeting Notes", content: "Discuss Q2 roadmap with the team", isPinned: true),
Note(title: "Recipe: Pasta Carbonara", content: "Eggs, pecorino, guanciale, black pepper"),
Note(title: "Book Recommendations", content: "The Midnight Library, Project Hail Mary"),
Note(title: "Workout Plan", content: "Mon: Upper body, Wed: Legs, Fri: Cardio"),
Note(title: "Gift Ideas", content: "Wireless earbuds, cookbook, plant pot"),
]
}
ViewModel holds data directly — no SwiftData, no modelContext:
import SwiftUI
@Observable
@MainActor
class NotesListViewModel {
var notes: [Note] = Note.sampleData
var searchText = ""
var filteredNotes: [Note] {
if searchText.isEmpty { return notes }
return notes.filter { $0.title.localizedCaseInsensitiveContains(searchText) }
}
func addNote(title: String) {
let note = Note(title: title)
notes.insert(note, at: 0)
}
func deleteNote(_ note: Note) {
notes.removeAll { $0.id == note.id }
}
}
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
Use for simple key-value settings:
@AppStorage("sortOrder") private var sortOrder = "date"
@AppStorage("showCompleted") private var showCompleted = true
Use @Model, @Query, .modelContainer ONLY if user says "save", "persist", "database", "storage", or "SwiftData":
import SwiftData
@Model
class Note {
var title: String
var content: String
var createdAt: Date
init(title: String, content: String = "") {
self.title = title
self.content = content
self.createdAt = Date()
}
}
Only import from this list:
SwiftUIFoundationSwiftDataObservationOSLog (for logging)UIKit (only for required bridges or APIs without a SwiftUI equivalent)PhotosUI (only when the app requires photo picking)AVFoundation (only when the app requires audio/video)MapKit (only when the app requires maps)CoreLocation (only when the app requires location)Use modern APIs — avoid deprecated alternatives:
| Use This | Not This |
|----------|----------|
| foregroundStyle() | foregroundColor() |
| clipShape(.rect(cornerRadius:)) | .cornerRadius() |
| @Observable | ObservableObject |
| @State with @Observable | @StateObject |
| .onChange(of:) { new in } | .onChange(of:) { old, new in } (iOS 17+ form) |
| .task { await … } | .onAppear { Task { await … } } (use .task for async work) |
| .onAppear { } | (acceptable for synchronous setup like passing modelContext) |
| NavigationStack | NavigationView |
Swift 6 enables strict concurrency checking by default. All data races are compile-time errors.
Sendable.final and have only immutable (let) Sendable stored properties, OR be marked @unchecked Sendable with manual thread safety (mutex/lock).@Sendable — they cannot capture mutable local state.@Published or @Observable state MUST be annotated @MainActor.@MainActor.@MainActor, so @State var vm: SomeViewModel works if SomeViewModel is @MainActor.@MainActor on the class/actor declaration, not on individual methods — partial isolation causes confusing errors.nonisolated.Hashable.hash(into:), CustomStringConvertible.description) on @MainActor types must be nonisolated.nonisolated(unsafe) only as a last resort for legacy API bridging.@MainActor-isolated values directly into APIs that execute in @concurrent contexts.MainActor for state updates:
// WRONG: passing actor-isolated self.items into concurrent context
let result = await processor.process(items)
// RIGHT: snapshot first
let snapshot = items // Copy Sendable data
let result = await processor.process(snapshot)
await MainActor.run { self.items = result }
sending parameter keyword marks closure parameters that cross isolation boundaries — use it when writing APIs that accept callbacks from different isolation domains.static let for shared constants — static var is a mutable global and violates strict concurrency.actor or @MainActor-isolated type.AppIntent static properties (title, description) MUST be static let.await inside an actor.@preconcurrency import FrameworkName for frameworks not yet annotated for Sendable (e.g., some older Apple frameworks)..translationTask { session in ... } flows, do NOT call session.translate(...) inside @MainActor-isolated methods.@MainActor only for UI state updates.import statementSwiftUI in a model file that only needs FoundationSwiftUI and UIKit when needed for framework types or platform bridges, but must not declare Views/UIViewControllersTIMERS:
IOS TYPE SCALE (use system text styles — NEVER .system(size:)):
| Style | Size | Weight | SwiftUI | Use Case | |----------------|------|----------|---------------------------|---------------------------------| | Large Title | 34pt | Regular | .largeTitle | Screen titles (NavigationStack) | | Title | 28pt | Regular | .title | Section headers | | Title 2 | 22pt | Regular | .title2 | Sub-section headers | | Title 3 | 20pt | Regular | .title3 | Card titles | | Headline | 17pt | Semibold | .headline | Row titles, emphasized labels | | Body | 17pt | Regular | .body | Primary content text | | Callout | 16pt | Regular | .callout | Secondary descriptions | | Subheadline | 15pt | Regular | .subheadline | Supporting text, timestamps | | Footnote | 13pt | Regular | .footnote | Tertiary info, disclaimers | | Caption | 12pt | Regular | .caption | Metadata, labels | | Caption 2 | 11pt | Regular | .caption2 | Smallest readable text |
HIERARCHY RULES:
FONT WEIGHT GUIDANCE:
FONT DESIGN:
DYNAMIC TYPE:
LINE SPACING & READABILITY:
NUMBER & DATA DISPLAY:
Every meaningful section of a view should be a @ViewBuilder computed property:
struct TaskListView: View {
var body: some View {
NavigationStack {
VStack {
headerView
taskList
addButton
}
}
}
private var headerView: some View {
Text("My Tasks")
.font(.largeTitle)
.padding()
}
private var taskList: some View {
List(tasks) { task in
TaskRow(task: task)
}
}
private var addButton: some View {
Button("Add Task") {
showAddSheet = true
}
.buttonStyle(.borderedProminent)
}
}
Use descriptive suffixes:
headerSection, headerView — top areacontentSection, contentView — main contentfooterSection — bottom area{feature}Section — feature-specific (e.g., profileSection, statsSection){action}Button — action buttons (e.g., addButton, saveButton)Extract a section into its own View struct when:
@State or @Binding)The body property should be a composition of computed properties — not raw implementation:
// Good — body is a clear outline
var body: some View {
ScrollView {
headerSection
featureCards
recentActivity
}
}
// Bad — body contains raw implementation
var body: some View {
ScrollView {
VStack(alignment: .leading) {
Text("Welcome")
.font(.largeTitle)
// ... 50 more lines
}
}
}
VIEW BODY COMPLEXITY (CRITICAL):
body grows beyond about 30 lines, extract sections into computed properties.headerSectioncontentSectionfooterSectionREQUIRED PATTERN:
var body: some View {
VStack(spacing: AppTheme.Spacing.medium) {
headerSection
contentSection
actionsSection
}
}
private var headerSection: some View { ... }
private var contentSection: some View { ... }
private var actionsSection: some View { ... }
TYPE-CHECK TIMEOUT FIX:
HStack/VStack/ZStack branches into computed propertiesChart blocks into computed propertiesWHEN EDITING EXISTING FILES:
WEBSITE LINKS:
WIDGETS (WidgetKit): FRAMEWORK: import WidgetKit
SETUP:
TIMELINE ENTRY (data for a single widget render): struct MyEntry: TimelineEntry { let date: Date let title: String let value: Double }
TIMELINE PROVIDER (supplies entries to the system): struct MyProvider: TimelineProvider { func placeholder(in context: Context) -> MyEntry { MyEntry(date: .now, title: "Placeholder", value: 0) } func getSnapshot(in context: Context, completion: @escaping (MyEntry) -> Void) { completion(MyEntry(date: .now, title: "Snapshot", value: 42)) } func getTimeline(in context: Context, completion: @escaping (Timeline<MyEntry>) -> Void) { let entry = MyEntry(date: .now, title: "Current", value: 42) let timeline = Timeline(entries: [entry], policy: .after(.now.addingTimeInterval(3600))) completion(timeline) } }
WIDGET VIEW: struct MyWidgetView: View { var entry: MyProvider.Entry @Environment(.widgetFamily) var family var body: some View { switch family { case .systemSmall: VStack { Text(entry.title).font(.headline); Text("(Int(entry.value))").font(.largeTitle) } case .systemMedium: HStack { VStack(alignment: .leading) { Text(entry.title); Text("(Int(entry.value))").font(.title) }; Spacer() } default: Text(entry.title) } } }
WIDGET DEFINITION: struct MyWidget: Widget { let kind: String = "MyWidget" var body: some WidgetConfiguration { StaticConfiguration(kind: kind, provider: MyProvider()) { entry in MyWidgetView(entry: entry) .containerBackground(.fill.tertiary, for: .widget) // REQUIRED iOS 17+ } .configurationDisplayName("My Widget") .description("Shows current status") .supportedFamilies([.systemSmall, .systemMedium]) } }
WIDGET BUNDLE (when multiple widgets exist): @main struct MyWidgetBundle: WidgetBundle { var body: some Widget { MyWidget() AnotherWidget() } }
MANDATORY FILES (every widget extension MUST have ALL of these):
CRITICAL RULES:
testing
Use for 3D games: racing, 3D sports, board games, marble maze, tower defense, bowling. SceneKit + SceneView architecture, 3D scene hierarchy, physics, game loop, primitives, materials, cameras, particles, audio.
documentation
Game UI patterns: SwiftUI HUD overlays on SpriteKit, menus (main/pause/game-over), virtual joystick/d-pad, score displays, health bars, tutorial onboarding.
tools
Download free game sprites/textures/3D models and generate procedural assets. Covers nw_download_asset tool, texture factories, sprite atlas organization, 3D model loading, and programmatic asset creation.
testing
Use for 2D games: arcade, puzzle, sports, ping pong, platformer, shooter, 2D racing. SpriteKit + SpriteView architecture, scene hierarchy, physics, game loop, audio, particles, game feel.