Logo for tanaschita.com

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.

Sponsorship logo
Architecture & Design Patterns for iOS
This book is a practical guide to essential architectural principles and design patterns for iOS development. It covers strategies for building maintainable apps with Swift and SwiftUI, including dependency injection, navigation, common patterns, and modularization.
LEARN MORE

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 ActivityKit
struct JourneyAttributes: ActivityAttributes {
struct ContentState: Codable, Hashable {
var currentStop: String
var nextStop: String?
var progress: Double
}
let line: String
let 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 ActivityKit
import SwiftUI
import WidgetKit
struct JourneyLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: JourneyAttributes.self) { context in
JourneyLockScreenView(context: context)
} dynamicIsland: { context in
makeDynamicIsland(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 ActivityKit
import SwiftUI
struct 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.

Sponsorship logo
Preparing for a technical iOS job interview
Preparing for a technical iOS Job Interview with over 300 questions & answers. Covering Swift & Objective-C, SwiftUI & UIKit, Combine, HTTP Networking, iOS File System, Core Data, Concurrency with async/await, Security, Automated Testing, Dependency Management, AI & Machine Learning and more.
LEARN MORE
Sponsorship logo
Architecture & Design Patterns for iOS
This book is a practical guide to essential architectural principles and design patterns for iOS development. It covers strategies for building maintainable apps with Swift and SwiftUI, including dependency injection, navigation, common patterns, and modularization.
LEARN MORE
Sponsorship logo
Become a sponsor of tanaschita.com
By publishing an article on different iOS topics every week, tanaschita.com is constantly growing in the developer community and may provide a great audience for you as a sponsor.
CLICK TO LEARN MORE

Newsletter

Image of a reading marmot
Subscribe

Like to support my work?

Say hi

Related tags

Articles with related topics

widgetkit

swiftui

swift

ios

Building widgets with WidgetKit and SwiftUI

Learn how to create widgets for an iOS application with WidgetKit and SwiftUI.

20 Jul 2026 · 8 min read

Latest articles and tips

© 2026 tanaschita.com

Privacy policy

Impressum