add agent skills and opencode config
This commit is contained in:
760
.agents/skills/live-activities/SKILL.md
Normal file
760
.agents/skills/live-activities/SKILL.md
Normal 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.
|
||||
Reference in New Issue
Block a user