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:
- Before quotes the user's actual code where possible.
- After is specific. Exact API calls, exact
.sensoryFeedbackstyles, exact widget container modifiers. - 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:
- 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.
- One App Intent, many surfaces. Since iOS 17, the same
AppIntentpowers Home widgets, Lock widgets, StandBy, Control Center, the Action Button, and Siri. Architect your business logic around intents — your peripheral surfaces become near-free. - 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 |
| Live Activities for urgency (countdown timers) | §15 |
| Push notification timing & content rules | §16 |
| Picture-in-Picture for guided onboarding | §17 |
| App Clips — try-before-install viral installs | §18 |
What this skill covers
| Surface | Framework | Min iOS | Section |
|---|---|---|---|
| Home Screen widgets | WidgetKit + SwiftUI | 14 (16 for Lock, 17 for interactive) | §1 |
| Lock Screen widgets | WidgetKit | 16 | §2 |
| StandBy widgets | WidgetKit | 17 | §3 |
| Live Activities (Lock Screen) | ActivityKit | 16.1 | §4 |
| Dynamic Island | ActivityKit (DynamicIsland) | 16.1 (iPhone 14 Pro+) | §5 |
| Control Center custom controls | WidgetKit (ControlWidget) | 18 | §6 |
| Haptic Touch context menus | UIContextMenuInteraction / .contextMenu |
13 | §7 |
| UIFeedbackGenerator haptics | UIKit | 10 | §8 |
| Core Haptics (custom patterns) | CoreHaptics | 13 | §9 |
| Action Button | App Intents | 17 (iPhone 15 Pro+) | §10 |
| Camera Control button | AVFoundation | 18 (iPhone 16/16 Pro) | §11 |
| Symbol Effects | SwiftUI | 17 | §12 |
| Focus filters | AppIntents | 16 | §13 |
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, delay state |
| FocusFlight | Lock Screen Live Activity for flights — large rectangular widget with route/ETA/timezone, never feels cluttered | FocusFlight LA |
| Runbuds | Workout Live Activity — distance, pace, time with red accent, no chrome | Runbuds DI, Lock Screen |
| Moonlitt / Sunlitt | Beautiful gradient-backed Lock Screen widgets for moon/sun phases — content as art | Moonlitt Lock, Sunlitt Golden Hour |
| Duolingo | Streak widget on Lock Screen + Dynamic Island variants — emotional hook (the sad owl when streak is in danger) | Streak widget |
| Yazio | Lock Screen Live Activity for tracking meals — 4 ring meters in one strip | Yazio Lock Screen |
| Transit | Real-time bus arrival Live Activity — countdown with route color | Transit Lock |
| MyFitnessPal / Yazio / Alma / GO Club | Interactive widgets for logging water, calories, steps — tap to log without opening the app | MFP Water widget, Alma Carrot |
| TIDE / Tolan | Minimal Dynamic Island for ambient apps (timers, AI characters) | TIDE, Tolan Lock Screen |
| 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
.systemSmalland.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).
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.
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:)orToggle(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.
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.withAnimationworks 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.
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.
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. Seethe-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).
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:
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
.systemSmallwidgets 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:
ActivityAttributes— static info that doesn't change (e.g., "Flight DL 412 SFO → JFK").ContentState— dynamic info that updates (e.g., gate, delay status, ETA).- 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)
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:
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
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:
- No background colors. The Island IS your background.
- No images that touch the edges. Inset all visual elements with ≥ 4pt padding.
- No buttons in compact or minimal. Interactive elements only in expanded.
- 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. - 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. →
- FocusFlight — full Lock Screen activity with origin/destination/route line. →
- Runbuds — compact: running figure + distance. Lock Screen: distance / time / pace in three big rows. →
- TIDE — minimal-style breathing visualization. Look at how restrained it is. →
6. Control Center custom controls
New in iOS 18. Control Center now hosts custom ControlWidgets — the same App Intent architecture as widgets.
Two types
ControlWidgetButton: a one-shot tap action.
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.
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
LabelwithImage(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:
- 0–500ms: nothing happens visually. The element is "loading" the menu.
- 500ms: haptic
.mediumimpact fires, the element subtly scales to 0.97 (signaling "menu coming"). - 520ms: the rest of the screen blurs (
.systemUltraThinMaterialbackground), the element scales back to 1.02 and lifts (slight shadow), the context menu appears with a stagger animation. - Release on menu item: haptic
.medium, item highlights, action fires. - Release outside menu: haptic
.soft, menu dismisses.
The system handles ALL of this for you when you use the standard APIs.
SwiftUI implementation
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:
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.
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.
// 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
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). Hasintensity(0–1) andsharpness(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:
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
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"
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:
let url = Bundle.main.url(forResource: "doorbell", withExtension: "ahap")!
let pattern = try CHHapticPattern(contentsOf: url)
AHAP format (Apple Haptic and Audio Pattern):
{
"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)