Getting started with Live Activities in SwiftUI
Build a public transport journey tracker for the Lock Screen and Dynamic Island with ActivityKit.
07 Sep 2026 · 7 min read
Live Activities let users follow an ongoing event without repeatedly opening our app. They can show a public transport journey, a workout, an order's delivery status or any other information that changes over a limited period of time.
Although their interface is part of a widget extension, Live Activities are not regular widgets. They don't use a timeline provider. Instead, we start, update and end them from our app with ActivityKit, while WidgetKit and SwiftUI define how their current state is presented.
In this article, we'll build a small public transport journey tracker and update it locally from the app. This lets us focus on the basic structure before adding remote updates with push notifications.

Adding a Live Activity to the project
A Live Activity's SwiftUI interface lives in a widget extension. If the app doesn't have one yet, we can add a Widget Extension target in Xcode and select Include Live Activity.
We also need to enable Live Activities for the app target. In the target's Info settings, add Supports Live Activities and set it to YES. The corresponding Info.plist key is NSSupportsLiveActivities.
Separating fixed and changing data
Every Live Activity has an attributes type. Its stored properties describe information that stays fixed for the lifetime of the activity. A nested ContentState contains the values that can change:
import ActivityKitstruct JourneyAttributes: ActivityAttributes {struct ContentState: Codable, Hashable {var currentStop: Stringvar nextStop: String?var progress: Double}let line: Stringlet destination: String}
The app and widget extension both need access to JourneyAttributes. We can place it in a separate Swift file and enable target membership for both targets.
The line and destination don't change during the journey, so they belong to JourneyAttributes. The current stop, next stop and progress will be replaced whenever the activity is updated, so they belong to ContentState. The next stop is optional because there isn't one after we reach the destination.
Apple limits the combined size of a Live Activity's static and dynamic data to 4 KB. The attributes and content state should therefore contain only compact values required to render the interface.
Building the Live Activity interface
Now that we've defined the data, we need to tell the system how the Live Activity should look. This interface belongs to the widget extension and is described by an ActivityConfiguration. We keep the configuration itself small by moving the Lock Screen and Dynamic Island presentations into separate helpers:
import ActivityKitimport SwiftUIimport WidgetKitstruct JourneyLiveActivity: Widget {var body: some WidgetConfiguration {ActivityConfiguration(for: JourneyAttributes.self) { context inJourneyLockScreenView(context: context)} dynamicIsland: { context inmakeDynamicIsland(for: context)}}}
The first closure builds the presentation used on the Lock Screen. Its context gives us access to both the fixed attributes and the current state. We pass that context to a dedicated view:
struct JourneyLockScreenView: View {let context: ActivityViewContext<JourneyAttributes>var body: some View {VStack(alignment: .leading, spacing: 12) {HStack {Label(context.attributes.line, systemImage: "tram.fill")Spacer()Text(context.attributes.destination).font(.callout.bold())}VStack(alignment: .leading, spacing: 4) {Text("Current: \(context.state.currentStop)")if let nextStop = context.state.nextStop {Text("Next: \(nextStop)").foregroundStyle(.secondary)} else {Text("Arrived").foregroundStyle(.secondary)}}ProgressView(value: context.state.progress).tint(.green)}.padding().foregroundStyle(.black).activityBackgroundTint(.white).activitySystemActionForegroundColor(.green)}}
Building the Dynamic Island presentations
The Dynamic Island is the interactive area around the front-facing camera and sensors on supported iPhones. A Live Activity can use this area to show compact information while another app is open, and users can touch and hold it to reveal a larger presentation. On iPhones without a Dynamic Island, the Live Activity is still available on the Lock Screen.
The second closure must return a DynamicIsland. We can move it into a method on our widget:
private extension JourneyLiveActivity {func makeDynamicIsland(for context: ActivityViewContext<JourneyAttributes>) -> DynamicIsland {DynamicIsland {DynamicIslandExpandedRegion(.leading) {Label(context.attributes.line, systemImage: "tram.fill").font(.headline)}DynamicIslandExpandedRegion(.trailing) {Text(context.state.progress, format: .percent.precision(.fractionLength(0)))}DynamicIslandExpandedRegion(.bottom) {VStack(alignment: .leading) {Text(context.state.currentStop)if let nextStop = context.state.nextStop {Text("Next: \(nextStop)").foregroundStyle(.secondary)} else {Text("Arrived at \(context.attributes.destination)").foregroundStyle(.secondary)}}}} compactLeading: {Text(context.attributes.line)} compactTrailing: {Text(context.state.progress, format: .percent.precision(.fractionLength(0)))} minimal: {Image(systemName: "tram.fill")}}}
The Dynamic Island defines three additional presentations:
- The expanded presentation appears when the user touches and holds the Dynamic Island.
- The compact leading and trailing views appear on either side of the camera area.
- The minimal presentation is used when more than one Live Activity is active.
If the extension uses a WidgetBundle, we need to add JourneyLiveActivity() to its body just like any other widget configuration.
Starting a Live Activity
We start a Live Activity from the app while it is in the foreground. For this local example, a view can keep a reference to the returned activity:
import ActivityKitimport SwiftUIstruct ContentView: View {@State private var activity: Activity<JourneyAttributes>?var body: some View {VStack(spacing: 16) {Button("Start journey") {startJourney()}.disabled(activity != nil)Button("Next stop") {Task {await updateJourney()}}.disabled(activity == nil)Button("Arrive") {Task {await endJourney()}}.disabled(activity == nil)}.buttonStyle(.borderedProminent).padding()}private func startJourney() {guard ActivityAuthorizationInfo().areActivitiesEnabled else {return}let attributes = JourneyAttributes(line: "U8",destination: "Alexanderplatz")let state = JourneyAttributes.ContentState(currentStop: "Hermannplatz",nextStop: "Kottbusser Tor",progress: 0.15)let content = ActivityContent(state: state, staleDate: nil)do {activity = try Activity.request(attributes: attributes,content: content,pushType: nil)} catch {print("Couldn't start journey: \(error)")}}}
ActivityAuthorizationInfo tells us whether the user allows Live Activities. Activity.request creates one with its fixed attributes and its initial content. Passing nil as the push type is enough because this example only sends updates from the app.
Updating the current state
To update the presentation, we create a new content state and pass it to the activity:
private func updateJourney() async {guard let activity else {return}let state = JourneyAttributes.ContentState(currentStop: "Kottbusser Tor",nextStop: "Moritzplatz",progress: 0.4)let content = ActivityContent(state: state, staleDate: nil)await activity.update(content)}
The system renders the Live Activity again with the new context.state. Updates aren't a way to run arbitrary code inside the widget extension: the extension receives the state and uses it to build the view.
For data that can become outdated, we can pass a staleDate. After that date, ActivityKit considers the content stale and makes this available to the presentation through context.isStale. The stale date doesn't fetch new data automatically.
Ending the Live Activity
A Live Activity represents a temporary event, so we should explicitly end it when that event is finished. We also provide a final state because the system may leave the ended activity visible on the Lock Screen for a while:
private func endJourney() async {guard let activity else {return}let finalState = JourneyAttributes.ContentState(currentStop: "Alexanderplatz",nextStop: nil,progress: 1)let finalContent = ActivityContent(state: finalState,staleDate: nil)await activity.end(finalContent,dismissalPolicy: .after(Date.now.addingTimeInterval(60)))self.activity = nil}
The dismissal policy controls how long the completed activity remains visible. We can use .immediate, .default, or .after(_:). Here, it stays on the Lock Screen for one minute so the user can see that the destination has been reached.
In a larger app, we shouldn't rely only on an in-memory reference. Activity<JourneyAttributes>.activities gives us the currently running activities of this type, which lets us find them again after the app has been relaunched.
Local and remote updates
This example updates the Live Activity while code in our app is running. The Live Activity itself can't start a network request or continuously execute in the background.
For a real public transport journey, service and location updates would usually originate outside our app while it isn't running. ActivityKit supports this through specialized push notifications. That requires obtaining a push token and sending correctly structured updates through APNs, so it is best treated as a separate step after the local lifecycle is working.
Conclusion
A Live Activity consists of two closely related parts: an ActivityAttributes model shared between the targets, and an ActivityConfiguration in the widget extension that renders it. The app then uses ActivityKit to start the activity, replace its dynamic content and end it with a final state.
Once this local flow works, we can build on it with push updates, deep links and interactive controls without changing the core structure.



Newsletter
Like to support my work?
Say hi
Related tags
Articles with related topics
Latest articles and tips



