iOS Development Rules
38 rules for writing production-quality iOS apps. Each rule targets common LLM mistakes with concrete fixes.
Accessibility
REDUCE 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
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
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 Translation
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.
Biometrics
BIOMETRIC 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
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)
Charts
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
Color & Contrast
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.
Component Patterns
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 Mode
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).
Design System Rules
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
Feedback States
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.
File Structure Rules
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
Forbidden Patterns
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
Gestures
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.
Haptics
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
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
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).
Localization
LOCALIZATION (.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(), La
…(truncated)