38 rules for writing production-quality iOS apps. Each rule targets common LLM mistakes with concrete fixes.
AccessibilityREDUCE MOTION:
- Check: @Environment(.accessibilityReduceMotion) var reduceMotion
- When enabled:
- Replace .spring() with .easeInOut(duration: 0.2)
- Replace slide transitions with .opacity
- Disable auto-playing animations
- Keep functional animations (progress bars), remove decorative ones
withAnimation(reduceMotion ? .easeInOut(duration: 0.2) : .spring(response: 0.3)) {
// state change
}
.transition(reduceMotion ? .opacity : .slide)
REDUCE TRANSPARENCY:
- Check: @Environment(.accessibilityReduceTransparency) var reduceTransparency
- When enabled: use opaque backgrounds instead of materials/blur.
.background(reduceTransparency ? Color(AppTheme.Colors.surface) : .ultraThinMaterial)
VOICEOVER LABELS:
- All interactive elements: .accessibilityLabel("descriptive text").
- Non-obvious actions: .accessibilityHint("Double tap to delete this item").
- Decorative images: .accessibilityHidden(true).
- Informative images: .accessibilityLabel("Profile photo of John").
- Icon-only buttons: MUST have .accessibilityLabel().
Button(action: addItem) {
Image(systemName: "plus")
}
.accessibilityLabel("Add new item")
GROUPING & COMBINING:
- Related content (icon + label + value): .accessibilityElement(children: .combine).
- Custom read order: .accessibilityElement(children: .ignore) + manual .accessibilityLabel.
- Cards with multiple elements: combine into single accessible element.
HStack {
Image(systemName: "heart.fill")
Text("Favorites")
Spacer()
Text("12")
}
.accessibilityElement(children: .combine)
ACCESSIBILITY TRAITS:
- Section headers: .accessibilityAddTraits(.isHeader)
- Buttons that play media: .accessibilityAddTraits(.startsMediaSession)
- Summary/aggregate values: .accessibilityAddTraits(.isSummaryElement)
- Selected items: .accessibilityAddTraits(.isSelected)
FOCUS MANAGEMENT:
- Use @FocusState with field enum for form navigation.
- .submitLabel(.next) to show "Next" on keyboard, .submitLabel(.done) for last field.
- Chain fields with .onSubmit { focusedField = .nextField }.
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:
- System text styles (.body, .headline, etc.) scale automatically.
- NEVER use .font(.system(size:)) — it opts out of Dynamic Type.
- If layout breaks at large sizes: .minimumScaleFactor(0.8) as last resort.
- Test with Xcode Environment Overrides at the largest accessibility size.
- ScrollView wraps content that may overflow at large type sizes.
COLOR & CONTRAST:
- Don't use color alone for status — always pair with icon + text.
- Minimum 4.5:1 contrast for normal text, 3:1 for large text.
- .foregroundStyle(.secondary) for de-emphasized text (maintains adaptive contrast).
ACCESSIBLE CUSTOM CONTROLS:
- Custom sliders/steppers: .accessibilityValue(), .accessibilityAdjustableAction().
- Custom toggles: .accessibilityAddTraits(.isToggle), .accessibilityValue(isOn ? "on" : "off").
- Progress indicators: .accessibilityValue("(Int(progress * 100)) percent").
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 Clip binary must be < 15 MB
- No access to HealthKit, CallKit, SiriKit (use App Intents in full app)
- Limited background modes
- Use @AppStorage for lightweight persistence (no SwiftData)
APP REVIEW (StoreKit):
- import StoreKit; @Environment(.requestReview) var requestReview
- Call requestReview() — Apple handles the dialog
- Trigger after meaningful engagement (launchCount >= 5, key task completed)
- Track with @AppStorage("launchCount"), increment in .onAppear of root view
- Apple limits to 3 prompts/year — never on first launch
- Check: never request immediately after error, crash, or purchase
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.
BiometricsBIOMETRIC AUTHENTICATION (Face ID / Touch ID):
- import LocalAuthentication; LAContext().evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics)
- Requires NSFaceIDUsageDescription permission (add CONFIG_CHANGES)
- Check canEvaluatePolicy first; fall back to passcode if biometrics unavailable
- LAContext().biometryType to detect .faceID vs .touchID vs .none
- Always provide manual unlock alternative (PIN/password)
CAMERA & PHOTOS:
- PhotosPicker (PhotosUI) for gallery selection — no permissions needed for limited access
- For camera capture: AVCaptureSession + AVCapturePhotoOutput + UIViewControllerRepresentable wrapper
- Camera requires NSCameraUsageDescription permission (add CONFIG_CHANGES)
- Full photo library requires NSPhotoLibraryUsageDescription
- Use @State private var selectedItem: PhotosPickerItem? with .onChange to load
- Load image: try await item.loadTransferable(type: Data.self)
SWIFT CHARTS:
- import Charts; use Chart { } container
- BarMark, LineMark, AreaMark, PointMark, RuleMark for data visualization
- .foregroundStyle(by: .value("Category", item.category)) for color coding
- chartXAxis { AxisMarks() }, chartYAxis { AxisMarks() } for custom axis labels
- Extract Chart into a separate computed property to avoid body complexity
- Use .chartScrollableAxes(.horizontal) for large datasets
CONTRAST RATIO REQUIREMENTS (WCAG 2.1 / Apple HIG):
- Normal text (<20pt): minimum 4.5:1 contrast ratio.
- Large text (20pt+ regular or 14pt+ bold): minimum 3:1 contrast ratio.
- Preferred: 7:1 for maximum readability.
- UI components (icons, borders, controls): minimum 3:1 against background.
SEMANTIC COLOR USAGE:
- Red (.red / destructive): delete, remove, error, critical alert.
- Green (.green): success, complete, enabled, positive.
- Orange (.orange): warning, caution, attention needed.
- Blue (.blue): informational, links, primary actions.
- NEVER use red for positive actions or green for destructive actions.
NEVER RELY ON COLOR ALONE:
- Status indicators: color + icon + text label (e.g. green circle + checkmark + "Complete").
- Error fields: red border + error icon + error message text.
- Charts/graphs: use patterns, shapes, or labels alongside color coding.
- Colorblindness: avoid red/green as the sole differentiator — use red/blue or add shapes.
SYSTEM ADAPTIVE COLORS:
- .primary: adapts to light/dark automatically — use for main text.
- .secondary: lighter text for subtitles, metadata.
- Color(.systemBackground): adapts to system appearance.
- These work well for structural elements; use AppTheme for brand colors.
DARK MODE COLOR PAIRING:
- Every custom color must have both light and dark variants.
- Light mode: dark text on light backgrounds.
- Dark mode: light text on dark backgrounds.
- Both variants must independently meet contrast requirements.
- Test both modes — a color that works in light may fail in dark.
APPTHEME COLOR PATTERNS:
- Use Color(light:dark:) extension when app has appearance switching.
- Use plain Color(hex:) when no dark mode support.
- Define semantic tokens: AppTheme.Colors.primary, .surface, .error, .success.
- NEVER use raw hex strings in views — always reference AppTheme tokens.
TEXT ON IMAGES/GRADIENTS:
- Add a dark overlay (.black.opacity(0.4)) before placing white text on images.
- Or use .shadow(color: .black.opacity(0.3), radius: 2) on text.
- Never place light text on light images without contrast treatment.
OPACITY GUIDELINES:
- Avoid text below .opacity(0.6) — fails contrast requirements.
- Disabled state: use .disabled() modifier (auto-handles opacity correctly).
- Placeholder text: use .foregroundStyle(.secondary) instead of manual opacity.
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) |
- ONE .borderedProminent per screen/section — multiple primaries confuse the user.
- Full-width primary: .controlSize(.large).frame(maxWidth: .infinity).
- ALWAYS use Button() — never .onTapGesture for actions.
- Disabled buttons: .disabled(condition) — SwiftUI auto-handles opacity.
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:
- Normal: TextField with .textFieldStyle(.roundedBorder).
- Focused: @FocusState with visual highlight (border color change or underline).
- Error: red border + error message below field.
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)
}
- Disabled: .disabled(true) — auto grays out.
- Form grouping: use Form or GroupBox for related fields.
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() } } |
- Disable the triggering button while loading to prevent double-taps.
- Show loading for operations > 300ms. Instant operations need no indicator.
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:
- Use Toggle for binary settings with immediate effect.
- Label must clearly describe the ON state.
- Group related toggles in a Section with a header.
PICKER PATTERNS:
- 2-4 options: Picker with .segmentedStyle.
- 5+ options: Picker with default menu style or NavigationLink to selection list.
- Date selection: DatePicker with appropriate displayedComponents.
EMPTY STATES:
- Always use ContentUnavailableView for empty lists/collections.
- Include: icon (SF Symbol), title, description, and action button if applicable.
- Never show a blank screen — empty state guides the user to the first action.
DIVIDERS:
- Use sparingly — prefer spacing to create visual separation.
- In lists: SwiftUI List provides dividers automatically.
- Custom dividers: Divider() with .padding(.horizontal) for inset style.
DARK/LIGHT MODE:
- 3-way picker (system/light/dark) is the standard pattern: @AppStorage("appearance") private var appearance: String = "system" private var preferredColorScheme: ColorScheme? { switch appearance { case "light": return .light; case "dark": return .dark; default: return nil } } .preferredColorScheme(preferredColorScheme) // on outermost container in @main app
- CRITICAL: .preferredColorScheme() MUST be in the root @main app, NOT just in the settings view.
- System option: .preferredColorScheme(nil) follows device setting.
- Settings screen: Picker with light/dark/system options writing to @AppStorage("appearance").
ADAPTIVE THEME COLORS (no color assets needed):
- Switch ALL AppTheme palette colors from plain Color(hex:) to Color(light:dark:) with TWO hex values: static let background = Color(light: Color(hex: "#F8F9FA"), dark: Color(hex: "#1C1C1E")) static let surface = Color(light: Color(hex: "#FFFFFF"), dark: Color(hex: "#2C2C2E"))
- Color(light:dark:) uses UIColor(dynamicProvider:) — reacts to .preferredColorScheme() automatically.
- YOU decide the dark palette based on app mood — user does not specify dark colors.
- Dark palette guidelines: darken backgrounds (#1C1C1E, #2C2C2E), lighten/brighten accents slightly, use Color.primary/Color.secondary for text.
- AppTheme MUST include the Color(light:dark:) extension (see shared constraints).
AppTheme Pattern
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
Typography
- System fonts only — use SwiftUI font styles:
.largeTitle,.title,.headline,.body,.caption - No custom fonts, no downloaded fonts
- Use
AppTheme.Fontsfor consistent sizing
Icons (SF Symbols)
- SF Symbols only for all icons — required for every list row, button, empty state, and tab
- Reference via
Image(systemName: "symbol.name") - Pick domain-appropriate symbols (e.g. "checkmark.circle.fill" for todos, "note.text" for notes, "heart.fill" for favorites)
- Use
.symbolRenderingMode(.hierarchical)or.symbolRenderingMode(.palette)for visual depth - No custom icon assets unless the app concept specifically requires them
Colors
- One accent color that fits the app's purpose
- Use semantic colors:
.primary,.secondary,Color(.systemBackground) - Do NOT add dark mode support, colorScheme checks, or custom dark/light color handling unless the user explicitly requests it
Spacing Standards
- 16pt standard padding (outer margins, section spacing)
- 8pt compact spacing (between related elements)
- 24pt large spacing (between major sections)
- Use
AppTheme.Spacingconstants throughout
Empty States
Every 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)
}
Animations
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:
- Always use
withAnimation(.spring)for toggle/complete state changes - Always add
.transition(.opacity.combined(with: .scale))for list add/remove - Never add gratuitous motion that slows down interaction
- Keep animations subtle —
.springand.defaultcurves only
LOADING PATTERNS:
- Inline button spinner (action on single element):
Button {
Task { await save() }
} label: {
if isSaving {
ProgressView()
.controlSize(.small)
} else {
Text("Save")
}
}
.disabled(isSaving)
- Full-screen loading (initial data load):
if isLoading {
ProgressView("Loading...")
} else {
ContentView()
}
- Skeleton loading (content placeholders):
ForEach(Item.sampleData) { item in
ItemRow(item: item)
}
.redacted(reason: .placeholder)
- Pull-to-refresh (list content):
List { ... }
.refreshable { await viewModel.refresh() }
- Overlay loading (blocking operation):
.overlay {
if isProcessing {
ZStack {
Color.black.opacity(0.3)
ProgressView()
.controlSize(.large)
.tint(.white)
}
.ignoresSafeArea()
}
}
LOADING RULES:
- Show indicator for operations > 300ms.
- ALWAYS disable the triggering button while loading (prevents double-taps).
- Never block the entire UI for a partial operation — use inline spinner.
- Match loading style to scope: button-level → inline, screen-level → full-screen.
ERROR HANDLING UI:
- Inline validation (below form fields):
if let error = emailError {
HStack(spacing: 4) {
Image(systemName: "exclamationmark.circle.fill")
Text(error)
}
.font(.caption)
.foregroundStyle(.red)
}
- Alert for blocking errors (require user acknowledgment):
.alert("Error", isPresented: $showError) {
Button("Retry") { Task { await retry() } }
Button("Cancel", role: .cancel) { }
} message: {
Text(errorMessage)
}
- Banner for non-blocking errors (dismissible):
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:
- Inline validation: show immediately as user types or on field blur.
- Alert: use for errors that block progress (network failure, permission denied).
- Banner: use for non-critical errors (sync failed, partial data).
- ALWAYS provide a retry path — never leave users stuck.
- Error messages: describe what happened + what the user can do.
SUCCESS FEEDBACK:
- Haptic: UINotificationFeedbackGenerator().notificationOccurred(.success).
- Visual: brief animation (checkmark, scale bounce, color flash).
- NEVER use modal alert for success — too disruptive.
- Subtle confirmation: toast, inline checkmark, or haptic alone.
// Brief success animation
withAnimation(.spring(response: 0.3)) {
showSuccess = true
}
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
withAnimation { showSuccess = false }
}
DISABLED STATE:
- .disabled(condition) — SwiftUI auto-handles opacity reduction.
- Always explain WHY something is disabled (tooltip, caption text, or label).
- Example: "Fill in all required fields to continue" below a disabled button.
- Don't hide actions — show them disabled with explanation.
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:
- Loading: ProgressView or .redacted skeleton.
- Empty (no data yet): ContentUnavailableView with action to create first item.
- Error (load failed): error message + retry button.
- These are three distinct states — never conflate them.
Line Limit
- Target: 150 lines per file — aim for this
- Hard limit: 200 lines — files exceeding 200 lines MUST be split using extensions (
View+Sections.swift) - Files between 150-200 lines are acceptable if splitting would hurt readability
Body as Table of Contents
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
}
}
Extension Splitting Pattern
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 { ... }
}
Directory Structure
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
- NEVER use flat
Views/,ViewModels/, orComponents/top-level directories - Every View and its ViewModel MUST live under
Features/<FeatureName>/ - Shared components go under
Features/Common/
One Type Per File
- Each file contains exactly one primary type (struct, class, or enum)
- Extensions of the same type may live in separate files
- File name matches the type name:
ProfileView.swiftforstruct ProfileView
Naming Conventions
- Views:
{Feature}View.swift(e.g.,NotesListView.swift) - ViewModels:
{Feature}ViewModel.swift(e.g.,NotesListViewModel.swift) - Models:
{ModelName}.swift(e.g.,Note.swift) - Theme:
AppTheme.swift - App entry:
{AppName}App.swift
Networking — BANNED
- No
URLSession, noAlamofire, no REST clients - No API calls of any kind
- No
async letURL fetches - The app is fully on-device
UIKit — AVOID BY DEFAULT
- Prefer SwiftUI-first architecture
UIKitimports are allowed only when a required feature has no viable SwiftUI equivalentUIViewRepresentable/UIViewControllerRepresentableare allowed only as minimal bridges for those required UIKit features- No Storyboards, no XIBs, no Interface Builder
Third-Party Packages — BANNED
- No SPM (Swift Package Manager) dependencies
- No CocoaPods
- No Carthage
- Use only Apple-native frameworks
CoreData — BANNED
- Use SwiftData instead of CoreData
- No
NSManagedObject, noNSPersistentContainer - No
.xcdatamodeldfiles
Authentication & Cloud — BANNED
- No authentication screens, login flows, or token management
- No CloudKit, no iCloud sync
- No push notifications
- No Firebase, no Supabase, no backend services
Type Re-declarations — BANNED
- NEVER re-declare types that exist in other project files
- NEVER re-declare types from SwiftUI/Foundation (
Color,CGPoint,Font, etc.) - Import the module or file that defines the type
- Each type must be defined in exactly one file
// 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
Foundation Models
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:
- Model output is filtered by Apple's safety system
- No internet required — fully on-device
- Context window is limited (~4K tokens typical) — keep prompts concise
- Use session.respond() for single turns, keep session for multi-turn conversations
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:
- ALWAYS use Button() for tappable UI elements. Never .onTapGesture on Text/Image/VStack.
- Button provides: accessibility labels, hit testing, highlight states, VoiceOver support.
- .onTapGesture only for non-button interactions (dismiss, background tap).
SWIPE ACTIONS (list rows):
- Trailing side: destructive actions (delete, archive).
- Leading side: positive/toggle actions (pin, favorite, mark read).
- Use .tint() to color-code actions.
- Destructive actions: .role(.destructive) for red styling.
.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:
- Use for secondary actions on any element (not just list rows).
- Group related actions, use Divider() between groups.
- Destructive actions go LAST with .role(.destructive).
.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:
- Default: child gestures take priority over parent.
- .highPriorityGesture(): parent gesture overrides child.
- .simultaneousGesture(): both recognize at the same time.
- Use .simultaneousGesture for scroll + pinch zoom combinations.
HAPTIC FEEDBACK PAIRING:
- Tap actions: UIImpactFeedbackGenerator(style: .light) for subtle confirmation.
- Toggle/switch: UIImpactFeedbackGenerator(style: .medium).
- Destructive action: UINotificationFeedbackGenerator().notificationOccurred(.warning).
- Success completion: UINotificationFeedbackGenerator().notificationOccurred(.success).
- Selection change (picker/list): UISelectionFeedbackGenerator().selectionChanged().
- Drag start/end: UIImpactFeedbackGenerator(style: .medium).
CONFIRMATION FOR DESTRUCTIVE ACTIONS:
- Always confirm before destructive actions (delete, remove, clear all).
- Use .confirmationDialog for choices, .alert for single confirmation.
.confirmationDialog("Delete Item?", isPresented: $showDelete) {
Button("Delete", role: .destructive) { delete(item) }
Button("Cancel", role: .cancel) { }
}
PULL-TO-REFRESH:
- Use .refreshable {} on List or ScrollView for refresh.
- SwiftUI handles the indicator automatically.
- The closure should be async — SwiftUI shows/hides spinner based on task completion.
SCROLL GESTURES:
- ScrollView handles scroll automatically.
- .scrollDismissesKeyboard(.interactively) for forms with keyboard.
- .scrollIndicators(.hidden) only when indicators are visually distracting.
HAPTIC FEEDBACK:
- UIImpactFeedbackGenerator(style: .medium).impactOccurred() for simple taps
- UINotificationFeedbackGenerator().notificationOccurred(.success/.warning/.error) for outcomes
- UISelectionFeedbackGenerator().selectionChanged() for selection changes
- CoreHaptics for custom patterns: CHHapticEngine + CHHapticEvent
- Always check CHHapticEngine.capabilitiesForHardware().supportsHaptics
- Prepare generator before use: generator.prepare() for lower latency
HEALTHKIT:
- import HealthKit; HKHealthStore()
- Check availability: HKHealthStore.isHealthDataAvailable()
- Request auth: healthStore.requestAuthorization(toShare:read:)
- Requires NSHealthShareUsageDescription + NSHealthUpdateUsageDescription (add CONFIG_CHANGES)
- Requires com.apple.developer.healthkit entitlement (add CONFIG_CHANGES)
- Query: HKSampleQuery, HKStatisticsQuery for aggregated data
- Common types: HKQuantityType(.stepCount), .heartRate, .activeEnergyBurned
LIVE ACTIVITIES (ActivityKit + Dynamic Island): FRAMEWORK: import ActivityKit
SETUP:
- Requires separate extension target (kind: "live_activity" in plan extensions array)
- NSSupportsLiveActivities: YES is auto-configured on the main app target in project.yml
- AppGroup entitlements are auto-configured for data sharing between app and extension
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 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):
- {Name}Bundle.swift — @main WidgetBundle entry point. Without this, the extension has no entry point → linker error "undefined symbol: _main" → CodeSign failure.
- LiveActivityWidget.swift — Widget struct with ActivityConfiguration(for: Attributes.self) for Lock Screen and Dynamic Island UI.
- (Optional) Intents.swift — AppIntents for interactive buttons (complete, skip, etc.).
SHARED TYPES (CRITICAL):
- ActivityAttributes struct MUST be defined in Shared/ directory (NOT in main app's Models/). Both the main app and the live activity extension compile Shared/, so the type is visible to both. Defining it only in the main app causes "Cannot find type in scope" in the extension.
SWIFT 6 CONCURRENCY:
- AppIntent static properties MUST use "static let" (not "static var"). Mutable global state violates Swift 6 concurrency rules.
- LiveActivityService (if @MainActor) → all ViewModels calling it must also be @MainActor.
PUSH-TO-START: Use APNs with activity-update payload to start activities remotely (advanced, requires server).
LocalizationLOCALIZATION (.strings files + RTL/LTR + Language Switching):
FORBIDDEN PATTERNS (CRITICAL — violation = broken app):
- NEVER hardcode translations with if/else or switch on language code. Example of FORBIDDEN code: if appLanguage == "ar" { Text("الإعدادات") } else { Text("Settings") } switch language { case "ar": return "بحث" default: return "Search" }
- NEVER build a manual translation dictionary/map in code (e.g., let translations = ["en": "Settings", "ar": "الإعدادات"]).
- NEVER use ternary operators to pick translated strings: Text(isArabic ? "الإعدادات" : "Settings").
- These patterns bypass Apple's localization system, break when new languages are added, and ignore the environment locale.
- The ONLY correct approach: use string literals in views — Text("Settings"), Button("Save"), .navigationTitle("Dashboard") — and let Localizable.strings handle translations.
- The deployment pipeline generates .strings files automatically. Your code must ONLY contain English string literals.
.strings FILE GENERATION:
- When localization is requested, generate Resources/{lang}.lproj/Localizable.strings for EACH language.
- File format: standard Apple .strings — one "key" = "translation"; per line.
- KEYS MUST BE THE ENGLISH TEXT ITSELF. Example: "Settings" = "Settings"; (en), "Settings" = "الإعدادات"; (ar). NOT snake_case like "settings_title".
- This means Text("Settings") in code auto-localizes because the key IS the English text.
- English .strings: identity mapping (key = value). Other languages: key = translated value.
- ALL user-facing strings MUST have a key: Text(), Button(), Label(), Toggle(), navigationTitle(), Section(), alert titles/messages, ContentUnavailableView labels, placeholder text.
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.
- Sample data strings stay as plain String — they are demo content, not translatable.
CONFIG_CHANGES FOR LOCALIZATIONS:
- CONFIG_CHANGES MUST include "localizations": ["en", "ar", "es"] (list of all language codes).
- The client reads this to register knownRegions in the Xcode .pbxproj and create .lproj file references.
LANGUAGE SELECTION & SWITCHING:
- Root view: @AppStorage("appLanguage") private var appLanguage: String = "en"
- Root @main app MUST apply .id(appLanguage) on RootView AND set locale/layoutDirection: private var layoutDirection: LayoutDirection { ["ar", "he", "fa", "ur"].contains(appLanguage) ? .rightToLeft : .leftToRight } RootView() .id(appLanguage) // MANDATORY: forces full view rebuild on language change .environment(.locale, Locale(identifier: appLanguage)) .environment(.layoutDirection, layoutDirection)
- .id(appLanguage) is CRITICAL — without it, changing language causes mirrored/broken layouts because SwiftUI animates the layout direction change instead of rebuilding. With .id(), the entire view tree is destroyed and recreated cleanly.
- Setting locale ALONE does NOT flip layout to RTL. You MUST set layoutDirection explicitly.
- RTL languages: Arabic (ar), Hebrew (he), Persian/Farsi (fa), Urdu (ur).
- App restart NOT needed — .id(appLanguage) forces a full view rebuild when @AppStorage changes.
- Settings screen: add a language picker (Picker or List with checkmark) that writes to @AppStorage("appLanguage").
- Display language name: Locale(identifier: code).localizedString(forLanguageCode: code) ?? code
RTL / LTR LAYOUT DIRECTION:
- .environment(.layoutDirection) MUST be set explicitly — .environment(.locale) does NOT set it automatically.
- Use .leading/.trailing (never .left/.right) for alignment, padding, edges.
- Icons that represent direction (back arrows, chevrons, progress bars) MUST call .flipsForRightToLeftLayoutDirection(true).
- Decorative/universal icons (checkmarks, stars, hearts) must NOT flip.
- Text alignment: always use .leading — SwiftUI resolves it to left or right based on layoutDirection.
- Padding/spacing: always use .leading/.trailing edges, never .left/.right (Edge.Set).
LOCALE-AWARE FORMATTING:
- Dates: use .formatted(date:time:) or Text(date, format:) — automatically adapts to user's locale/calendar.
- Numbers: use .formatted() or Text(number, format: .number) — respects locale decimal/grouping separators.
- Currency: use .formatted(.currency(code:)) — locale-aware symbol placement and formatting.
- Measurements: use Measurement + MeasurementFormatter for locale-appropriate units.
- NEVER manually format dates/numbers with hardcoded separators or patterns.
TESTING RTL IN PREVIEWS:
- Add preview with RTL locale: .environment(.locale, Locale(identifier: "ar")) and .environment(.layoutDirection, .rightToLeft)
- Verify: text alignment flips, HStack order reverses, directional icons mirror, padding sides swap.
MAPS & LOCATION:
- import MapKit; use Map(position:) { } for SwiftUI maps
- Annotation("Label", coordinate:) { } for custom map pins
- MapCircle, MapPolyline for overlays
- CLLocationManager for user location — requires NSLocationWhenInUseUsageDescription (add CONFIG_CHANGES)
- @State private var position: MapCameraPosition = .automatic
- MKLocalSearch for place search; MKDirections for routing
- .mapControls { MapUserLocationButton(); MapCompass() }
OVERLAY PATTERNS ON MAP VIEWS:
- Map() with .ignoresSafeArea() fills screen edge-to-edge (behind status bar and home indicator)
- For overlays on a map (search bar, header, floating buttons), use .safeAreaInset(edge:) on the Map: Map(position: $position) { ... } .ignoresSafeArea() .safeAreaInset(edge: .top) { HeaderView() .padding(.horizontal) .background(.ultraThinMaterial) } .safeAreaInset(edge: .bottom) { ActionBar() .padding() .background(.ultraThinMaterial) }
- NEVER use ZStack { Map(); VStack { overlay }.padding(.top, 60) } — magic numbers break on different devices
- .mapControls positions built-in controls (compass, user location) inside safe area automatically
- For floating action buttons, use .overlay(alignment: .bottomTrailing) with .padding() — overlay respects safe area by default
ViewModel Pattern (In-Memory Default)
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 }
}
}
ViewModel Rules
- Annotate with
@Observableand@MainActor - ViewModel files contain ONLY the
@Observableclass — no other type declarations - Handle business logic: CRUD, filtering, sorting, validation
- Initialize data arrays from model's
static sampleDataso app looks alive on first launch - May import
SwiftUIandUIKitwhen needed for framework types, animation helpers, or required platform bridges - Must NOT contain Views,
UIView/UIViewControllerdeclarations,body, or#Preview
View Responsibilities
- Views own
@Statefor local UI state (sheet presented, text field binding, animation flags) - Views reference ViewModels via
@State var viewModel = SomeViewModel() - Every View file MUST include a
#Previewblock with sample data
Data Access Patterns
| 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 Selection Guide
| 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 |
NavigationStack
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")
}
TabView with Tab API
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()
}
}
Sheets
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()
}
Full Screen Cover
Use for immersive content that should cover the entire screen:
@State private var showOnboarding = false
.fullScreenCover(isPresented: $showOnboarding) {
OnboardingView()
}
Type-Safe Routing
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
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() }
NotificationsNOTIFICATIONS (LOCAL — no server needed):
FRAMEWORK: UserNotifications — UNUserNotificationCenter.current()
PERMISSION STATES (CRITICAL — must handle ALL four states):
- NOT DETERMINED: Call .requestAuthorization(options: [.alert, .badge, .sound]). System shows dialog.
- AUTHORIZED: Schedule notifications normally.
- DENIED: App CANNOT re-request permission. Must redirect user to System Settings.
- PROVISIONAL: Quiet notifications — treat as authorized.
PERMISSION IS SYSTEM-CONTROLLED (CRITICAL):
- Notification authorization is owned by the SYSTEM, not the app.
- Once the user denies, the app has NO way to re-request — only the user can re-enable in System Settings.
- To open System Settings: UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!)
UI THAT DISPLAYS NOTIFICATION STATUS (in ANY view — must handle ALL three states):
- .notDetermined: Show an "Enable Notifications" button that calls requestAuthorization(options: [.alert, .badge, .sound]). After the system dialog, re-check status and update UI.
- .authorized / .provisional: Show enabled state (e.g. "bell" icon, "Notifications are enabled" text). This is read-only — the user can disable in System Settings.
- .denied: Show disabled state ("bell.slash" icon) with an actionable "Open Settings" button that calls UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!). Show helper text explaining the user must enable in System Settings.
- NEVER use a writable Toggle for notification permission — the app cannot grant/revoke it programmatically. Use buttons with state-specific actions instead.
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:
- Local: UNMutableNotificationContent + UNNotificationRequest
- Time-based: UNTimeIntervalNotificationTrigger(timeInterval:repeats:)
- Calendar-based: UNCalendarNotificationTrigger(dateMatching:repeats:)
- Default to local notifications unless user explicitly asks for remote/push.
BADGE COUNT:
- Set via UNMutableNotificationContent().badge = NSNumber(value: count)
- Clear on app open: UIApplication.shared.applicationIconBadgeNumber = 0
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):
- manifest.json (Web Extension manifest v3)
- background.js (service worker)
- content.js (injected into pages)
- popup.html (toolbar popup)
ENABLE IN SAFARI: User must enable in Safari → Settings → Extensions.
Share ExtensionSHARE 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 IntentsSIRI 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.
Spacing & Layout8-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:
- Outer screen margins: 16pt (.padding(.horizontal, AppTheme.Spacing.medium)).
- Card internal padding: 16pt (.padding(AppTheme.Spacing.medium)).
- Section spacing (between groups): 24pt.
- List row vertical padding: 12pt.
- Toolbar/header spacing: 16pt horizontal, 8pt vertical.
TAP TARGET SIZES:
- Minimum: 44x44pt for ALL interactive elements (Apple HIG requirement).
- If visual element is smaller (e.g. 24x24 icon), expand hit area: .frame(minWidth: 44, minHeight: 44) .contentShape(Rectangle())
- Inline buttons in text: ensure surrounding padding creates 44pt height.
- Icon buttons: use .frame(width: 44, height: 44) even if icon is 20-24pt.
VERTICAL RHYTHM:
- Consistent spacing within a section (all rows use same gap).
- Larger gap between sections than within sections.
- Example: 8pt between rows within a section, 24pt between sections.
- Headers get extra top spacing: 32pt above, 8pt below.
DENSITY GUIDELINES (from plan's design.density):
- "spacious": Use .large/.xLarge gaps. Generous padding (20-24pt). Best for meditation, health, reading apps.
- "standard": Use .medium gaps. 16pt padding. Default for most apps.
- "compact": Use .small/.xSmall gaps. 12pt padding. Best for data-heavy apps (finance, productivity).
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:
- Space between icon and label: 8pt.
- Space between label and trailing value: use Spacer().
- Button bar with 2-3 buttons: HStack with spacing: 12pt.
- 4+ buttons: wrap in ScrollView(.horizontal).
SAFE AREA:
- Content respects safe areas by default — don't add redundant padding.
- Full-screen backgrounds use .ignoresSafeArea() to extend edge-to-edge.
- Overlays use .safeAreaInset(edge:) — never manual padding for safe areas.
SPEECH RECOGNITION:
- import Speech; SFSpeechRecognizer()
- Requires NSSpeechRecognitionUsageDescription + NSMicrophoneUsageDescription (add CONFIG_CHANGES)
- SFSpeechRecognizer.requestAuthorization() for permission
- On-device: SFSpeechRecognizer(locale:), set requiresOnDeviceRecognition = true
- Live: SFSpeechAudioBufferRecognitionRequest + AVAudioEngine
- File: SFSpeechURLRecognitionRequest(url:)
Default: In-Memory with Sample Data
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 |
In-Memory Models (Default)
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"),
]
}
In-Memory ViewModel (Default)
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 }
}
}
App Entry Point (In-Memory — No Container)
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
@AppStorage
Use for simple key-value settings:
@AppStorage("sortOrder") private var sortOrder = "date"
@AppStorage("showCompleted") private var showCompleted = true
SwiftData (ONLY when user explicitly requests persistence)
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()
}
}
Swift Conventions
Platform Requirements
- Swift 6 language version
- iOS 26+ deployment target
- SwiftUI-first architecture. UIKit is allowed only when no viable SwiftUI equivalent exists for a required feature.
- No Storyboards or XIBs
Allowed Frameworks
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)
API Preferences
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 Concurrency Safety
Swift 6 enables strict concurrency checking by default. All data races are compile-time errors.
Sendable Conformance
- All types that cross isolation boundaries MUST conform to
Sendable. - Value types (structs, enums) with only Sendable stored properties are implicitly Sendable.
- Reference types (classes) must be
finaland have only immutable (let) Sendable stored properties, OR be marked@unchecked Sendablewith manual thread safety (mutex/lock). - Closures that cross isolation boundaries must be
@Sendable— they cannot capture mutable local state.
@MainActor Isolation
- ViewModels that update
@Publishedor@Observablestate MUST be annotated@MainActor. - Services that read/write UI-bound state MUST be
@MainActor. - SwiftUI Views are implicitly
@MainActor, so@State var vm: SomeViewModelworks ifSomeViewModelis@MainActor. - Use
@MainActoron the class/actor declaration, not on individual methods — partial isolation causes confusing errors.
nonisolated Usage
- Computed properties that return Sendable values and don't access mutable actor state should be
nonisolated. - Protocol conformance requirements (e.g.,
Hashable.hash(into:),CustomStringConvertible.description) on@MainActortypes must benonisolated. - Use
nonisolated(unsafe)only as a last resort for legacy API bridging.
Crossing Isolation Boundaries
- Do not pass
@MainActor-isolated values directly into APIs that execute in@concurrentcontexts. - Snapshot required values into local Sendable variables first, perform the concurrent call, then hop back to
MainActorfor 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 } - The
sendingparameter keyword marks closure parameters that cross isolation boundaries — use it when writing APIs that accept callbacks from different isolation domains.
Global Variables
- Use
static letfor shared constants —static varis a mutable global and violates strict concurrency. - If mutable global state is truly needed, wrap it in an
actoror@MainActor-isolated type. AppIntentstatic properties (title, description) MUST bestatic let.
Actor Re-entrancy
- Actor methods can suspend and resume — do not assume state is unchanged after an
awaitinside an actor. - Re-read state after suspension points; do not rely on pre-await values.
@preconcurrency Import
- Use
@preconcurrency import FrameworkNamefor frameworks not yet annotated for Sendable (e.g., some older Apple frameworks). - This silences warnings for types the framework hasn't updated, without losing safety for your own code.
Translation Framework
- For
.translationTask { session in ... }flows, do NOT callsession.translate(...)inside@MainActor-isolated methods. - Perform translation work off the main actor and hop back to
@MainActoronly for UI state updates.
Import Rules
- Start every file with the appropriate
importstatement - Only import what's needed — don't import
SwiftUIin a model file that only needsFoundation - ViewModels may import
SwiftUIandUIKitwhen needed for framework types or platform bridges, but must not declare Views/UIViewControllers
TIMERS:
- Use TimelineView(.animation) for smooth countdown/stopwatch UI
- Timer.publish(every:on:in:).autoconnect() for periodic updates
- @State private var timeRemaining: TimeInterval for countdown state
- .onReceive(timer) { _ in } to update state each tick
- Format with Duration.formatted() or custom mm:ss formatter
- Invalidate timer in .onDisappear to prevent leaks
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:
- ONE .largeTitle per screen (via .navigationTitle with .large display mode).
- .headline for list row titles and card headings.
- .body for primary content paragraphs and descriptions.
- .subheadline or .caption for metadata (dates, counts, secondary info).
- Visual hierarchy: at least 2 levels of contrast per screen (e.g. .headline + .caption).
FONT WEIGHT GUIDANCE:
- .regular: body text, descriptions.
- .medium: subtle emphasis, tab labels.
- .semibold: section headers, row titles, buttons.
- .bold: primary action labels, important callouts.
- AVOID .ultraLight, .thin, .light — poor readability, especially on small screens.
FONT DESIGN:
- .fontDesign(.rounded): friendly, playful apps (fitness, kids, social).
- .fontDesign(.serif): editorial, premium (news, reading, luxury).
- .fontDesign(.monospaced): technical, developer tools, code display.
- .fontDesign(.default): neutral, professional (productivity, finance).
- Apply on outermost container — it cascades to all children.
DYNAMIC TYPE:
- System text styles automatically scale with user's accessibility settings.
- NEVER use .font(.system(size: N)) — it opts out of Dynamic Type.
- If a layout breaks at larger type sizes, use .minimumScaleFactor(0.8) as last resort.
- Test with largest accessibility size: Xcode → Environment Overrides → Dynamic Type.
LINE SPACING & READABILITY:
- System styles include appropriate leading — don't override unless necessary.
- For long-form text, .lineSpacing(4) improves readability.
- Maximum comfortable line length: ~70 characters. Use .frame(maxWidth: 600) for wide screens.
NUMBER & DATA DISPLAY:
- Use .monospacedDigit() for numbers that change (timers, counters, prices).
- This prevents layout jitter as digits change width.
- Example: Text(price).font(.title2).monospacedDigit()
@ViewBuilder Computed Properties
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)
}
}
Size Guidelines
- Each computed property: 15-40 lines
- If a section exceeds 40 lines, extract it to a separate struct
- If a section is reused in multiple views, extract to a separate file
Section Naming Pattern
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)
When to Extract to Separate Struct
Extract a section into its own View struct when:
- It exceeds 40 lines of code
- It's reused across multiple views
- It has its own state (needs
@Stateor@Binding) - It represents a distinct UI component (e.g., a card, a row)
Body Should Only Contain Property References
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 Complexity
VIEW BODY COMPLEXITY (CRITICAL):
- If a View
bodygrows beyond about 30 lines, extract sections into computed properties. - If nesting is deeper than 3 levels, flatten the structure by extracting sub-sections.
- Prefer a body that reads like a table of contents:
headerSectioncontentSectionfooterSection
REQUIRED 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:
- Error pattern: "The compiler is unable to type-check this expression in reasonable time"
- Fix by splitting large expressions:
- Move long
HStack/VStack/ZStackbranches into computed properties - Move complex
Chartblocks into computed properties - Move long modifier chains into intermediate variables/properties
- Move long
- Keep behavior exactly the same; only refactor structure.
WHEN EDITING EXISTING FILES:
- If your new change pushes body size over the threshold, include the refactor in the same edit.
- Do not leave a giant body as technical debt.
WEBSITE LINKS:
- Link("Visit Website", destination: URL(string: "https://...")!) for simple links
- Or @Environment(.openURL) var openURL; Button { openURL(url) } for custom styling
- No permissions needed for external URLs
- Safari opens by default; use SFSafariViewController via UIViewControllerRepresentable for in-app browser
- Validate URL before force-unwrapping: guard let url = URL(string:) else { return }
WIDGETS (WidgetKit): FRAMEWORK: import WidgetKit
SETUP:
- Requires separate widget extension target (kind: "widget" in plan extensions array)
- Use AppGroup for data sharing between app and widget (auto-configured in project.yml)
- Widget code goes in Targets/{WidgetName}/ directory
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) -> 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):
- {Name}Bundle.swift — @main WidgetBundle entry point. Without this, the extension has no entry point → linker error "undefined symbol: _main" → CodeSign failure.
- Provider.swift — TimelineProvider implementation.
- WidgetView.swift — The widget's SwiftUI view.
- (Optional) Intent.swift — AppIntent for interactive widgets (tap-to-complete, etc.).
CRITICAL RULES:
- .containerBackground(.fill.tertiary, for: .widget) is REQUIRED on the widget view in iOS 17+. Without it, the widget renders with no background.
- The @main entry point is MANDATORY. Use @main on WidgetBundle (multiple widgets) or Widget (single widget). An extension target with NO @main will fail to link.
- Shared data types between app and widget go in the Shared/ directory at the project root (both targets compile it). NEVER define shared types only in the main app's Models/ — the widget extension cannot see them.
- Widget views must be self-contained — they cannot use @StateObject, @ObservedObject, or network calls. All data comes through the TimelineEntry.
- Use .supportedFamilies() to declare which sizes the widget supports.
- AppIntent static properties MUST use "static let" (not "static var") for Swift 6 concurrency safety. Using "static var" causes "not concurrency-safe because it is nonisolated global shared mutable state" error.