add agent skills and opencode config

This commit is contained in:
Millian Lamiaux
2026-05-10 20:09:13 +01:00
parent 349a96379e
commit 03f660958f
91 changed files with 15526 additions and 0 deletions

View File

@@ -0,0 +1,760 @@
---
name: swiftui-live-activities
description: Design and implement Live Activities in SwiftUI for iOS. Covers ActivityKit, WidgetKit, Dynamic Island layouts, Lock Screen presentations, push notifications, animations, HIG compliance, and accessibility. Use when building, reviewing, or debugging Live Activities, Dynamic Island UI, or real-time notification features.
license: MIT
metadata:
version: "1.0"
---
# Live Activities in SwiftUI
**Use this skill for any Live Activities work — designing layouts, implementing lifecycle code, configuring push notifications, or reviewing Dynamic Island UI.**
---
## References
Key Apple documentation pages (fetch as needed):
```
https://developer.apple.com/documentation/activitykit/displaying-live-data-with-live-activities
https://developer.apple.com/documentation/activitykit/creating-custom-views-for-live-activities
https://developer.apple.com/documentation/widgetkit/adding-interactivity-to-widgets-and-live-activities
https://developer.apple.com/documentation/widgetkit/animating-data-updates-in-widgets-and-live-activities
https://developer.apple.com/documentation/activitykit/starting-and-updating-live-activities-with-activitykit-push-notifications
```
Sample code: Emoji Rangers — `https://developer.apple.com/documentation/WidgetKit/emoji-rangers-supporting-live-activities-interactivity-and-animations`
WWDC: Session 10028 (Bring widgets to life), Session 10185 (Live Activities push notifications)
---
## 1. Architecture
Live Activities span **two targets**:
```
Widget Extension (UI) App Target (Lifecycle)
───────────────────── ─────────────────────
ActivityConfiguration ◄── data ── ActivityAttributes
├── Lock Screen view │
└── DynamicIsland { ├── Activity.request()
├── Expanded (4 regions) ├── activity.update()
├── compactLeading ├── activity.end()
├── compactTrailing ├── pushTokenUpdates
└── minimal └── activityStateUpdates
}
```
The **widget extension** renders the UI using WidgetKit + SwiftUI. The **app target** manages the lifecycle via ActivityKit — requesting, updating, and ending activities. Push notifications from APNs can also drive lifecycle changes.
---
## 2. ActivityAttributes — Data Model
Define a struct conforming to `ActivityAttributes` with static properties and a nested `ContentState` for dynamic data.
```swift
import ActivityKit
struct DeliveryAttributes: ActivityAttributes {
// Static never changes
let orderNumber: Int
let restaurantName: String
// Dynamic changes with each update
struct ContentState: Codable, Hashable {
var driverName: String
var estimatedMinutes: Int
var status: Status
}
}
extension DeliveryAttributes {
enum Status: String, Codable {
case preparing, enRoute, delivered
}
}
```
**Constraint**: Encoded `ContentState` must not exceed **4 KB**.
---
## 3. ActivityConfiguration — Full UI
One `ActivityConfiguration` defines every presentation the system needs. This lives in the widget extension.
```swift
import WidgetKit
import SwiftUI
struct DeliveryActivityWidget: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: DeliveryAttributes.self) { context in
// Lock Screen (and Home Screen banner on non-Dynamic Island devices)
LockScreenDeliveryView(
orderNumber: context.attributes.orderNumber,
state: context.state,
isStale: context.isStale
)
.activityBackgroundTint(Color.indigo.opacity(0.3))
.activitySystemActionForegroundColor(.white)
} dynamicIsland: { context in
DynamicIsland {
// Expanded (touch & hold)
DynamicIslandExpandedRegion(.leading) {
Label(context.state.driverName, systemImage: "person.circle.fill")
.font(.caption)
}
DynamicIslandExpandedRegion(.trailing) {
Label("\(context.state.estimatedMinutes) min", systemImage: "clock")
.font(.caption)
}
DynamicIslandExpandedRegion(.center) {
Text(context.state.status.rawValue)
.font(.headline)
}
DynamicIslandExpandedRegion(.bottom) {
HStack {
Text("Order #\(context.attributes.orderNumber)")
Spacer()
Text(context.attributes.restaurantName)
}
.font(.caption2)
.foregroundStyle(.secondary)
}
} compactLeading: {
// Compact leading (left of TrueDepth)
Image(systemName: "takeoutbag.and.cup.and.straw.fill")
.foregroundStyle(.orange)
} compactTrailing: {
// Compact trailing (right of TrueDepth)
Text("\(context.state.estimatedMinutes) min")
.font(.caption2)
.fontWeight(.heavy)
} minimal: {
// Minimal (when 2+ apps active)
Image(systemName: "takeoutbag.and.cup.and.straw.fill")
.foregroundStyle(.orange)
}
.keylineTint(.orange)
}
.supplementalActivityFamilies([.small, .medium])
}
}
```
---
## 4. Dynamic Island Layouts
### Presentation Types
| Presentation | When shown | Size constraint |
|---|---|---|
| **Compact** (leading + trailing) | Single active Live Activity | Very small, icon + 1-2 text elements |
| **Minimal** | Multiple apps active, StandBy | Tiny, single icon or word |
| **Expanded** | Touch & hold compact/minimal | 4 configurable regions |
### Expanded Regions
```
┌──────────────────────────────────┐
│ .leading │ .center │ .trailing │ ← beside TrueDepth camera
│ │ │ │
│ ┌─────────┴─────────┴─────────┐ │
│ │ .bottom │ │ ← below everything else
│ └──────────────────────────────┘ │
└──────────────────────────────────┘
```
```swift
DynamicIsland {
DynamicIslandExpandedRegion(.leading, priority: 1) { ... }
DynamicIslandExpandedRegion(.trailing, priority: 1) { ... }
DynamicIslandExpandedRegion(.center) { ... }
DynamicIslandExpandedRegion(.bottom) { ... }
}
```
**Priority**: The region with the highest priority gets full width. Equal priority → equal space.
**Vertical placement**: Use `.dynamicIsland(verticalPlacement: .belowIfTooWide)` to wrap leading/trailing content below the camera when too wide.
### Content Margins
```swift
DynamicIsland { ... }
.contentMargins(.trailing, 8, for: .expanded)
.contentMargins(.leading, 16, for: .expanded)
```
Inner `.contentMargins` takes precedence over outer. Avoid placing content at the very edges.
### Keyline Tint
```swift
.keylineTint(.orange) // Subtle border tint in Dark Mode on the Dynamic Island
```
---
## 5. Lock Screen Presentation
- **Max height**: 160 points (system truncates beyond this)
- **Banner mode**: On unlocked devices without Dynamic Island, the Lock Screen view appears as a top banner when the update includes an `AlertConfiguration`
- **StandBy**: The Lock Screen view scales to fill the whole display. Detect it:
```swift
@Environment(\.isActivityFullscreen) var isFullscreen
```
### Background
```swift
// Custom background
.activityBackgroundTint(Color.indigo.opacity(0.25))
// System action button text color
.activitySystemActionForegroundColor(.white)
```
---
## 6. Supplemental Activity Families
```swift
.supplementalActivityFamilies([.small, .medium])
```
| Family | Device |
|---|---|
| `.small` | Apple Watch Smart Stack, CarPlay Home Screen |
| `.medium` | StandBy on iPhone/iPad — expanded Lock Screen layout |
Switch layouts with the environment value:
```swift
@Environment(\.activityFamily) var activityFamily
var body: some View {
switch activityFamily {
case .small: SmallSupplementalView(context: context)
case .medium: MediumSupplementalView(context: context)
default: LockScreenView(context: context)
}
}
```
---
## 7. Color & Styling Rules
### Dynamic Island
| Property | Rule |
|---|---|
| Background | **Always black** — cannot be changed |
| Text | White |
| Tint | `.keylineTint(_:)` for subtle border color in Dark Mode |
### Lock Screen
| Property | Rule |
|---|---|
| Light Mode | Solid white background by default |
| Dark Mode | Solid black background by default |
| Custom | `.activityBackgroundTint(_:)` with optional opacity |
| Action text | `.activitySystemActionForegroundColor(_:)` |
### Always-On Display
The system dims the screen and renders as Dark Mode. Animations are disabled. Use:
```swift
@Environment(\.isLuminanceReduced) var isLuminanceReduced
// Avoid animated transitions when luminance is reduced
if !isLuminanceReduced {
content.transition(.opacity)
}
```
---
## 8. Lifecycle Management
### Start
```swift
let attributes = DeliveryAttributes(orderNumber: 1234, restaurantName: "Toscana")
let initialState = DeliveryAttributes.ContentState(
driverName: "Marco",
estimatedMinutes: 15,
status: .preparing
)
let content = ActivityContent(
state: initialState,
staleDate: Date().addingTimeInterval(1800),
relevanceScore: 100
)
do {
let activity = try Activity.request(
attributes: attributes,
content: content,
pushType: .token
)
} catch {
// Handle: device may have reached simultaneous activity limit
}
```
### Transient (iOS 18+)
Auto-ends when the user collapses the Dynamic Island or locks the device:
```swift
try Activity.request(
attributes: attributes,
content: content,
pushType: nil,
style: .transient
)
```
### Scheduled
```swift
try Activity.request(
attributes: attributes,
content: content,
pushType: .token,
alertConfiguration: AlertConfiguration(
title: "Game starting",
body: "Your team is playing now",
sound: .default
),
startDate: gameDate // AlertConfiguration is required when scheduling
)
```
### Update
```swift
let updatedState = DeliveryAttributes.ContentState(
driverName: "Marco",
estimatedMinutes: 5,
status: .enRoute
)
let updatedContent = ActivityContent(
state: updatedState,
staleDate: Date().addingTimeInterval(1800),
relevanceScore: 80
)
await activity.update(updatedContent)
// With alert (lights up the device, shows expanded/banner):
let alert = AlertConfiguration(title: "Driver nearby", body: "Marco is 5 minutes away", sound: .default)
await activity.update(updatedContent, alertConfiguration: alert)
```
### End
```swift
let finalContent = ActivityContent(state: finalState, staleDate: nil)
await activity.end(finalContent, dismissalPolicy: .default)
// Dismissal policies: .default (removes after a few seconds), .immediate, .after(Date)
```
### Observe
```swift
// Content updates from push notifications
Task {
for await content in activity.contentUpdates {
updateUI(content.state)
}
}
// State changes (.active .stale .ended .dismissed)
Task {
for await state in activity.activityStateUpdates {
if state == .stale { showStaleBanner() }
}
}
// Push token changes
Task {
for await token in activity.pushTokenUpdates {
sendTokenToServer(token)
}
}
// All ongoing activities for the app
let allActive = Activity<DeliveryAttributes>.activities
```
---
## 9. Animations
### Default Behavior
| Content type | Default animation |
|---|---|
| Text | Blurred content transition |
| Images / SF Symbols | Default content transition |
| Add/remove views | Fade in/out |
### Custom (iOS 17+, max 2 seconds)
```swift
// Content transition for numeric text
Text("\(minutes) min")
.contentTransition(.numericText())
.animation(.spring(duration: 0.2), value: minutes)
// View transitions
MyView()
.transition(.push(from: .trailing))
.animation(.easeInOut(duration: 0.3), value: state)
// Supported transitions: .opacity, .move(edge:), .slide, .push(from:),
// or combinations thereof
AnyTransition.asymmetric(
insertion: .push(from: .bottom),
removal: .opacity
)
```
### Disable Animations
```swift
// For views, pass nil to animation
.animation(nil, value: someValue)
// For transitions, use identity
.transition(.identity)
```
### Always-On Display
Animations are **completely disabled** on Always-On displays. Always check `isLuminanceReduced`.
---
## 10. Interactivity — Buttons & Toggles
Uses **App Intents** — not closures or action handlers.
```swift
import AppIntents
struct CheckInIntent: AppIntent {
static var title: LocalizedStringResource = "Check In"
func perform() async throws -> some IntentResult {
// Perform the action (update the Live Activity)
return .result()
}
}
// In the Live Activity view:
Button(intent: CheckInIntent()) {
Label("Check In", systemImage: "figure.wave")
}
Toggle(intent: MuteNotificationsIntent()) {
Label("Mute", systemImage: "bell.slash")
}
```
**Limitation**: Buttons and toggles don't work in CarPlay.
**LiveActivityIntent**: Conform to `LiveActivityIntent` to allow starting Live Activities from Siri, Shortcuts, or App Intents.
---
## 11. Push Notifications
### Setup
1. Add Push Notifications capability in Xcode
2. Add `NSSupportsLiveActivities``YES` in `Info.plist`
3. For frequent updates: add `NSSupportsLiveActivitiesFrequentUpdates``YES`
### Token-based
```swift
// Start with push token support
let activity = try Activity.request(attributes: ..., content: ..., pushType: .token)
// Observe token it may change during the activity's lifetime
Task {
for await token in activity.pushTokenUpdates {
let tokenString = token.reduce("") { $0 + String(format: "%02x", $1) }
await server.register(token: tokenString, for: activity.id)
}
}
```
### Channel-based (iOS 18+)
```swift
try Activity.request(attributes: ..., content: ..., pushType: .channel("channel-uuid"))
```
Allows broadcasting updates to many devices subscribed to the same channel.
### Push-to-Start (iOS 18+)
```swift
// No active activity needed observe the push-to-start token
Task {
for await token in Activity<DeliveryAttributes>.pushToStartTokenUpdates {
let tokenString = token.reduce("") { $0 + String(format: "%02x", $1) }
await server.registerPushToStart(token: tokenString)
}
}
```
### APNs Headers
```
apns-push-type: liveactivity
apns-topic: <bundleID>.push-type.liveactivity (token-based)
apns-channel-id: <channelId> (channel-based)
apns-priority: 5 (low, budget-free) | 10 (high, counts toward budget)
```
### JSON Payload
```json
{
"aps": {
"timestamp": 1685952000,
"event": "update",
"content-state": {
"driverName": "Marco",
"estimatedMinutes": 5,
"status": "enRoute"
},
"relevance-score": 100,
"stale-date": 1685955600,
"alert": {
"title": { "loc-key": "%@ is nearby", "loc-args": ["Marco"] },
"body": { "loc-key": "Arriving in %@ minutes", "loc-args": ["5"] },
"sound": "chime.mp4"
}
}
}
```
- `event`: `"update"` or `"end"`
- `content-state`: Must exactly match your `ContentState` struct — no custom JSON encoding strategies
- `timestamp`: Current time in seconds since 1970 (system shows the most recent update)
- `dismissal-date`: When to remove an ended activity from the Lock Screen
### Priority Budget
- Priority `5`: Does **not** count toward hourly budget — use for routine updates
- Priority `10`: Counts toward budget — use for critical, time-sensitive updates
- Mix `5` and `10` to avoid throttling
- Enable `NSSupportsLiveActivitiesFrequentUpdates` for high-frequency use cases (users can disable in Settings)
---
## 12. Authorization & Availability
```swift
// Synchronous check
if ActivityAuthorizationInfo().areActivitiesEnabled {
showStartButton()
} else {
showEnableInstructions()
}
// Asynchronous observation
Task {
for await enabled in ActivityAuthorizationInfo().activityEnablementUpdates {
updateUI(enabled)
}
}
// Frequent push notification status
if ActivityAuthorizationInfo().frequentPushesEnabled {
configureHighFrequencyUpdates()
}
Task {
for await enabled in ActivityAuthorizationInfo().frequentPushEnablementUpdates {
adjustPushFrequency(enabled)
}
}
```
- Live Activities are **iPhone and iPad only**
- Users can disable them per-app in Settings
- Always handle errors when starting (device may reach activity limit)
---
## 13. Accessibility
Every view in every presentation needs accessibility labels:
```swift
Image(systemName: "takeoutbag.and.cup.and.straw.fill")
.accessibilityLabel("Food delivery is in progress")
Text("\(minutes) min")
.accessibilityLabel("Estimated arrival: \(minutes) minutes")
```
Add labels to compact, minimal, expanded, and Lock Screen views alike.
---
## 14. Relevance & Staleness
### Relevance Score
```swift
ActivityContent(state: ..., staleDate: ..., relevanceScore: 100)
```
| Score | Effect |
|---|---|
| Higher | Appears in Dynamic Island, top of Lock Screen list |
| Equal or unset | First-started wins |
Use relative values: `100` for important, `50` for routine.
### Stale Date
```swift
ActivityContent(state: ..., staleDate: Date().addingTimeInterval(600))
```
When the stale date passes:
- `activityState``.stale`
- `context.isStale``true`
- Show a "last updated" indicator or dim the content
On each update, advance the stale date to prevent it from appearing stale prematurely.
---
## 15. Xcode Previews
```swift
#Preview("Lock Screen", as: .content, using: DeliveryAttributes(...)) {
DeliveryActivityWidget()
} contentStates: {
DeliveryAttributes.ContentState(...)
}
#Preview("Dynamic Island Expanded", as: .dynamicIsland(.expanded), using: DeliveryAttributes(...)) {
DeliveryActivityWidget()
} contentStates: {
DeliveryAttributes.ContentState(...)
}
#Preview("Dynamic Island Compact", as: .dynamicIsland(.compact), using: DeliveryAttributes(...)) {
DeliveryActivityWidget()
} contentStates: {
DeliveryAttributes.ContentState(...)
}
#Preview("Dynamic Island Minimal", as: .dynamicIsland(.minimal), using: DeliveryAttributes(...)) {
DeliveryActivityWidget()
} contentStates: {
DeliveryAttributes.ContentState(...)
}
```
---
## 16. Constraints Reference
| Constraint | Value |
|---|---|
| Content state payload | ≤ 4 KB |
| Lock Screen height | ≤ 160 points |
| Animation duration | ≤ 2 seconds |
| Dynamic Island background | Always black |
| Compact leading view | Icon + 1-2 text elements max |
| Compact trailing view | Icon + 1-2 text elements max |
| Minimal view | Single icon or very short text |
| Platform support | iPhone, iPad (not macOS, not visionOS, not tvOS) |
| Simultaneous activities | Limited per device (exact number varies) |
---
## 17. API Quick Reference
| Type | Framework | Purpose |
|---|---|---|
| `ActivityAttributes` | ActivityKit | Static + dynamic data definition |
| `ActivityContent` | ActivityKit | State + staleDate + relevanceScore |
| `Activity` | ActivityKit | Lifecycle: request, update, end, observe |
| `ActivityConfiguration` | WidgetKit | Widget extension UI configuration |
| `DynamicIsland` | WidgetKit | Dynamic Island layout container |
| `DynamicIslandExpandedRegion` | WidgetKit | Expanded positioning (.leading, .trailing, .center, .bottom) |
| `DynamicIslandExpandedRegionPosition` | WidgetKit | Region position enum |
| `ActivityViewContext` | WidgetKit | Context passed to views (state, isStale, attributes) |
| `ActivityAuthorizationInfo` | ActivityKit | Permission checks |
| `ActivityState` | ActivityKit | .active, .stale, .ended, .dismissed |
| `ActivityFamily` | WidgetKit | .small, .medium for supplemental |
| `ActivityPreviewViewKind` | WidgetKit | Xcode preview configurations |
| `PushType` | ActivityKit | .token or .channel(String) |
| `AlertConfiguration` | ActivityKit | Alert when updating or scheduling |
| `LiveActivityIntent` | AppIntents | Base protocol for LA app intents |
---
## 18. Checklist — Before Shipping a Live Activity
- [ ] All 4 Dynamic Island presentations implemented (compact leading/trailing, minimal, expanded)
- [ ] Lock Screen presentation fits within 160pt
- [ ] `.supplementalActivityFamilies([.small, .medium])` added
- [ ] Accessibility labels on every view in every presentation
- [ ] Stale date configured and handled in the UI
- [ ] Relevance scores set when multiple activities can run
- [ ] Always-On display handled (`isLuminanceReduced`)
- [ ] Errors handled when starting (activity limit reached)
- [ ] Authorization checked before showing start UI
- [ ] Push notification payload matches ContentState exactly — no custom JSON strategies
- [ ] Push priority mix planned (5 for routine, 10 for critical)
- [ ] Xcode previews for all presentation types
- [ ] Widget extension target includes the ActivityConfiguration
- [ ] `NSSupportsLiveActivities` = YES in Info.plist
---
## 19. Common Pitfalls
1. **Missing a presentation** — You must provide ALL of: Lock Screen, compactLeading, compactTrailing, minimal, expanded. The system needs every one and will reject the configuration if any is absent.
2. **Dynamic Island background color** — You cannot change it. It's always black. Use `.keylineTint` and white text.
3. **ContentState too large** — The 4 KB limit is strict. Avoid large strings, arrays, or nested objects in the content state.
4. **Custom JSON encoding** — The system uses default `JSONEncoder`/`JSONDecoder` strategies. Custom encoding strategies cause push update failures with no obvious error.
5. **No stale date** — Without a stale date, outdated content persists indefinitely. Always set one and advance it with each update.
6. **Animations on Always-On** — They're silently disabled. Always check `isLuminanceReduced` before relying on animation for critical information.
7. **Forgetting accessibility** — Dynamic Island views are tiny and receive focus from VoiceOver. Every icon and text needs a descriptive label.
8. **Push token lifecycle** — Tokens can change during an activity's lifetime. Always observe `pushTokenUpdates` — never read `pushToken` once and store it permanently.
9. **Priority 10 everything** — Using priority 10 for every update hits the budget quickly. Reserve it for truly urgent updates.
10. **Scheduling without AlertConfiguration** — The `request(attributes:content:pushType:style:alertConfiguration:startDate:)` API requires the alert configuration parameter when scheduling.