Search for “Dynamic Island API” and you’ll find a pile of tutorials that make it sound like the Dynamic Island is the thing you’re building. It’s not. There is no Dynamic Island API. There’s an ActivityKit API — and the Dynamic Island is one surface where the system chooses to render your Live Activity.
That distinction matters. A Live Activity is one piece of data with multiple presentation surfaces: the lock screen, the Dynamic Island in compact mode, the Dynamic Island in expanded mode, the minimal dot when two activities compete, StandBy, and Apple Watch. You build the activity. The system decides where it lives. You write views for each surface, and the OS picks which one to show based on context.
Most devs search for the wrong thing, wire up the wrong mental model, and then wonder why their “Dynamic Island” isn’t behaving the way they expected. The fix is understanding what you actually built.
This is the second half of the RTL Air series. In Ride the Lightning Air: Building Interactive WidgetKit Widgets, we built a widget that tracked a passenger through check-in and gate confirmation using AppGroup, UserDefaults, and WidgetCenter.shared.reloadAllTimelines(). The terminal state was gate confirmed — cleared for takeoff.
That’s where the widget’s job ends. Once RTL 1984 pushes back from Gate M72, the widget goes static. No more state transitions, no more buttons, nothing to update. The widget did what a widget does.
The Live Activity takes over from there. Lock screen. Dynamic Island. The full flight from SFO to JFK, tracked in real time. This post builds that.
The source is on GitHub: wesmatlock/RideTheLightningAir.
Set 1: ActivityAttributes — The Static/Dynamic Split
ActivityKit is built around one core idea: separate the data that never changes from the data that updates constantly. That’s the job of the ActivityAttributes protocol.
Static data goes directly on the ActivityAttributes struct. It’s set at creation and never touched again — flight number, route, gate. Dynamic data goes inside ContentState. That’s the struct you’ll be updating through the flight.
import ActivityKit
struct FlightAttributes: ActivityAttributes {
// Static — set once at activity creation, never updated
let flightNumber: String
let origin: String
let destination: String
let gate: String
// Dynamic — updated throughout the flight
public struct ContentState: Codable, Hashable {
var status: FlightLiveStatus
var departureTime: Date
var estimatedArrival: Date
}
}
enum FlightLiveStatus: String, Codable, Hashable {
case boarding
case inFlight
case landed
var displayLabel: String {
switch self {
case .boarding: return "Boarding ✈️"
case .inFlight: return "In Flight ⚡️"
case .landed: return "Landed ✓"
}
}
}
The reason for the split is performance. Static data doesn’t need to be serialized and pushed to the lock screen or Dynamic Island every time something changes. Only ContentState gets transmitted on each update. Keep it lean.
One thing that will cost you a silent failure if you skip it: NSSupportsLiveActivities in your app’s Info.plist, set to true. There’s no crash. No warning. Activities just never start. It’s the kind of bug that makes you question your ActivityAttributes implementation for an hour before you find it.
Set 2: The Views
Live Activity views are WidgetKit views. The same SwiftUI, the same layout system, the same constraints. You build them inside a widget extension and declare them using ActivityConfiguration instead of StaticConfiguration or AppIntentConfiguration.
ActivityConfiguration takes two closures: one for the lock screen banner, one for the Dynamic Island. Inside the Dynamic Island closure, you get a DynamicIsland builder with four contexts to fill.
import ActivityKit
import WidgetKit
import SwiftUI
struct RTLAirLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: FlightAttributes.self) { context in
// Lock screen / banner
LockScreenLiveActivityView(context: context)
.activityBackgroundTint(.black)
.activitySystemActionForegroundColor(.yellow)
} dynamicIsland: { context in
DynamicIsland {
// Expanded — shown when the user long-presses the island
DynamicIslandExpandedRegion(.leading) {
Label(context.attributes.origin, systemImage: "airplane.departure")
.font(.caption).bold()
}
DynamicIslandExpandedRegion(.trailing) {
Label(context.attributes.destination, systemImage: "airplane.arrival")
.font(.caption).bold()
}
DynamicIslandExpandedRegion(.center) {
Text(context.attributes.flightNumber)
.font(.headline).bold()
}
DynamicIslandExpandedRegion(.bottom) {
HStack {
Text(context.state.status.displayLabel)
Spacer()
Text("ETA: \(context.state.estimatedArrival, style: .time)")
}
.font(.caption)
.foregroundStyle(.secondary)
}
} compactLeading: {
// Left pill when a second activity is also live
Label(context.attributes.flightNumber, systemImage: "bolt.fill")
.font(.caption2).bold()
} compactTrailing: {
// Right pill
Text(context.state.estimatedArrival, style: .timer)
.font(.caption2)
} minimal: {
// Tiny dot when two activities compete for the same spot
Image(systemName: "airplane")
.foregroundStyle(.yellow)
}
}
}
}
The four Dynamic Island contexts aren’t interchangeable — the system picks which one to use based on what else is happening on the device:
- expanded: user long-presses the island; you get four regions to fill (leading, trailing, center, bottom)
- compactLeading / compactTrailing: the island splits into two pills when a second activity is simultaneously live; one app owns each side
- minimal: a single small view when two activities from different apps are competing; one gets the island, one gets demoted to a dot near the camera
Design all four. The system will use whichever it needs.
The lock screen view gets the most real estate and the most passenger attention. Make it count:
struct LockScreenLiveActivityView: View {
let context: ActivityViewContext<FlightAttributes>
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("⚡️ \(context.attributes.flightNumber)")
.font(.headline).bold()
Spacer()
Text(context.state.status.displayLabel)
.font(.caption)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(.yellow.opacity(0.2))
.clipShape(Capsule())
}
HStack {
Text(context.attributes.origin)
.font(.title2).bold()
Image(systemName: "arrow.right")
.foregroundStyle(.secondary)
Text(context.attributes.destination)
.font(.title2).bold()
Spacer()
Text("Gate \(context.attributes.gate)")
.font(.caption)
.foregroundStyle(.secondary)
}
HStack {
Text("Departs")
.font(.caption).foregroundStyle(.secondary)
Text(context.state.departureTime, style: .time)
.font(.caption).bold()
Spacer()
Text("ETA")
.font(.caption).foregroundStyle(.secondary)
Text(context.state.estimatedArrival, style: .time)
.font(.caption).bold()
}
}
.padding()
.foregroundStyle(.white)
}
}
context.attributes gives you the static data. context.state gives you the dynamic data. The separation you made in ActivityAttributes shows up clearly here — route and gate never change, so they read from attributes; status and timing read from state.
Set 3: Starting, Updating, and Ending
The activity lifecycle lives entirely in the main app target. The widget extension provides the views; the app controls the data. None of this code goes in the extension.
The right moment to call startFlightActivity() in the RTL Air flow is when gateConfirmed is set — the handoff point from the widget. The widget’s job ends, the Live Activity begins.
Starting:
func startFlightActivity() {
let attributes = FlightAttributes(
flightNumber: "RTL 1984",
origin: "SFO",
destination: "JFK",
gate: "M72"
)
var components = DateComponents()
components.hour = 23
components.minute = 59
let departure = Calendar.current.date(from: components) ?? Date()
let initialState = FlightAttributes.ContentState(
status: .boarding,
departureTime: departure,
estimatedArrival: departure.addingTimeInterval(5.5 * 3600)
)
do {
let activity = try Activity.request(
attributes: attributes,
content: .init(state: initialState, staleDate: nil)
)
print("Started activity: \(activity.id)")
} catch {
print("Failed to start activity: \(error)")
}
}
Store the activity.id somewhere persistent if you need to update it later from a different part of the app. Activity.activities can recover any active activity by type if you lose the reference, but holding onto the ID is cleaner.
Updating:
func updateFlightActivity(_ activity: Activity<FlightAttributes>, status: FlightLiveStatus) async {
let updatedState = FlightAttributes.ContentState(
status: status,
departureTime: activity.content.state.departureTime,
estimatedArrival: activity.content.state.estimatedArrival
)
await activity.update(.init(state: updatedState, staleDate: nil))
}
You can also drive updates from a push notification using the ActivityKit push token — that’s the production pattern for a real airline app, where a backend service pushes departure updates to the device. For RTL Air, the app drives it directly. Same ContentState, same update call regardless of source.
Ending:
func endFlightActivity(_ activity: Activity<FlightAttributes>) async {
let finalState = FlightAttributes.ContentState(
status: .landed,
departureTime: activity.content.state.departureTime,
estimatedArrival: activity.content.state.estimatedArrival
)
await activity.end(
.init(state: finalState, staleDate: nil),
dismissalPolicy: .after(.now + 30)
)
}
The dismissalPolicy is worth thinking about. .immediate pulls the activity off the lock screen the moment you call end. For a flight landing, that’s abrupt — the passenger is still on the runway. .after(.now + 30) lets it linger for thirty seconds in the “Landed ✓” state before the system removes it. That’s a small UX detail, but it’s the difference between an experience that feels finished and one that just disappears.
A Few Things Worth Knowing Before You Ship
Test on hardware. Simulator support for Live Activities exists but is inconsistent. The Dynamic Island won’t render correctly in the simulator — you’re looking at a flat notification banner, not the island. If you’re making layout decisions based on the simulator, you’ll be surprised when it hits a physical device.
NSSupportsLiveActivities is a silent requirement. If it’s not in your Info.plist, Activity.request succeeds without throwing — but no activity is ever created. Set it, then double-check it. The Xcode template doesn’t always add it automatically.
The widget extension provides views; the app controls state. This trips people up. You don’t call Activity.request from the extension. You don’t update or end it from the extension. All of that happens in the main app. The extension is purely presentation.
Check ActivityAuthorizationInfo before requesting. The system can refuse activity creation if the user has disabled Live Activities for your app, or if the device has too many active activities already. Wrap Activity.request in a check:
guard ActivityAuthorizationInfo().areActivitiesEnabled else {
// Live Activities not available — handle gracefully
return
}
The staleDate is your friend on unreliable connections. If you set a staleDate on the content, the system will visually indicate that the data is stale when that date passes and no update has arrived. For a real flight tracker, you’d set this to something reasonable — maybe five minutes past the last known update time. For RTL Air, nil is fine.
The Full Arc
The widget handled boarding. The Live Activity handles the flight. Two different tools, one continuous experience — the passenger never notices the seam, which is exactly right.
The widget’s strength is persistence and interaction. It sits on the home screen, accepts taps, drives state transitions. It doesn’t need to be alive; it just needs to respond when asked.
The Live Activity’s strength is presence. It surfaces at exactly the right moment — when something is actively happening and the passenger needs to see it without unlocking their phone. Lock screen. Dynamic Island compact. A dot near the camera reminding them that RTL 1984 is still in the air.
Gate M72 is behind us. JFK is five and a half hours out.
Let’s ship.