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-dev-docs 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:
tools
Apple platform skill for docs, WWDC lookup, App Store Connect work, and SwiftUI app generation. Use repo-local `node cli.js` for Apple docs and WWDC search, `appledev store` for App Store Connect workflows, and `appledev build` for app scaffolding or fix loops on macOS. USE WHEN: Apple APIs, WWDC sessions, TestFlight/App Store tasks, or building/fixing Apple-platform apps. DON'T USE WHEN: non-Apple platforms, generic backend work, or general web research. EDGE CASES: docs-only queries use `node cli.js` in this repo, not `appledev`; release workflows use `appledev store`; app scaffolding uses `appledev build`; rules-only requests can read `references/ios-rules/` or `references/swiftui-guides/` progressively without invoking binaries.
tools
All-in-one Apple developer skill with three integrated tools shipped as a single unified binary. (1) Documentation search across Apple frameworks, symbols, and 1,267 WWDC sessions from 2014-2025. No credentials needed. (2) App Store Connect CLI with 120+ commands covering builds (find/wait/upload), TestFlight, pre-submission validate, submissions, signing, subscriptions (family-sharable), IAP, analytics, Xcode Cloud, metadata workflows, release pipeline dashboard, insights, win-back offers, promoted purchases, product pages, nominations, accessibility declarations, pre-orders, pricing filters, localizations update, diff, webhooks with local receiver, workflow automation, and more. Requires App Store Connect API key. (3) Multi-platform app builder (iOS/watchOS/tvOS/iPad/macOS/visionOS) that generates complete Swift/SwiftUI apps from natural language with auto-fix, simulator launch, interactive chat mode, and open-in-Xcode. Requires an LLM API key and Xcode. Includes 38 iOS development rules and 12 SwiftUI best practice guides for Liquid Glass, navigation, state management, and modern APIs. All three tools ship as one binary (appledev). USE WHEN: Apple API docs, App Store Connect management, WWDC lookup, or building iOS/watchOS/tvOS/macOS/visionOS apps from scratch. DON'T USE WHEN: non-Apple platforms or general coding.
testing
watchOS complications: WidgetKit complication families, accessory sizes, timeline providers for watch face. Use when implementing watchOS-specific patterns related to widgets.
development
watchOS haptic feedback: WKInterfaceDevice preset haptic types for wrist-based feedback. Use when implementing watchOS-specific patterns related to haptics.