How to Build a Live Activity in SwiftUI

iOS 17+ Xcode 16+ Advanced APIs: ActivityKit Updated: May 12, 2026
TL;DR

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

  1. ActivityAttributes separates static from dynamic data. The outer struct (OrderAttributes) holds values that never change — like orderID and restaurantName. The nested ContentState holds everything that can update while the activity is alive: status, eta, courierName. ActivityKit serialises only the changing ContentState on each update, keeping payloads small.
  2. ActivityConfiguration wires the shared type to two rendering contexts. The trailing closure is the Lock Screen (and StandBy) view; the dynamicIsland parameter provides four island states — expanded (long-press), compact leading, compact trailing, and minimal. Each context receives both context.attributes (static) and context.state (dynamic).
  3. ActivityAuthorizationInfo().areActivitiesEnabled guards the start call. Users can disable Live Activities system-wide in Settings. Always check this flag before calling Activity.request to avoid a thrown error, and consider surfacing a nudge UI if it returns false.
  4. Updates are pushed with activity.update(_:). This is an async method called from your app (or, for background updates, via a push notification with pushType: .token). The system merges the new ContentState and re-renders both the Lock Screen view and Dynamic Island without relaunching the extension.
  5. Ending uses a dismissalPolicy. .after(date) keeps the collapsed banner visible until the given date so the user sees the final state. .immediate removes it at once. Always call end — 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

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.