# IOS Interaction Primitives Design

> Design and build best-in-class native iOS interaction surfaces — Home Screen widgets, Lock Screen widgets, StandBy widgets, Live Activities, Dynamic Island presentations, Control Center custom controls, Haptic Touch context menus, haptic feedback (UIFeedbackGenerator and CoreHaptics), Action Button, Camera Control button, App Intents, Symbol Effects, and Focus filters. Use this skill whenever the user is building or polishing any of these peripheral surfaces that surround a native iOS app, especially for apps targeting iOS 17, iOS 18, or iOS 26 (Liquid Glass). Triggers on: widget, WidgetKit, Lock Screen widget, StandBy widget, interactive widget, App Intent, ControlWidget, Control Center widget, Live Activity, ActivityKit, Dynamic Island, Haptic Touch, long press, context menu, haptic feedback, UIImpactFeedbackGenerator, CoreHaptics, CHHapticEngine, AHAP, Action Button, Camera Control, Symbol Effects, symbolEffect, Focus filters, FocusFilterIntent, iOS 26, Liquid Glass widget, glassEffect.

- Skill: `heyimjames/ios-interaction-primitives-design` (Agent Skill)
- Install (CLI): `npx skillmds@latest add heyimjames/ios-interaction-primitives-design`
- Raw SKILL.md: https://api.skillmd.com/api/skills/heyimjames/ios-interaction-primitives-design/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: heyimjames (https://skillmd.com/u/heyimjames)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/heyimjames/ios-interaction-primitives-design

---


# iOS Interaction Primitives — Design Engineering Skill

A taste guide for the *peripheral* surfaces that surround a native iOS app — the Home Screen widget, the Dynamic Island, the haptic pulse on a button press. These are not afterthoughts. For many users, these surfaces are the *primary* product. Spotify's widget gets opened 50× more often than the app. Flighty's Dynamic Island is more memorable than Flighty's main screen.

## Output format — required

When this skill is invoked to review widget/Live-Activity/haptic code or recommend changes, **always output recommendations as a markdown table** with three columns:

| Before | After | What this changes |
| --- | --- | --- |
| The current code, value, or approach (quote the user's actual code when possible) | The recommended replacement — **specific**, with exact values | One sentence on what the user will *see, feel, or experience* differently |

Three rules:
1. **Before** quotes the user's actual code where possible.
2. **After** is specific. Exact API calls, exact `.sensoryFeedback` styles, exact widget container modifiers.
3. **What this changes** is *experiential or visual*, not abstract.

Output ONE table with multiple rows for multi-recommendation reviews — not one table per row. Use `—` for Before if the user hasn't implemented that thing yet.

**Examples drawn from this skill:**

| Before | After | What this changes |
| --- | --- | --- |
| Widget with `.background(Color.white)` | `.containerBackground(.fill.tertiary, for: .widget)` | Widget auto-adapts to iOS 26 Liquid Glass, dark mode, and Lock Screen tint with zero additional code |
| Live Activity that pushes server updates every second to refresh a countdown | `Text(timerInterval: now...endDate, countsDown: true)` rendered once | Countdown ticks on-device without burning push budget — same visual, zero server cost, no latency variance |
| `.symbolEffect(.breathe)` on an idle icon as ambient decoration | Remove it entirely (icon stays static) | The icon stops looking like a screensaver — confidence over noise; static is more refined than animated-for-no-reason |

This format is required for every recommendation output by this skill.

---

## Philosophy

> The app is the universe. The widget is the planet you see from your bed.

Three rules that govern every primitive in this guide:
1. **Glanceability is the entire design constraint.** A widget that takes 2 seconds to parse is broken. Lock-Screen widgets get 0.4 seconds of attention. Dynamic Island gets less. Design for the half-second.
2. **One App Intent, many surfaces.** Since iOS 17, the same `AppIntent` powers Home widgets, Lock widgets, StandBy, Control Center, the Action Button, and Siri. Architect your business logic around intents — your peripheral surfaces become near-free.
3. **Haptics are punctuation, not paragraphs.** They confirm, they don't communicate. The wrong haptic feels like the app yelling. The right haptic feels like the app *agreeing* with you.

The pixel-pushers' rules:
- **Widgets are content, not chrome.** No "Open in app" buttons, no settings gears, no logos. The data IS the widget.
- **Live Activities live and die.** Set a `staleDate`. End them within 8 hours. Never leave a stale Activity hanging.
- **The Dynamic Island is a canvas, not a wallpaper.** No background colors, no images that bleed to the edge — Apple's HIG is explicit: foreground elements only.
- **Default haptics are not optional.** Every tappable thing in your app should fire a haptic. If you're not sure which, use `.selectionChanged`. It's never wrong.

---

## NEW additions covered in this update

| Surface | Section |
| --- | --- |
| Apple Wallet passes (loyalty / status / events) | [§14](#14-apple-wallet-passes--gamified-loyalty--status) |
| Live Activities for urgency (countdown timers) | [§15](#15-live-activities-for-urgency--the-explode-pattern) |
| Push notification timing & content rules | [§16](#16-push-notification-timing--content--biers-rules) |
| Picture-in-Picture for guided onboarding | [§17](#17-picture-in-picture-for-guided-onboarding) |
| App Clips — try-before-install viral installs | [§18](#18-app-clips--try-before-install) |

---

## What this skill covers

| Surface | Framework | Min iOS | Section |
| --- | --- | --- | --- |
| Home Screen widgets | WidgetKit + SwiftUI | 14 (16 for Lock, 17 for interactive) | [§1](#1-home-screen-widgets) |
| Lock Screen widgets | WidgetKit | 16 | [§2](#2-lock-screen-widgets) |
| StandBy widgets | WidgetKit | 17 | [§3](#3-standby-widgets) |
| Live Activities (Lock Screen) | ActivityKit | 16.1 | [§4](#4-live-activities) |
| Dynamic Island | ActivityKit (DynamicIsland) | 16.1 (iPhone 14 Pro+) | [§5](#5-dynamic-island) |
| Control Center custom controls | WidgetKit (ControlWidget) | 18 | [§6](#6-control-center-custom-controls) |
| Haptic Touch context menus | UIContextMenuInteraction / `.contextMenu` | 13 | [§7](#7-haptic-touch--context-menus) |
| UIFeedbackGenerator haptics | UIKit | 10 | [§8](#8-haptic-feedback-the-easy-90) |
| Core Haptics (custom patterns) | CoreHaptics | 13 | [§9](#9-core-haptics-the-delightful-10) |
| Action Button | App Intents | 17 (iPhone 15 Pro+) | [§10](#10-action-button-iphone-15-pro) |
| Camera Control button | AVFoundation | 18 (iPhone 16/16 Pro) | [§11](#11-camera-control-button-iphone-16) |
| Symbol Effects | SwiftUI | 17 | [§12](#12-symbol-effects) |
| Focus filters | AppIntents | 16 | [§13](#13-focus-filters) |

---

## Reference apps to study

Premium examples of each surface, with Mobbin citations:

| App | What to learn | Mobbin |
| --- | --- | --- |
| **Flighty** | Best-in-class Dynamic Island for flight tracking — compact (countdown), expanded (full status), live updates throughout journey | [Flighty Dynamic Island](https://mobbin.com/screens/cfd5bf7f-efe2-4a72-a8ba-84302dc5c331), [delay state](https://mobbin.com/screens/0c6d9f54-f050-488d-92d4-c0fbeeed6901) |
| **FocusFlight** | Lock Screen Live Activity for flights — large rectangular widget with route/ETA/timezone, never feels cluttered | [FocusFlight LA](https://mobbin.com/screens/54d63fe8-f924-436c-9071-c63038dbefb6) |
| **Runbuds** | Workout Live Activity — distance, pace, time with red accent, no chrome | [Runbuds DI](https://mobbin.com/screens/4902bd7a-b3c6-473a-a9c0-af8be91ff5b4), [Lock Screen](https://mobbin.com/screens/8ebcad43-ccca-4a8e-81c2-70a0496a0393) |
| **Moonlitt / Sunlitt** | Beautiful gradient-backed Lock Screen widgets for moon/sun phases — content as art | [Moonlitt Lock](https://mobbin.com/screens/cee3304b-1709-427f-8ac4-7df615080050), [Sunlitt Golden Hour](https://mobbin.com/screens/dfe1dd5e-2194-45d0-bd1c-72484fc92956) |
| **Duolingo** | Streak widget on Lock Screen + Dynamic Island variants — emotional hook (the sad owl when streak is in danger) | [Streak widget](https://mobbin.com/screens/c78046b1-5d17-4225-ad76-2d66a72ac8bd) |
| **Yazio** | Lock Screen Live Activity for tracking meals — 4 ring meters in one strip | [Yazio Lock Screen](https://mobbin.com/screens/4bc38d1d-5a05-4e0a-8939-352891988c19) |
| **Transit** | Real-time bus arrival Live Activity — countdown with route color | [Transit Lock](https://mobbin.com/screens/b84f786b-1c21-4110-9977-f0544d9adcf5) |
| **MyFitnessPal / Yazio / Alma / GO Club** | Interactive widgets for logging water, calories, steps — tap to log without opening the app | [MFP Water widget](https://mobbin.com/screens/f74772bf-95d0-4a91-8e2d-56d33a77508e), [Alma Carrot](https://mobbin.com/screens/fe23c4a3-b043-432c-aa9f-9bb4db519889) |
| **TIDE / Tolan** | Minimal Dynamic Island for ambient apps (timers, AI characters) | [TIDE](https://mobbin.com/screens/4d286ed5-f515-4bd7-aa63-2bb579b8030b), [Tolan Lock Screen](https://mobbin.com/screens/a0693172-1a6d-4e4b-80b9-f8a29980cc4f) |
| **Apple Music / Maps / Timer** | The system standard for Live Activities. Study them on your device — they're the bar. | (use your device) |

---

## 1. Home Screen widgets

Widgets are SwiftUI views rendered by the system from your `TimelineProvider`. They cannot animate continuously; they redraw at intervals you specify.

### Sizing

| Size | Use case |
| --- | --- |
| `.systemSmall` (2×2 grid) | A single number / status (steps today, weather temp, streak count) |
| `.systemMedium` (4×2) | A title + 1–2 supporting data points, OR a horizontal bar chart |
| `.systemLarge` (4×4) | A list of items (up next 3 calendar events, top 5 tasks) |
| `.systemExtraLarge` (iPad only) | A dashboard view |
| `.accessoryCircular`, `.accessoryRectangular`, `.accessoryInline` | Lock Screen and StandBy widgets |

### Layout grid

- 22pt margins inside `.systemSmall` and `.systemMedium`.
- 24pt margins inside `.systemLarge`.
- 8pt content gap (between title and primary data).
- Corner radius: handled by the system. Use `ContainerRelativeShape()` to follow the widget's outer shape for inner elements (cards, backgrounds).

```swift
RoundedRectangle(cornerRadius: 22, style: .continuous) // ❌ Wrong
ContainerRelativeShape()                                // ✅ Right
```

### Typography for widgets

The single most common widget mistake: text too small to read at arm's length. Bars:

| Element | Font | Weight | Size |
| --- | --- | --- | --- |
| Hero number | SF Pro Rounded | `.bold` | 36pt (small) / 48pt (medium) / 64pt (large) |
| Hero label | SF Pro | `.semibold` | 13pt |
| Supporting metric | SF Pro | `.medium` | 15pt |
| Caption / timestamp | SF Pro | `.regular` | 11pt |

Always set `.minimumScaleFactor(0.7)` on hero numbers to handle internationalization gracefully (12,345 km is wider than 1.2 mi).

### Refresh cadence

Widgets are NOT push-driven. The system reads your timeline.

- Budget: roughly 40–70 reloads per day across all your widgets per device.
- For frequent updates, return a longer timeline (e.g., 24 entries spaced 1 hour apart) so the system has data to render between actual reloads.
- For sparse updates (e.g., delivery tracking that changes 4 times a day), use `WidgetCenter.shared.reloadTimelines(ofKind:)` from the app or a background task.

```swift
struct CoffeeWidgetProvider: TimelineProvider {
    func getTimeline(in context: Context, completion: @escaping (Timeline<CoffeeEntry>) -> Void) {
        let entries = generateNextHourEntries() // 12 entries, 5min apart
        let timeline = Timeline(entries: entries, policy: .after(Date().addingTimeInterval(3600)))
        completion(timeline)
    }
}
```

### Interactive widgets (iOS 17+)

The killer feature. Same `AppIntent` powers widget buttons, Control Center, Action Button, Siri.

**Anatomy:**
- `Button(intent:)` or `Toggle(isOn:intent:)` — these render as SwiftUI controls and execute the intent in the background.
- The intent's `perform()` runs in the WIDGET extension's process, not your app's. Share data via App Groups.
- After the intent runs, the widget timeline refreshes — your UI updates with the new state.

```swift
import WidgetKit
import SwiftUI
import AppIntents

struct LogWaterIntent: AppIntent {
    static var title: LocalizedStringResource = "Log a glass of water"

    @Parameter(title: "Amount (ml)")
    var amount: Int

    func perform() async throws -> some IntentResult {
        await WaterStore.shared.log(amount: amount) // shared via App Group
        return .result()
    }
}

struct WaterWidgetView: View {
    let entry: WaterEntry
    var body: some View {
        VStack(alignment: .leading, spacing: 8) {
            HStack {
                Image(systemName: "drop.fill").foregroundStyle(.blue)
                Text("Water").font(.system(size: 13, weight: .semibold))
            }
            Text("\(entry.totalToday) ml")
                .font(.system(size: 36, weight: .bold, design: .rounded))
                .contentTransition(.numericText(value: Double(entry.totalToday)))
            Button(intent: LogWaterIntent(amount: 250)) {
                Label("+250 ml", systemImage: "plus")
                    .font(.system(size: 13, weight: .semibold))
            }
            .buttonStyle(.borderedProminent)
            .tint(.blue)
        }
        .containerBackground(.fill.tertiary, for: .widget)
    }
}
```

**Animation in widgets**: limited but lovely.
- `.contentTransition(.numericText(value:))` — rolls digits like an odometer when numbers change.
- `.contentTransition(.symbolEffect)` — bounces SF Symbols on state change.
- `withAnimation` works inside intent callbacks — the system animates the diff between widget states.

### iOS 26 Liquid Glass widgets

iOS 26 widgets adopt the Liquid Glass material. To opt in:
- Set `.containerBackground(.fill.tertiary, for: .widget)` — the system picks the right material per context.
- Use `.widgetAccentable()` on elements you want tinted in Accent rendering mode (the user can choose between Auto / Accent / Light / Dark tint per widget).
- Avoid hardcoded colors for backgrounds; use semantic system colors so they adapt.
- For dark accent on light wallpaper / vice versa, use `.foregroundStyle(.primary)` and let the system handle contrast.

```swift
Text("\(entry.steps)")
    .font(.system(size: 48, weight: .bold, design: .rounded))
    .widgetAccentable() // gets tinted in Accent mode
```

### MeshGradient for premium Home Screen widgets (iOS 18+)

For brand-defining widgets where the background IS part of the personality (workout apps, finance apps, music apps), use `MeshGradient` as the container background. It looks hand-painted and renders for free.

```swift
struct PremiumWidget: View {
    let entry: Entry
    var body: some View {
        VStack(alignment: .leading) {
            Text("\(entry.value)")
                .font(.system(size: 48, weight: .bold, design: .rounded))
                .foregroundStyle(.white)
            Text("Today").font(.caption).foregroundStyle(.white.opacity(0.8))
        }
        .containerBackground(for: .widget) {
            MeshGradient(
                width: 2, height: 2,
                points: [[0,0], [1,0], [0,1], [1,1]],
                colors: [.indigo, .purple, .pink, .orange]
            )
        }
    }
}
```

**Rules:**
- **Home widgets only.** Lock Screen widgets render in tint-mode (monochrome) — MeshGradient gets flattened and looks broken. Use `.containerBackground(.fill.tertiary, for: .widget)` (semantic) for Lock variants.
- **Dynamic Island is forbidden from backgrounds.** Apple's HIG: foreground elements only. Never apply MeshGradient or any background fill to a Dynamic Island presentation.
- **Pick colors in OKLCH and ship as Display P3** — `Color(.displayP3, red:green:blue:)` — so the mesh feels balanced AND renders at the wider gamut on every Apple device since 2017. See `the-final-5-percent` §5 for the full workflow.
- **Keep text high-contrast.** A MeshGradient background means dynamic colors behind your text. Add a subtle shadow or use `.foregroundStyle(.white)` with a translucent darkening layer if needed for legibility.

### Anti-patterns
- **Don't put a "Open App" button.** Tap-anywhere-to-open is automatic. Buttons should DO things.
- **Don't fill the widget with chrome.** Top label + giant data point + maybe one secondary element. That's it.
- **Don't update a sleeping widget.** If nothing's changed, return a long-tail timeline. Updating just to update wastes the budget.
- **Don't use thin fonts.** SF Pro Light at any size looks fine on a high-DPI display in your editor and unreadable on a Lock Screen at arm's length.

---

## 2. Lock Screen widgets

Three sizes, all monochrome, all tiny. Treat them as iconography, not data viz.

| Family | Size | Use case |
| --- | --- | --- |
| `.accessoryCircular` | ~76 × 76pt rendered | A single number, a gauge, a progress ring |
| `.accessoryRectangular` | ~160 × 76pt rendered | A title + one line of supporting info |
| `.accessoryInline` | Single text line beside time | "Coffee: 3 cups", "Next: Sam 14:30" |

### Visual language

Lock Screen widgets render in **tint mode** by default — flat single-color silhouettes (the user's chosen tint). You can render in **full color** by setting `.widgetRenderingMode(.fullColor)` but this is rarely the right call — full color photos on the Lock Screen look out of place and Apple actively discourages it.

Design rules:
- **Use SF Symbols** for icons (they tint correctly).
- **Use Gauge** for progress (built-in, looks native).
- **NO drop shadows or gradients** in tint mode — they're flattened to a single color and look weird.
- **Use `.privacySensitive()`** on data you don't want shown when the device is locked (Apple Watch only honors this currently, but it's good practice).

```swift
struct StreakLockWidget: View {
    let entry: StreakEntry
    var body: some View {
        VStack {
            Image(systemName: "flame.fill")
                .font(.system(size: 18))
            Text("\(entry.days)")
                .font(.system(size: 22, weight: .bold, design: .rounded))
                .contentTransition(.numericText())
            Text("days").font(.system(size: 9))
        }
    }
}
```

### Tap behavior

Tapping a Lock-Screen widget opens the app to a specific destination. Use `widgetURL`:

```swift
WaterLockWidgetView(entry: entry)
    .widgetURL(URL(string: "myapp://water/today")!)
```

OR — in iOS 17+ — make the entire widget a `Button(intent:)`. The intent fires, the app does NOT open. Use this for "increment my coffee count from the Lock Screen" UX.

---

## 3. StandBy widgets

When iPhone is plugged in and on its side (iOS 17+), it enters StandBy mode. Your widget appears in a special context: nighttime-friendly, glanceable from across a room.

- StandBy uses `.systemSmall` widgets by default in a stacked carousel.
- At night, the system shifts to **Red Tint mode** automatically — your widget gets a red monochrome rendering. SF Symbols and text re-tint correctly; gradient/photo backgrounds get clipped.
- Always design the small widget to look great at 6 feet away. That's the test.

Design rules for StandBy:
- Hero number ≥ 56pt.
- High contrast — white on black, or your accent on dark.
- No interactive buttons in StandBy (taps just open the app).
- Test red-tint mode! Most apps haven't and look terrible.

---

## 4. Live Activities

Live Activities display real-time data on the Lock Screen and Dynamic Island. They survive in the system for up to 8 hours (with caveats — iOS 17.2+ allows extension).

### When to use them

✅ Sports score in progress
✅ Food delivery / rideshare ETA
✅ Workout in progress
✅ Timer / Pomodoro
✅ Flight tracker
✅ Audio call / FaceTime
✅ Long-running export / upload

❌ Background processes the user doesn't care about minute-to-minute
❌ Daily check-ins ("Don't forget your meditation!")
❌ Marketing / promotions
❌ "Hey, look at me!" — be a tool, not a billboard

### Anatomy

You define:
1. `ActivityAttributes` — static info that doesn't change (e.g., "Flight DL 412 SFO → JFK").
2. `ContentState` — dynamic info that updates (e.g., gate, delay status, ETA).
3. **Four presentations**:
   - **Lock Screen** (large rectangular widget on Lock Screen + Notification Center)
   - **Dynamic Island compact** (leading + trailing islands when only your activity is showing)
   - **Dynamic Island expanded** (when user touches and holds the Island)
   - **Dynamic Island minimal** (a tiny icon when multiple activities are competing)

```swift
struct FlightAttributes: ActivityAttributes {
    public struct ContentState: Codable, Hashable {
        var status: FlightStatus  // boarding, departed, in-flight, landed
        var minutesRemaining: Int
        var gate: String?
    }
    var flightNumber: String
    var origin: String
    var destination: String
}

struct FlightLiveActivity: Widget {
    var body: some WidgetConfiguration {
        ActivityConfiguration(for: FlightAttributes.self) { context in
            // Lock Screen presentation
            FlightLockView(context: context)
        } dynamicIsland: { context in
            DynamicIsland {
                // Expanded
                DynamicIslandExpandedRegion(.leading) { ... }
                DynamicIslandExpandedRegion(.trailing) { ... }
                DynamicIslandExpandedRegion(.bottom) { ... }
            } compactLeading: {
                Image(systemName: "airplane")
            } compactTrailing: {
                Text(context.state.minutesRemaining, format: .number) + Text("m")
            } minimal: {
                Image(systemName: "airplane")
            }
            .keylineTint(.green) // border tint
        }
    }
}
```

### Lock Screen Live Activity design

- Max height: ~160pt (the system gives you up to this; respect it).
- Layout in tiers:
  - **Top row**: app icon (left) + title + status pill (right). 24pt height.
  - **Middle**: the hero content. A route map, a giant timer, a progress bar.
  - **Bottom row**: secondary info — gate, ETA, delta vs schedule. 20pt height.
- Use `.activityBackgroundTint(.black)` for the background or let it inherit the Lock Screen wallpaper context.
- Honor Dynamic Type via `.body`, `.caption`, etc.

**Flighty's lock-screen activity** is the reference. Study it.

### Updating from your server

Live Activities can be push-updated. Get the push token after starting the activity:

```swift
let activity = try Activity<FlightAttributes>.request(
    attributes: ...,
    content: .init(state: ..., staleDate: Date().addingTimeInterval(4 * 3600)),
    pushType: .token
)

Task {
    for await tokenData in activity.pushTokenUpdates {
        let tokenHex = tokenData.map { String(format: "%02x", $0) }.joined()
        await uploadToken(tokenHex, for: activity.id)
    }
}
```

Then send pushes via APNs to `push-type: liveactivity`.

**Best-practice cadence**: update only when something *changed enough to notice*. For a flight: every 10 minutes during pre-boarding, every 2 minutes during taxi/approach. For a ride-share: every 30s while in transit. For audio playing: 5–10s.

### staleDate

Set `staleDate` aggressively. After this date, your activity becomes visually "stale" (slightly desaturated) until the next update. Users hate seeing 2-hour-old data presented as live.

### Ending Live Activities

```swift
await activity.end(
    ActivityContent(state: finalState, staleDate: nil),
    dismissalPolicy: .after(Date().addingTimeInterval(60))
)
```

Three dismissal options:
- `.immediate`: removes from UI immediately.
- `.default`: keeps for ~4 hours after ending (user can swipe away).
- `.after(date)`: keeps until specified date (max 4 hours).

For "you've arrived" / "your timer's done" → `.after(now + 60s)` so the user sees the end state, then it disappears.

---

## 5. Dynamic Island

The Dynamic Island is a *canvas of foreground elements floating around the TrueDepth camera*. Apple's HIG is explicit: **no background colors, no images that bleed.**

### Three presentations

**Compact** (the default when only your activity is live):
- Two regions: **leading** (left of camera) and **trailing** (right of camera).
- Each region max ~50pt wide.
- Use a single icon + ≤ 5 characters of text per side.
- Leading: usually an icon representing the activity (airplane, fork, timer).
- Trailing: the most-glanceable data point (countdown, ETA, score).

**Minimal** (when multiple activities compete, or yours isn't most active):
- A SINGLE element — usually a 22 × 22pt icon.
- Two minimal activities show side by side, one "attached" to the Island, one floating just below it.

**Expanded** (when user touches-and-holds the Island):
- Four regions: `.leading`, `.trailing`, `.center`, `.bottom`.
- This is where you can show rich content: a map, progress rings, multiple lines of text, controls.
- Max height: ~200pt.
- **You CAN have buttons here** (interactive Live Activities, iOS 17+) — `Button(intent:)` etc.

### Design rules (these are not optional)

From Apple's HIG:
1. **No background colors.** The Island IS your background.
2. **No images that touch the edges.** Inset all visual elements with ≥ 4pt padding.
3. **No buttons in compact or minimal.** Interactive elements only in expanded.
4. **Use `.keylineTint(...)`** to add a subtle 1pt border tint around the entire Island — this is the ONLY chrome you get for branding. Use it sparingly.
5. **Honor sensitivity:** if the user has Reduce Motion on, skip transitions; if Reduce Transparency, you don't need to change anything (the Island is opaque already).

### Animation between states

Transitions between content states are automatic. To make them beautiful:
- Use `.contentTransition(.numericText())` for changing numbers — they roll like an odometer.
- Use `.symbolEffect(.bounce)` for SF Symbols that change.
- Use `.transition(.opacity)` for swappable views.
- **Avoid complex layout changes** between updates — the system animates between two snapshots, and big layout deltas look choppy.

### Compact → Expanded transition

When the user long-presses the Island, the system runs a smooth morph from compact to expanded. You DON'T animate this — the system does. Your job is to make the two views *related enough* that the morph feels natural. Keep iconography consistent. Keep colors consistent.

### Minimal presentation

When two activities compete, your activity might be shown minimal. Test this. Many apps look great in compact and weird in minimal because the icon they chose doesn't read at 22pt.

### Real-world examples to study (Mobbin)

- **Flighty** — compact: airplane icon + countdown. The countdown turns red as you approach departure. [→](https://mobbin.com/screens/cfd5bf7f-efe2-4a72-a8ba-84302dc5c331)
- **FocusFlight** — full Lock Screen activity with origin/destination/route line. [→](https://mobbin.com/screens/54d63fe8-f924-436c-9071-c63038dbefb6)
- **Runbuds** — compact: running figure + distance. Lock Screen: distance / time / pace in three big rows. [→](https://mobbin.com/screens/4902bd7a-b3c6-473a-a9c0-af8be91ff5b4)
- **TIDE** — minimal-style breathing visualization. Look at how restrained it is. [→](https://mobbin.com/screens/4d286ed5-f515-4bd7-aa63-2bb579b8030b)

---

## 6. Control Center custom controls

New in iOS 18. Control Center now hosts custom `ControlWidget`s — the same App Intent architecture as widgets.

### Two types

**`ControlWidgetButton`**: a one-shot tap action.
```swift
struct StartTimerControl: ControlWidget {
    var body: some ControlWidgetConfiguration {
        StaticControlConfiguration(kind: "com.app.startTimer") {
            ControlWidgetButton(action: StartTimerIntent()) {
                Label("Start Timer", systemImage: "timer")
            }
        }
        .displayName("Start Timer")
        .description("Quickly start a 25-minute timer.")
    }
}
```

**`ControlWidgetToggle`**: a state with on/off.
```swift
ControlWidgetToggle(
    "Focus Mode",
    isOn: focusEnabled,
    action: ToggleFocusIntent()
) { isOn in
    Label(isOn ? "Focus On" : "Focus Off",
          systemImage: isOn ? "moon.fill" : "moon")
}
```

### Where Controls appear

- **Control Center** (the user adds them via the redesigned Control Center).
- **Lock Screen** (bottom corners — replace flashlight/camera).
- **Action Button** (user can assign your control to it).

This is the magic: ONE intent, ONE control declaration, THREE surfaces. iOS 26 even adds them to the home screen widget surface as a "small action" style.

### Visual design

Controls inherit a system-rendered chrome (rounded rectangle, system material background). You provide:
- A `Label` with `Image(systemName:)` + text.
- Optionally, a `controlWidgetActionHint(...)` for accessibility.
- For toggles: separate visual states for on/off (different icon, optionally different color).

Use SF Symbols. Custom icons can be embedded via asset catalog with `Image("MyCustomIcon")`, but SF Symbols render correctly across all surfaces (full color, tinted, monochrome).

---

## 7. Haptic Touch & context menus

**Note**: 3D Touch was deprecated in iOS 13. Modern devices use **Haptic Touch** — a long-press with haptic feedback. The API surface is `UIContextMenuInteraction` (UIKit) or `.contextMenu { ... }` (SwiftUI).

### Timing

- Default long-press duration: **0.5 seconds** (system standard).
- For chat reactions, FAST: **0.4 seconds** (iMessage).
- For destructive contexts, SLOW: **0.7 seconds** (e.g., long-press to enter delete mode).

You can't change the system's long-press threshold globally, but you can use a custom `LongPressGesture(minimumDuration:)` for in-app gestures.

### The Haptic Touch interaction

When the user touches and holds an element:
1. **0–500ms**: nothing happens visually. The element is "loading" the menu.
2. **500ms**: haptic `.medium` impact fires, the element subtly scales to 0.97 (signaling "menu coming").
3. **520ms**: the rest of the screen blurs (`.systemUltraThinMaterial` background), the element scales back to 1.02 and lifts (slight shadow), the context menu appears with a stagger animation.
4. **Release on menu item**: haptic `.medium`, item highlights, action fires.
5. **Release outside menu**: haptic `.soft`, menu dismisses.

The system handles ALL of this for you when you use the standard APIs.

### SwiftUI implementation

```swift
PhotoCell(image: photo)
    .contextMenu {
        Button("Save", systemImage: "square.and.arrow.down") {
            save(photo)
        }
        Button("Share", systemImage: "square.and.arrow.up") {
            share(photo)
        }
        Divider()
        Button("Delete", systemImage: "trash", role: .destructive) {
            delete(photo)
        }
    } preview: {
        // Optional: full-size preview that appears while menu is shown
        Image(uiImage: photo.fullSize)
            .resizable()
            .aspectRatio(contentMode: .fit)
            .frame(maxWidth: 300, maxHeight: 400)
    }
```

The `preview` closure is a fantastic discoverability trick — long-pressing a small thumbnail shows a giant preview while the menu is shown. Apple Photos does this with photos in the library.

### Custom context menu animations (iMessage / Telegram style)

For maximum control over the animation (like iMessage's bubble-lift-and-reaction-pill), implement `UIContextMenuInteraction` manually:

```swift
let interaction = UIContextMenuInteraction(delegate: self)
bubbleView.addInteraction(interaction)

// In the delegate:
func contextMenuInteraction(_ interaction: UIContextMenuInteraction,
                            configurationForMenuAtLocation location: CGPoint)
    -> UIContextMenuConfiguration? {
    return UIContextMenuConfiguration(identifier: nil) {
        // Preview view — can be nil for no preview
        nil
    } actionProvider: { _ in
        let reply = UIAction(title: "Reply", image: UIImage(systemName: "arrowshape.turn.up.left")) { _ in ... }
        let copy  = UIAction(title: "Copy", image: UIImage(systemName: "doc.on.doc")) { _ in ... }
        let delete = UIAction(title: "Delete", image: UIImage(systemName: "trash"),
                              attributes: .destructive) { _ in ... }
        return UIMenu(children: [reply, copy, delete])
    }
}
```

For TRULY custom presentations (the reaction picker pill above the bubble), you'll need to manage the animation yourself outside `UIContextMenuInteraction` and use a `UIWindow` overlay. iMessage and Telegram do this — it's significant work.

### When NOT to use a context menu

- **Don't use context menus as the ONLY way to access a feature.** They're discoverability hell. Mirror the actions in a visible UI somewhere.
- **Don't put more than 6 items.** Cognitive overload. If you have 10 actions, group them or use a sheet.
- **Don't nest more than 1 level of submenus.** Users get lost.
- **Don't use them on items that are also tap-actionable in confusing ways.** Tapping a tweet opens it; long-pressing should reveal MORE actions, not the same one.

---

## 8. Haptic feedback — the easy 90%

`UIFeedbackGenerator` covers the vast majority of cases. Memorize this table:

| Generator | Styles | When |
| --- | --- | --- |
| `UIImpactFeedbackGenerator` | `.light` | Light touches, button taps, selection of light/inert items |
| | `.medium` | Standard tap on a meaningful control |
| | `.heavy` | Critical actions, slammed-into-place events |
| | `.soft` | (iOS 13+) Even softer than `.light` — perfect for ambient feedback like scrolling detents |
| | `.rigid` | (iOS 13+) Sharp click — perfect for ratchets, dial detents, toggle switches |
| `UISelectionFeedbackGenerator` | (single style) | Picker/scroll-wheel changes, segmented control changes, ANY selection change in a scrollable list |
| `UINotificationFeedbackGenerator` | `.success` | Task completed successfully (saved, sent, posted) |
| | `.warning` | About-to-be-destructive (delete confirmation) |
| | `.error` | Failed action (login wrong, network error) |

### The two rules of UIFeedbackGenerator

**Rule 1: `prepare()` BEFORE you'll need it.**

Without prepare, the haptic engine spins up on first fire, adding ~50ms latency. With prepare, latency is < 5ms. You'll hear the difference.

```swift
private let lightImpact = UIImpactFeedbackGenerator(style: .light)

func viewDidAppear(_ animated: Bool) {
    super.viewDidAppear(animated)
    lightImpact.prepare() // warm up
}

func buttonTapped() {
    lightImpact.impactOccurred() // fires in < 5ms
}
```

**Rule 2: A generator stays "warm" for ~2 seconds after `prepare()`.**

If you `prepare()` then wait 3 seconds before firing, you're back to cold. For continuous interactions (a slider you'll be firing every 100ms), call `prepare()` after each `.impactOccurred()` to keep it warm.

### Haptic intensity (iOS 13+)

`UIImpactFeedbackGenerator` supports `.impactOccurred(intensity: 0.5)` — values 0.0 to 1.0. Map this to gesture velocity for natural-feeling drag-snap interactions.

```swift
// Velocity-mapped haptic during a scrub gesture
let v = abs(scrubVelocity) / maxVelocity // 0…1
impactGen.impactOccurred(intensity: 0.3 + v * 0.7) // 0.3 floor, scales to 1.0
```

### Common haptic anti-patterns

❌ Firing haptics on EVERY scroll event. The result feels like a vibrating phone.
✅ Fire on detents, breakpoints, or thresholds — the "ticks" the user is aware of.

❌ Using `.heavy` for normal button taps. Feels aggressive.
✅ Default to `.light` or `.soft`. Reserve `.heavy` for genuinely heavy moments.

❌ Using `.warning` for routine messages. Now users ignore your warnings.
✅ Reserve `.error` and `.warning` for truly destructive paths.

❌ Forgetting to honor system settings. `UIDevice` does NOT expose a "haptics disabled" setting — but the system respects the user's Sounds & Haptics setting automatically. You don't need to check.

❌ Haptics on cold app launches. The first few seconds the engine is sleeping; haptics will lag.

---

## 9. Core Haptics — the delightful 10%

When `UIFeedbackGenerator` isn't enough — multi-step patterns, audio-synced haptics, intensity envelopes — use `CoreHaptics`. This is what separates premium apps from default apps.

### When to reach for CoreHaptics

- Custom multi-tap patterns (a "chk-chk-chk" film advance, a "thump-thump" heartbeat, a "whoosh-pop" balloon release).
- Haptics that sync with audio (vocal effects, music drops, sound-effect synchronized hits).
- Continuous haptics with dynamic intensity (a vehicle's vibration that ramps with speed in a game).
- Haptic event longer than 1 sec (UIFeedbackGenerator only supports transients).

### Capability check

```swift
guard CHHapticEngine.capabilitiesForHardware().supportsHaptics else { return }
```

iPhone 8 and later support it. iPads do NOT support haptics (touch on iPad has none). Always degrade gracefully.

### Two event types

- **`hapticTransient`**: a short impulse (~80ms). Has `intensity` (0–1) and `sharpness` (0–1).
- **`hapticContinuous`**: a sustained vibration up to 30 seconds. Same parameters + `duration`.

### A reusable engine wrapper

Don't recreate the engine for every haptic. Wrap it:

```swift
import CoreHaptics

final class HapticPlayer {
    static let shared = HapticPlayer()
    private var engine: CHHapticEngine?

    init() {
        guard CHHapticEngine.capabilitiesForHardware().supportsHaptics else { return }
        do {
            engine = try CHHapticEngine()
            engine?.stoppedHandler = { _ in /* restart on next play */ }
            engine?.resetHandler = { [weak self] in try? self?.engine?.start() }
            try engine?.start()
        } catch {
            print("Haptic engine init failed: \(error)")
        }
    }

    func play(pattern: CHHapticPattern) {
        guard let engine else { return }
        do {
            let player = try engine.makePlayer(with: pattern)
            try player.start(atTime: 0)
        } catch {
            print("Play failed: \(error)")
        }
    }
}
```

### Pattern: a "shutter" haptic

```swift
let click = CHHapticEvent(eventType: .hapticTransient, parameters: [
    .init(parameterID: .hapticIntensity, value: 1.0),
    .init(parameterID: .hapticSharpness, value: 0.9)
], relativeTime: 0)

let echo = CHHapticEvent(eventType: .hapticTransient, parameters: [
    .init(parameterID: .hapticIntensity, value: 0.4),
    .init(parameterID: .hapticSharpness, value: 0.3)
], relativeTime: 0.06)

let pattern = try CHHapticPattern(events: [click, echo], parameters: [])
HapticPlayer.shared.play(pattern: pattern)
```

### Pattern: a "heartbeat"

```swift
func heartbeatPattern(bpm: Double = 60) throws -> CHHapticPattern {
    let interval = 60.0 / bpm
    var events: [CHHapticEvent] = []

    for beat in 0..<8 {
        let t = Double(beat) * interval
        // Lub (strong)
        events.append(.init(eventType: .hapticTransient,
                            parameters: [.init(parameterID: .hapticIntensity, value: 1.0),
                                         .init(parameterID: .hapticSharpness, value: 0.4)],
                            relativeTime: t))
        // Dub (softer, 0.12s later)
        events.append(.init(eventType: .hapticTransient,
                            parameters: [.init(parameterID: .hapticIntensity, value: 0.6),
                                         .init(parameterID: .hapticSharpness, value: 0.4)],
                            relativeTime: t + 0.12))
    }
    return try CHHapticPattern(events: events, parameters: [])
}
```

### AHAP files

For complex patterns, write them as `.ahap` JSON files in your bundle and load:

```swift
let url = Bundle.main.url(forResource: "doorbell", withExtension: "ahap")!
let pattern = try CHHapticPattern(contentsOf: url)
```

AHAP format (Apple Haptic and Audio Pattern):
```json
{
  "Version": 1.0,
  "Pattern": [
    { "Event": { "Time": 0.0, "EventType": "HapticTransient",
                 "EventParameters": [
                   { "ParameterID": "HapticIntensity", "ParameterValue": 1.0 },
                   { "ParameterID": "HapticSharpness", "ParameterValue": 0.8 }
                 ]
    }},
    { "Event": { "Time": 0.15, "EventType": "HapticContinuous", "EventDuration": 0.4,
                 "EventParameters": [
                   { "ParameterID": "HapticIntensity", "ParameterValue": 0.5 },
                   { "ParameterID": "HapticSharpness", "ParameterValue": 0.3 }
                 ]
    }}
  ]
}
```

Apple ships a few sample `.ahap` files in the Core Haptics sample code — start there.

### Sharpness vs Intensity (the two knobs)

- **Intensity** (volume): how strong does it feel? Low intensity = barely perceptible. High = strong vibration.
- **Sharpness**: how *crisp* does it feel? Low sharpness = soft/dull thump (like a felt mallet on a drum). High sharpness = crisp click (like a fingernail on glass).

Most natural-feeling haptics live around:
- Tap/click: intensity 0.8–1.0, sharpness 0.6–1.0.
- Soft press: intensity 0.4–0.6, sharpness 0.2–0.4.
- Rumble: intensity 0.3–0.6, sharpness 0.0–0.2.

### Audio + haptic synchronization

`CHHapticEvent` has `hapticAudioCustom` (uses an embedded audio file). For deeply immersive moments (a sword-clash sound effect tied to a haptic), this is the only way to 

…(truncated)
