Build Live Activities and Dynamic Island interfaces with ActivityKit on iPhone 14 Pro

This article is a cleaner walkthrough for the first generation of ActivityKit on iOS 16.1. The core job is straightforward: define your activity model, replace the normal widget configuration with an ActivityKit one, describe each Dynamic Island state, then start, update, and end the activity from your app.

Dynamic Island compact Live Activity example on iPhone 14 Pro

Live Activities arrive on the lock screen first, and on Dynamic Island for supported iPhones.

This article was written in September 2022, when Live Activities had been removed from iOS 16.0 and were only available in the iOS 16.1 public beta. That timing matters: this is an iOS 16.1-or-later feature, and the Dynamic Island presentation is specifically relevant to iPhone 14 Pro-class hardware.

The UI surface is split across two places. Every supported iPhone can show the Live Activity on the lock screen. On Dynamic Island devices, the same activity can also render in compact, expanded, or minimal forms above the app.

Historical Note The post reflects the initial ActivityKit rollout for iOS 16.1, not the earlier iOS 16.0 release.
Sample Code The companion repository is mszpro/iOS16.1-LiveActivities-DynamicIsland-Demo.

Dynamic Island has compact, expanded, and minimal forms, while the lock screen gets the wider Live Activity card.

Before writing code, the article first maps the four surfaces you need to think about. Compact mode gives you a leading and trailing slot with very little width. Expanded mode appears when the user long-presses the island. Minimal mode is used when the system has to shrink activities even further, such as when multiple activities are present. The lock screen version is the most spacious and works on non-Dynamic-Island iPhones too.

Compact Dynamic Island Live Activity with leading and trailing regions
Compact mode gives you narrow leading and trailing regions, roughly around 50 points wide each in this example.
Expanded Dynamic Island Live Activity showing multiple regions
Expanded mode opens the richer layout with leading, trailing, center, and bottom regions.
Minimal Dynamic Island Live Activity
Minimal mode is tiny, so it is best treated like an icon or simple progress mark.
Lock screen Live Activity card
The lock screen activity is available on iPhones running iOS 16.1 or later, even without Dynamic Island.

The feature gate is simple: a widget extension, an iOS 16.1 deployment target, and the Live Activities plist flag.

The first setup step is making sure the iOS app already has a widget extension target. From there, this article sets the minimum deployment target to iOS 16.1, while also recommending if #available(iOS 16.1, *) checks if the wider app still supports older systems.

Both the main app and the widget extension need NSSupportsLiveActivities set to YES in Info.plist. That is the switch that tells the system the app is allowed to publish Live Activities at all.

Model the activity with static fields outside and changing fields inside ContentState.

The article uses a fictional space-trip example to explain the split between data that stays fixed for the whole session and data that changes while the activity is alive. In ActivityKit, that means your type conforms to ActivityAttributes, while the changing values live in a nested ContentState struct.

import Foundation
import ActivityKit

struct TripAppAttributes: ActivityAttributes {
    enum TripStatus: String {
        case predeparture
        case inflight
        case landed
    }

    public struct ContentState: Codable, Hashable {
        var tripStatus: String
        var arrivalTime: Date
    }

    var shipNumber: String
    var departureTime: Date
    var userStopPlanetName: String
    var userCabinNumber: String
}

In this example, the ship number, departure time, destination, and cabin number stay constant. The trip status and arrival time can change, so they belong in ContentState.

Replace the normal widget body with ActivityConfiguration.

A regular widget configuration is not enough for this UI. The widget target needs an ActivityConfiguration instead, keyed to the attribute type you defined above. The first closure renders the lock screen activity, and the dynamicIsland closure handles every island variant.

@main
struct LiveActivitiesTestWidget: Widget {
    let kind: String = "LiveActivitiesTestWidget"

    var body: some WidgetConfiguration {
        ActivityConfiguration(for: TripAppAttributes.self) { context in
            LiveActivitiesTestWidgetEntryView(
                attribute: context.attributes,
                state: context.state
            )
        } dynamicIsland: { context in
            // Dynamic Island content goes here.
        }
    }
}

The lock screen view is just SwiftUI, but it should be designed around changing state.

The article's LiveActivitiesTestWidgetEntryView reads both the static attributes and the mutable content state, then branches the body based on the current trip status. That is the view shown underneath the island on the lock screen.

Live Activity lock screen SwiftUI layout example
The same activity data can present richer copy on the lock screen than in the tighter island formats.
struct LiveActivitiesTestWidgetEntryView: View {
    @State var attribute: TripAppAttributes
    @State var state: TripAppAttributes.ContentState

    var body: some View {
        VStack(alignment: .center) {
            HStack {
                Label("Ship number", systemImage: "moon.stars")
                Spacer()
                Text(attribute.shipNumber)
            }

            HStack {
                Label("Your stop", systemImage: "lanyardcard")
                Spacer()
                Text(attribute.userStopPlanetName)
            }

            switch TripAppAttributes.TripStatus(rawValue: state.tripStatus) {
            case .predeparture:
                Text(attribute.userCabinNumber)
            case .inflight:
                Label("Currently in trip", systemImage: "clock")
            case .landed:
                Label("Landed", systemImage: "checkmark.circle.fill")
            default:
                Text("Unknown trip status")
            }
        }
        .padding()
    }
}

Expanded mode has four named regions, and you must also supply compact and minimal variants.

The Dynamic Island configuration starts with the expanded layout. The article uses four explicit regions: .leading, .trailing, .center, and .bottom. After that, it supplies separate content for compactLeading, compactTrailing, and minimal.

DynamicIsland {
    DynamicIslandExpandedRegion(.leading) {
        Image(systemName: "moon.stars.fill")
    }
    DynamicIslandExpandedRegion(.trailing) {
        Text(context.state.arrivalTime, style: .timer)
            .font(.caption2)
    }
    DynamicIslandExpandedRegion(.center) {
        Text("Next stop: \(context.attributes.userStopPlanetName)")
    }
    DynamicIslandExpandedRegion(.bottom) {
        Button("Spacecraft access badge") { }
            .buttonStyle(.borderedProminent)
    }
} compactLeading: {
    Text("Trip - \(context.attributes.shipNumber)")
} compactTrailing: {
    Text(context.state.arrivalTime, style: .relative)
        .frame(width: 50)
        .monospacedDigit()
        .font(.caption2)
} minimal: {
    Image(systemName: "moon.stars.fill")
}

The design guidance in the article is practical. Expanded leading and trailing areas should stay short. The center can carry a sentence or scoreboard-like summary. The bottom is a good place for a button. Minimal mode should remain icon-like because the system can shrink activities aggressively when multiple ones are active.

Starting the activity happens in the main app, not inside the widget extension.

Once the widget extension knows how to render the activity, the main app can create it by building the fixed attributes, preparing the initial content state, and sending them to Activity.request.

let attributes = TripAppAttributes(
    shipNumber: "To Mars",
    departureTime: Date(),
    userStopPlanetName: "Mars",
    userCabinNumber: "A-07"
)

let contentState = TripAppAttributes.ContentState(
    tripStatus: TripAppAttributes.TripStatus.inflight.rawValue,
    arrivalTime: Calendar.current.date(
        byAdding: .minute,
        value: 8,
        to: Date()
    ) ?? Date()
)

do {
    self.currentActivity = try Activity<TripAppAttributes>.request(
        attributes: attributes,
        contentState: contentState,
        pushType: nil
    )
} catch {
    print(error.localizedDescription)
}

You can inspect existing activities later through Activity<TripAppAttributes>.activities. The post also notes that users can disable Live Activities for an app, so it is worth checking ActivityAuthorizationInfo().areActivitiesEnabled before relying on them.

Time Limits Per the article's summary of Apple's documentation, a Live Activity can remain active for up to 8 hours, then stay on the lock screen for up to 4 more hours after it ends, for a total lock-screen lifetime of up to 12 hours.

Updates can come from the app itself or from your server through liveactivity pushes.

For local updates, the flow is simple: build a new ContentState and call update(using:) on the current activity. The article demonstrates extending the arrival time by ten minutes.

Button("Trip arrival time +10 minutes") {
    Task {
        guard let currentActivity else { return }

        let updatedState = TripAppAttributes.ContentState(
            tripStatus: TripAppAttributes.TripStatus.inflight.rawValue,
            arrivalTime: Calendar.current.date(
                byAdding: .minute,
                value: 10,
                to: currentActivity.contentState.arrivalTime
            ) ?? Date()
        )

        await currentActivity.update(using: updatedState)
    }
}

Remote updates are a little more operational. You send the activity's pushToken to your server, then encode the latest content-state as JSON in the same shape as the Swift struct.

Push Checklist Use apns-push-type: liveactivity, set apns-topic to [BundleID].push-type.liveactivity, send event: update for changes or event: end to stop the activity, and keep watching pushTokenUpdates because the token can change.

Ending the activity means sending a final state and a dismissal policy.

The final step is not just removing the widget. You tell ActivityKit what the end state should be, then let the system decide when to dismiss it according to the chosen policy.

Button("End activity") {
    Task {
        guard let currentActivity else { return }

        let updatedState = TripAppAttributes.ContentState(
            tripStatus: TripAppAttributes.TripStatus.landed.rawValue,
            arrivalTime: Date()
        )

        await currentActivity.end(
            using: updatedState,
            dismissalPolicy: .default
        )
    }
}

That fits the general lifecycle the article teaches: request the activity, update it as state changes, and end it with one last snapshot when the event is over.

The important mental model is not the UI chrome. It is the lifecycle.

The Dynamic Island screenshots are what make this topic eye-catching, but the durable lesson is the shape of the API: define immutable and mutable data separately, wire the widget extension through ActivityConfiguration, describe every island state, and let the main app or server drive the content updates.

For early ActivityKit-era code, this article is still a useful reference because it walks through the complete path from prerequisites to teardown without hiding the operational details.