A Live Activity is the one surface that outlives your app on screen. Start one for a delivery, a workout, or a live score, and it sits on the Lock Screen and in the Dynamic Island updating in real time, without the user reopening your app once. On iOS 27 it is still the same ActivityKit framework introduced in iOS 16.1, but it has become the default answer for "how do I show ongoing progress" for any app that has something worth tracking minute to minute.
This guide covers the whole path: defining the attributes and state ActivityKit needs, starting an activity, building both the Lock Screen presentation and the Dynamic Island's compact, minimal, and expanded layouts, updating it locally or from your server with a push token, and ending it cleanly.
On this page
- What a Live Activity actually is
- Step 1: Define your ActivityAttributes
- Step 2: Start the activity
- Step 3: Build the Lock Screen presentation
- Step 4: Build the Dynamic Island
- Step 5: Update and end the activity
- Step 6: Update from your server with a push token
- What iOS 27 refined
- FAQ
What a Live Activity actually is
A Live Activity is a small piece of state, your ContentState, rendered by a widget-extension view that the system keeps on screen and refreshes without launching your app. It has two homes at once: a card on the Lock Screen and in the notification stack, and, on hardware with a Dynamic Island, three more presentations there, a minimal glyph, a compact leading/trailing pair, and an expanded view when the user long-presses it.
You do not push a new UI for every update. You push a new ContentState, and the view you already wrote re-renders with it, on the Lock Screen and in the Island at once.
Step 1: Define your ActivityAttributes
Every Live Activity is typed by an ActivityAttributes conformance: fixed data that does not change for the activity's lifetime, plus a nested ContentState for the data that does.
import ActivityKit
struct DeliveryAttributes: ActivityAttributes {
public struct ContentState: Codable, Hashable {
var status: String
var etaMinutes: Int
}
var orderNumber: String
}orderNumber is set once, when the activity starts, and never changes. status and etaMinutes are what you update as the delivery moves. Put this type somewhere both your app target and your widget extension can import it, a shared framework or a file added to both targets, since both sides need the same definition.
Step 2: Start the activity
Starting an activity is a single call from your main app, typically right after the action that kicks off the tracked event, an order confirmed, a workout begun.
import ActivityKit
func startDeliveryActivity(orderNumber: String) {
let attributes = DeliveryAttributes(orderNumber: orderNumber)
let initialState = DeliveryAttributes.ContentState(status: "Preparing", etaMinutes: 25)
do {
let activity = try Activity<DeliveryAttributes>.request(
attributes: attributes,
content: .init(state: initialState, staleDate: nil),
pushType: .token
)
print("Started activity: \(activity.id)")
} catch {
print("Failed to start Live Activity: \(error)")
}
}pushType: .token asks ActivityKit for a push token so your server can update the activity remotely later. Pass nil instead if every update will come from the app itself while it is in the foreground or background, no server involved.
Step 3: Build the Lock Screen presentation
The Lock Screen view and the Dynamic Island are both defined in one ActivityConfiguration, which lives in your widget extension alongside any Home Screen widgets you ship.
import WidgetKit
import SwiftUI
struct DeliveryLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: DeliveryAttributes.self) { context in
VStack(alignment: .leading, spacing: 4) {
Text("Order #\(context.attributes.orderNumber)")
.font(.caption)
.foregroundStyle(.secondary)
Text(context.state.status)
.font(.headline)
Text("\(context.state.etaMinutes) min away")
.font(.subheadline)
}
.padding()
.activityBackgroundTint(.black)
.activitySystemActionForegroundColor(.white)
} dynamicIsland: { context in
buildDynamicIsland(for: context)
}
}
}context.attributes gives you the fixed data, context.state gives you the current ContentState. .activityBackgroundTint and .activitySystemActionForegroundColor control the card's background and the color of the system-drawn dismiss affordance, keep them a matched pair so the text stays legible. buildDynamicIsland(for:) is the piece the next step fills in.
Step 4: Build the Dynamic Island
The Dynamic Island needs four separate views: minimal (a single glyph shown when two activities compete for space), compactLeading and compactTrailing (the pill shape around the camera), and an expanded view built from named regions.
func buildDynamicIsland(
for context: ActivityViewContext<DeliveryAttributes>
) -> DynamicIsland {
DynamicIsland {
DynamicIslandExpandedRegion(.leading) {
Text("#\(context.attributes.orderNumber)")
.font(.caption)
}
DynamicIslandExpandedRegion(.trailing) {
Text("\(context.state.etaMinutes) min")
.font(.caption)
.bold()
}
DynamicIslandExpandedRegion(.bottom) {
Text(context.state.status)
.font(.subheadline)
}
} compactLeading: {
Image(systemName: "shippingbox.fill")
} compactTrailing: {
Text("\(context.state.etaMinutes)m")
} minimal: {
Image(systemName: "shippingbox.fill")
}
}Keep compactLeading and compactTrailing tiny, they render at a fixed, small size next to the camera. The .bottom expanded region is the easiest place to add a full sentence without crowding .leading and .trailing. All four closures are required; there is no default the system falls back to if one is missing.
Step 5: Update and end the activity
Updates and ending both go through the Activity instance you got back from request, or one you look up later by iterating Activity<DeliveryAttributes>.activities.
func updateDeliveryActivity(
_ activity: Activity<DeliveryAttributes>,
status: String,
etaMinutes: Int
) async {
let updatedState = DeliveryAttributes.ContentState(status: status, etaMinutes: etaMinutes)
await activity.update(ActivityContent(state: updatedState, staleDate: nil))
}
func endDeliveryActivity(_ activity: Activity<DeliveryAttributes>) async {
let finalState = DeliveryAttributes.ContentState(status: "Delivered", etaMinutes: 0)
await activity.end(
ActivityContent(state: finalState, staleDate: nil),
dismissalPolicy: .after(.now.addingTimeInterval(5 * 60))
)
}staleDate tells the system when to gray out the activity if no update arrives by then, set it when your data has a natural expiry, an ETA that should not be trusted after it passes. dismissalPolicy on end controls how long the final state stays visible before the system removes the activity; .after gives the user a few minutes to see "Delivered" before it disappears, .immediate clears it right away.
Step 6: Update from your server with a push token
For anything your app is not actively running to observe, a delivery moving through a courier's system, a sports score, updates need to come from your server. Read the token ActivityKit handed you and send it to your backend.
Task {
for await pushToken in activity.pushTokenUpdates {
let tokenString = pushToken.map { String(format: "%02x", $0) }.joined()
await sendTokenToServer(tokenString, orderNumber: orderNumber)
}
}Your server then sends an APNs push with the activity's content-state payload, using the same push-to-update mechanism as a regular remote notification but targeted at the Live Activity token instead of the device token. The activity updates without your app doing anything, including while it is not running.
What iOS 27 refined
ActivityKit's core shape has not changed since it launched, but iOS 27 relaxes a few of the practical limits that shaped how developers used it. Push-driven activities get a longer budget before the system treats them as stale, so a slow-moving delivery or a multi-hour live event does not need to compete as hard for update slots against apps sending frequent pushes. The expanded Dynamic Island layout also gets a little more vertical room on the newest hardware, enough for a short second line in the .bottom region without truncating. Neither change touches the API above; a Live Activity built against the original ActivityKit shape keeps working exactly as written, it simply has a bit more breathing room on iOS 27.
For the wider WWDC26 toolchain, see the Xcode 27 guide and what's new in Swift. For the Home Screen counterpart to this surface, see the widgets guide.
FAQ
Do I need a widget extension for Live Activities?
Yes. The ActivityConfiguration that defines both the Lock Screen view and the Dynamic Island lives in a widget extension target, the same one you would use for Home Screen widgets if you ship those too.
How long can a Live Activity stay alive?
Apple's guidance caps a single activity around eight hours before the system ends it automatically, longer with regular updates keeping it fresh. For anything that runs longer than that, end the activity and start a new one, or design around shorter tracked sessions.
Can I update a Live Activity without a server?
Yes, if your app is running (foreground or background) you can call activity.update(_:) directly, no push token needed. Reach for pushType: .token only when updates need to originate from your server while your app is not actively running.
What is the difference between compact and minimal presentations?
Compact leading and trailing render as the pill around the camera when your activity is the only one, or the most relevant one, active. Minimal renders as a single small glyph when a second Live Activity from another app is also competing for Dynamic Island space and both need to shrink further.
Can a user have more than one Live Activity running at once?
Yes, from the same app or different apps. The system decides which one gets the compact presentation and which gets pushed to minimal based on recency and relevance; you do not control that ordering directly.
Does ending an activity remove it immediately?
Only with dismissalPolicy: .immediate. .after(date) keeps the final state visible until that date, useful for letting a user see a completed state like "Delivered" before it clears itself from the Lock Screen.
A Live Activity is a small amount of code for a surface most apps never touch, and it is one of the few places where your app stays useful without being open. Start with one tracked event that genuinely changes over minutes, not seconds, and the Dynamic Island work above is the whole implementation.
Spaceport generates a production-ready SwiftUI Xcode project with RevenueCat subscriptions, Sign in with Apple and Google, notifications, an AI assistant, onboarding, Home Screen widgets, and App Store Connect pricing across 25 markets already wired up. From an indie iOS dev, for indie iOS devs.