How to Build a Live Activity in SwiftUI
Define an ActivityAttributes struct with a ContentState for live data, build Lock Screen and Dynamic Island SwiftUI views in a Widget Extension, then call Activity.request(attributes:content:) from your app to launch the activity.
import ActivityKit
struct OrderAttributes: ActivityAttributes {
struct ContentState: Codable, Hashable {
var status: String
var eta: Date
}
let orderID: String
}
// Start from your app:
let attrs = OrderAttributes(orderID: "ORD-42")
let state = OrderAttributes.ContentState(
status: "Out for delivery",
eta: .now.addingTimeInterval(900)
)
let content = ActivityContent(state: state, staleDate: nil)
let activity = try Activity.request(
attributes: attrs,
content: content
)
Full implementation
A Live Activity has two distinct parts: the app side (starting, updating, and ending the activity via ActivityKit) and the Widget Extension side (rendering the Lock Screen banner and Dynamic Island UI with WidgetKit). Both share the same ActivityAttributes type, which you should place in a shared Swift package or a target your app and extension both belong to. Below is a complete, self-contained example for a food-delivery tracker.
// MARK: - Shared: OrderAttributes.swift
// (Add this file to both your App target and Widget Extension target)
import ActivityKit
import Foundation
struct OrderAttributes: ActivityAttributes {
/// Dynamic — changes during the activity's lifetime
struct ContentState: Codable, Hashable {
var status: String
var eta: Date
var courierName: String
}
/// Static — set once at launch
let orderID: String
let restaurantName: String
}
// ─────────────────────────────────────────────────────────────────
// MARK: - Widget Extension: OrderLiveActivityWidget.swift
// ─────────────────────────────────────────────────────────────────
import SwiftUI
import WidgetKit
import ActivityKit
struct OrderLiveActivityWidget: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: OrderAttributes.self) { context in
// Lock Screen / StandBy view
OrderLockScreenView(context: context)
.activityBackgroundTint(Color.orange.opacity(0.15))
.activitySystemActionForegroundColor(.orange)
} dynamicIsland: { context in
DynamicIsland {
// Expanded (long-press)
DynamicIslandExpandedRegion(.leading) {
Label("Order", systemImage: "bag.fill")
.font(.caption2)
.foregroundStyle(.orange)
}
DynamicIslandExpandedRegion(.trailing) {
Text(context.state.eta, style: .timer)
.font(.caption2.monospacedDigit())
.foregroundStyle(.secondary)
}
DynamicIslandExpandedRegion(.bottom) {
HStack {
Image(systemName: "bicycle")
Text(context.state.status)
.font(.subheadline.bold())
Spacer()
Text("ETA \(context.state.eta.formatted(.dateTime.hour().minute()))")
.font(.caption)
.foregroundStyle(.secondary)
}
}
} compactLeading: {
Image(systemName: "bag.fill")
.foregroundStyle(.orange)
} compactTrailing: {
Text(context.state.eta, style: .timer)
.monospacedDigit()
.font(.caption2)
} minimal: {
Image(systemName: "bag.fill")
.foregroundStyle(.orange)
}
.widgetURL(URL(string: "myapp://order/\(context.attributes.orderID)"))
.keylineTint(.orange)
}
}
}
struct OrderLockScreenView: View {
let context: ActivityViewContext<OrderAttributes>
var body: some View {
HStack(spacing: 16) {
Image(systemName: "bicycle.circle.fill")
.font(.system(size: 44))
.foregroundStyle(.orange)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 4) {
Text(context.state.status)
.font(.headline)
Text("via \(context.attributes.restaurantName)")
.font(.subheadline)
.foregroundStyle(.secondary)
Label(
"ETA \(context.state.eta.formatted(.dateTime.hour().minute()))",
systemImage: "clock"
)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
}
.padding()
.accessibilityElement(children: .combine)
.accessibilityLabel(
"\(context.state.status). ETA \(context.state.eta.formatted(.dateTime.hour().minute()))"
)
}
}
// ─────────────────────────────────────────────────────────────────
// MARK: - App side: LiveActivityManager.swift
// ─────────────────────────────────────────────────────────────────
import ActivityKit
import Foundation
@MainActor
final class LiveActivityManager: ObservableObject {
private var activity: Activity<OrderAttributes>?
func start(orderID: String, restaurant: String) {
guard ActivityAuthorizationInfo().areActivitiesEnabled else { return }
let attrs = OrderAttributes(orderID: orderID, restaurantName: restaurant)
let state = OrderAttributes.ContentState(
status: "Preparing your order",
eta: .now.addingTimeInterval(1800),
courierName: "Alex"
)
let content = ActivityContent(state: state, staleDate: .now.addingTimeInterval(3600))
do {
activity = try Activity.request(attributes: attrs, content: content)
} catch {
print("Live Activity failed: \(error)")
}
}
func update(status: String, eta: Date) async {
let newState = OrderAttributes.ContentState(
status: status, eta: eta, courierName: "Alex"
)
let content = ActivityContent(state: newState, staleDate: nil)
await activity?.update(content)
}
func end(finalStatus: String) async {
let state = OrderAttributes.ContentState(
status: finalStatus,
eta: .now,
courierName: "Alex"
)
let content = ActivityContent(state: state, staleDate: nil)
await activity?.end(content, dismissalPolicy: .after(.now.addingTimeInterval(30)))
}
}
// ─────────────────────────────────────────────────────────────────
// MARK: - Example view wiring
// ─────────────────────────────────────────────────────────────────
import SwiftUI
struct OrderTrackingView: View {
@StateObject private var liveActivity = LiveActivityManager()
var body: some View {
VStack(spacing: 20) {
Button("Start Live Activity") {
liveActivity.start(orderID: "ORD-42", restaurant: "Sakura Ramen")
}
Button("Update: Out for delivery") {
Task { await liveActivity.update(
status: "Out for delivery",
eta: .now.addingTimeInterval(600)
)}
}
Button("End: Delivered!") {
Task { await liveActivity.end(finalStatus: "Delivered!") }
}
}
.buttonStyle(.borderedProminent)
}
}
#Preview {
OrderTrackingView()
}
How it works
-
ActivityAttributes separates static from dynamic data. The outer struct (
OrderAttributes) holds values that never change — likeorderIDandrestaurantName. The nestedContentStateholds everything that can update while the activity is alive:status,eta,courierName. ActivityKit serialises only the changingContentStateon each update, keeping payloads small. -
ActivityConfiguration wires the shared type to two rendering contexts. The trailing closure is the Lock Screen (and StandBy) view; the
dynamicIslandparameter provides four island states — expanded (long-press), compact leading, compact trailing, and minimal. Each context receives bothcontext.attributes(static) andcontext.state(dynamic). -
ActivityAuthorizationInfo().areActivitiesEnabledguards the start call. Users can disable Live Activities system-wide in Settings. Always check this flag before callingActivity.requestto avoid a thrown error, and consider surfacing a nudge UI if it returnsfalse. -
Updates are pushed with
activity.update(_:). This is anasyncmethod called from your app (or, for background updates, via a push notification withpushType: .token). The system merges the newContentStateand re-renders both the Lock Screen view and Dynamic Island without relaunching the extension. -
Ending uses a
dismissalPolicy..after(date)keeps the collapsed banner visible until the given date so the user sees the final state..immediateremoves it at once. Always callend— orphaned activities expire after ~8 hours but waste system resources.
Variants
Push-token updates (server-driven)
// Request with push support — returns a push token
let activity = try Activity.request(
attributes: attrs,
content: content,
pushType: .token // enables remote updates
)
// Observe the token and send it to your server
for await tokenData in activity.pushTokenUpdates {
let token = tokenData.map { String(format: "%02x", $0) }.joined()
await MyServer.registerLiveActivityToken(token, for: orderID)
}
// Your server then sends an APNs push to the Live Activity endpoint:
// POST https://api.push.apple.com/3/device/{token}
// apns-push-type: liveactivity
// apns-topic: com.example.MyApp.push-type.liveactivity
// Body: { "aps": { "timestamp": 1715000000,
// "event": "update",
// "content-state": { "status": "Arriving now", "eta": ... } } }
Observing activity updates in SwiftUI
Listen to Activity<OrderAttributes>.activities to keep your UI in sync with all running activities — handy if a push relaunch starts a new activity ID you need to reconnect to:
.task {
for await activityState in activity.activityStateUpdates {
if activityState == .dismissed {
// Activity is gone — clean up local state
}
}
}
Common pitfalls
-
Missing entitlement / capability. You must add the Supports Live Activities key (
NSSupportsLiveActivities = YES) in your app'sInfo.plistand enable the "Push Notifications" capability on your Widget Extension target. Without both,Activity.requestthrows silently on device. -
Sharing the attributes type between targets.
OrderAttributesmust be compiled into both your app and your Widget Extension — not just one. The safest way is a local Swift Package with a shared library target that both link against. Forgetting this causes a runtime crash when ActivityKit tries to decode the state. -
Dynamic Island views must be size-constrained. Each island region has strict size limits (e.g., compact leading/trailing are tiny pill segments). Putting a full
VStackin a compact region produces clipped, broken UI. Always test on a physical Dynamic Island device or the simulator with an iPhone 16 form factor — the canvas preview never clips. -
System caps on concurrent activities. iOS limits the number of simultaneous Live Activities per app. Starting a new one when you're at the cap silently fails unless you end or replace old ones. Store
Activity.activitiesand clean up stale entries before requesting a new one. -
Accessibility is opt-in. The system does not automatically combine island labels for VoiceOver. Add
.accessibilityLabeland.accessibilityElement(children: .combine)on your Lock Screen container view, and set.accessibilityHidden(true)on decorative images.
Prompt this with Claude Code
When using Soarias or Claude Code directly to implement this:
Implement a Live Activity in SwiftUI for iOS 17+. Use ActivityKit and ActivityAttributes with a nested ContentState. Build Lock Screen and Dynamic Island (expanded, compact, minimal) views. Add start(), update(), and end() methods in a @MainActor manager class. Make it accessible (VoiceOver labels on Lock Screen view). Add a #Preview with realistic sample data showing the Lock Screen layout.
In the Soarias Build phase, drop this prompt into the Claude Code panel after scaffolding your Widget Extension target — Soarias will wire the shared attributes file into both targets and configure the Info.plist entitlement automatically.
Related
FAQ
Does this work on iOS 16?
Live Activities were introduced in iOS 16.1, and the Dynamic Island API became available in iOS 16.2. However, the ActivityContent initialiser used in the examples above requires iOS 16.2+. Since Soarias targets iOS 17+ as a baseline, you get the full API without any availability guards. If you need to ship on 16.1–16.x, wrap the Activity.request call in #available(iOS 16.1, *) and use the older deprecated initialiser — but it is not recommended.
Can I update the Live Activity from a background URLSession or background task?
Yes — call await activity.update(_:) inside a BGAppRefreshTask or a URLSession background completion handler. For truly server-driven updates without waking the app at all, use push-token updates (pushType: .token) so APNs delivers the new ContentState directly to the system without launching your app process.
What is the UIKit equivalent?
There is no UIKit equivalent — Live Activities and Dynamic Island are exclusively SwiftUI-rendered Widget Extension views driven by ActivityKit. You can manage the activity lifecycle (start/update/end) from a UIKit app, since Activity.request and related methods are plain Swift calls, but the visual UI in the island and Lock Screen must be declared in SwiftUI inside a Widget Extension target.
Last reviewed: 2026-05-12 by the Soarias team.