How to Implement Dynamic Island in SwiftUI

iOS 17+ Xcode 16+ Advanced APIs: ActivityKit Updated: May 12, 2026
TL;DR

Dynamic Island is driven by Live Activities: define an ActivityAttributes struct shared between your app and a Widget Extension, then call Activity.request() from the app. The Widget Extension supplies the DynamicIsland view builder with expanded, compact, and minimal regions.

import ActivityKit

struct OrderAttributes: ActivityAttributes {
    struct ContentState: Codable, Hashable {
        var status: String
        var eta: Date
    }
    let orderID: String
}

// In your app — start the Live Activity (appears in Dynamic Island)
let attrs  = OrderAttributes(orderID: "#1042")
let state  = OrderAttributes.ContentState(
    status: "On the way", eta: .now.addingTimeInterval(600))
let activity = try Activity.request(
    attributes: attrs,
    content: .init(state: state, staleDate: nil)
)

Full implementation

The Dynamic Island requires two targets to work together. Your main app imports ActivityKit to start, update, and end the Live Activity. A separate Widget Extension target owns the UI — it provides both the Lock Screen banner and the DynamicIsland view with all three presentation states. The ActivityAttributes struct must be accessible to both targets (compile it into both, or put it in a shared Swift package/framework).

// ─────────────────────────────────────────────────
// 1. SHARED (compile into App target AND Widget Extension)
// DeliveryAttributes.swift
// ─────────────────────────────────────────────────
import ActivityKit
import Foundation

struct DeliveryAttributes: ActivityAttributes {
    // Dynamic — changes during the activity
    struct ContentState: Codable, Hashable {
        var status: String
        var eta: Date
        var driverName: String
    }
    // Static — set at request time, never changes
    let orderID: String
    let restaurantName: String
}

// ─────────────────────────────────────────────────
// 2. WIDGET EXTENSION TARGET
// DeliveryLiveActivity.swift
// ─────────────────────────────────────────────────
import SwiftUI
import WidgetKit

struct DeliveryLiveActivity: Widget {
    var body: some WidgetConfiguration {
        ActivityConfiguration(for: DeliveryAttributes.self) { context in

            // Lock Screen / StandBy / Notification banner
            HStack(spacing: 12) {
                Image(systemName: "bag.fill")
                    .font(.title2)
                    .foregroundStyle(.orange)
                VStack(alignment: .leading, spacing: 2) {
                    Text(context.attributes.restaurantName)
                        .font(.headline)
                    Text(context.state.status)
                        .font(.subheadline)
                        .foregroundStyle(.secondary)
                }
                Spacer()
                Text(context.state.eta, style: .timer)
                    .font(.title2.monospacedDigit())
                    .foregroundStyle(.orange)
            }
            .padding()
            .activityBackgroundTint(.orange.opacity(0.12))
            .activitySystemActionForegroundColor(.orange)

        } dynamicIsland: { context in
            DynamicIsland {

                // ── Expanded (user long-presses the island) ──
                DynamicIslandExpandedRegion(.leading) {
                    Image(systemName: "bag.fill")
                        .font(.title)
                        .foregroundStyle(.orange)
                        .padding(.leading, 4)
                }
                DynamicIslandExpandedRegion(.trailing) {
                    VStack(alignment: .trailing, spacing: 2) {
                        Text("ETA")
                            .font(.caption2)
                            .foregroundStyle(.tertiary)
                        Text(context.state.eta, style: .timer)
                            .font(.headline.monospacedDigit())
                            .foregroundStyle(.orange)
                    }
                    .padding(.trailing, 4)
                }
                DynamicIslandExpandedRegion(.center) {
                    Text(context.state.status)
                        .font(.headline)
                        .lineLimit(1)
                }
                DynamicIslandExpandedRegion(.bottom) {
                    HStack {
                        Label(context.state.driverName, systemImage: "person.fill")
                            .font(.caption)
                            .foregroundStyle(.secondary)
                        Spacer()
                        Text(context.attributes.orderID)
                            .font(.caption2)
                            .foregroundStyle(.tertiary)
                    }
                    .padding(.bottom, 4)
                }

            } compactLeading: {
                // ── Compact left (shown when one app owns the island) ──
                Image(systemName: "bag.fill")
                    .foregroundStyle(.orange)

            } compactTrailing: {
                // ── Compact right ──
                Text(context.state.eta, style: .timer)
                    .font(.caption2.monospacedDigit())
                    .foregroundStyle(.orange)
                    .frame(width: 40)

            } minimal: {
                // ── Minimal (two competing activities — shown as small circle) ──
                Image(systemName: "bag.fill")
                    .foregroundStyle(.orange)
            }
            .keylineTint(.orange)
            .contentMargins(.horizontal, 8, for: .expanded)
        }
    }
}

// ─────────────────────────────────────────────────
// 3. MAIN APP TARGET
// DeliveryTracker.swift
// ─────────────────────────────────────────────────
import ActivityKit
import Foundation

@MainActor
class DeliveryTracker: ObservableObject {
    private var activity: Activity<DeliveryAttributes>?

    func start(orderID: String, restaurant: String, driverName: String) {
        guard ActivityAuthorizationInfo().areActivitiesEnabled else { return }
        let attributes = DeliveryAttributes(orderID: orderID, restaurantName: restaurant)
        let initialState = DeliveryAttributes.ContentState(
            status: "Order confirmed",
            eta: .now.addingTimeInterval(1800),
            driverName: driverName
        )
        do {
            activity = try Activity.request(
                attributes: attributes,
                content: .init(state: initialState, staleDate: nil),
                pushType: nil          // use .token for push-based updates
            )
        } catch {
            print("Live Activity error: \(error)")
        }
    }

    func update(status: String, eta: Date) async {
        guard let activity else { return }
        let updatedState = DeliveryAttributes.ContentState(
            status: status,
            eta: eta,
            driverName: activity.content.state.driverName
        )
        await activity.update(.init(state: updatedState, staleDate: nil))
    }

    func end() async {
        await activity?.end(
            .init(state: activity!.content.state, staleDate: nil),
            dismissalPolicy: .after(.now.addingTimeInterval(5))
        )
    }
}

// ─────────────────────────────────────────────────
// 4. PREVIEW (Widget Extension target)
// ─────────────────────────────────────────────────
#Preview("Compact",
    as: .dynamicIsland(.compact),
    using: DeliveryAttributes(orderID: "#1042", restaurantName: "Burrito Bros")
) {
    DeliveryLiveActivity()
} contentStates: {
    DeliveryAttributes.ContentState(
        status: "On the way",
        eta: .now.addingTimeInterval(600),
        driverName: "Alex"
    )
}

How it works

  1. ActivityAttributes defines the contract. The ContentState inner struct holds every value that can change during the session (status, eta, driverName). The outer struct holds values that are fixed at launch (orderID, restaurantName). Both must be Codable and Hashable. Because this struct is consumed by two targets, it must be compiled into both — typically by checking both targets in the file inspector.
  2. ActivityConfiguration wires the widget to Live Activities. The first trailing closure receives a context parameter whose context.state and context.attributes let you read the current data. This closure produces the Lock Screen / StandBy banner. The dynamicIsland: closure receives the same context and builds the pill UI.
  3. DynamicIsland has four expanded regions plus compact and minimal. Use DynamicIslandExpandedRegion(.leading), .trailing, .center, and .bottom when the user long-presses the island. The compactLeading and compactTrailing closures render on either side of the camera cutout when only your app has an active island. minimal renders as a small circle attached to the island when two apps compete.
  4. Activity.request() launches from the main app. After checking ActivityAuthorizationInfo().areActivitiesEnabled, pass your attributes and an initial ActivityContent to Activity.request(). The returned Activity<T> handle is used for subsequent updates and the final end call.
  5. Updates and dismissal are async. Call activity.update() with a new ActivityContent whenever state changes — this is safe to call many times. activity.end(_:dismissalPolicy:) accepts .immediate, .default, or .after(date) so the island can linger briefly to show a completion state before disappearing.

Variants

Push-based remote updates (server-driven)

Request a push token at activity creation time, then send APNs payloads from your server to update the island without the app being in the foreground.

// Request with push support
activity = try Activity.request(
    attributes: attributes,
    content: .init(state: initialState, staleDate: nil),
    pushType: .token              // ← enables push updates
)

// Observe the push token
Task {
    for await pushToken in activity.pushTokenUpdates {
        let tokenHex = pushToken.map { String(format: "%02x", $0) }.joined()
        // Send tokenHex to your server — it posts APNs payloads
        await sendTokenToServer(tokenHex)
    }
}

// APNs payload your server sends (content-type: application/json):
// {
//   "aps": {
//     "timestamp": 1715000000,
//     "event": "update",
//     "content-state": {
//       "status": "Delivered!",
//       "eta": 0,
//       "driverName": "Alex"
//     }
//   }
// }

Theming with keylineTint and activityBackgroundTint

Apply .keylineTint(.purple) on the DynamicIsland { … } call to tint the glowing ring that appears in the expanded state. For the Lock Screen banner, .activityBackgroundTint(Color) sets the background and .activitySystemActionForegroundColor(Color) adjusts the system action button tints. Both accept any ShapeStyle, including gradients in iOS 17.2+. Keep contrast ratios accessible — the island UI can appear over both light and dark wallpapers.

Common pitfalls

Prompt this with Claude Code

When using Soarias or Claude Code directly to implement this:

Implement Dynamic Island in SwiftUI for iOS 17+.
Use ActivityKit, ActivityAttributes, and ActivityConfiguration.
Create a Widget Extension with a DynamicIsland builder covering
expanded (leading, trailing, center, bottom), compactLeading,
compactTrailing, and minimal states.
Add Activity.request(), activity.update(), and activity.end()
in the main app target via an ObservableObject.
Make it accessible (VoiceOver labels on all image assets).
Add a #Preview with realistic sample data for compact state.

In Soarias, run this prompt during the Build phase after your screens are scaffolded — the assistant will create both the widget extension target and the shared attributes file, wiring up the full ActivityKit lifecycle in one pass.

Related

FAQ

Does Dynamic Island work on iOS 16?

Live Activities launched with iOS 16.1 and ActivityKit, but the Dynamic Island pill UI requires iPhone 14 Pro or later running iOS 16.1+. On older hardware or non-Pro iPhone 14, the Live Activity degrades gracefully to a Lock Screen banner — there is no Dynamic Island. Because this guide targets iOS 17+, you get all the stable APIs including contentMargins(_:_:for:) and ActivityContent (both introduced at iOS 16.2 / 17) without worrying about earlier edge cases.

Can I update the Dynamic Island from a background URLSession without push tokens?

Yes, but with limitations. You can call activity.update() from a background URLSession completion handler or a BackgroundTask scheduled via BGAppRefreshTask. However, iOS rate-limits local updates (roughly one per fifteen seconds under normal conditions) and may defer background task execution. For real-time updates — such as live sports scores or delivery ETAs — Apple strongly recommends using push-based updates (pushType: .token) so your server can push content-state payloads directly via APNs without waking the app at all.

What is the UIKit equivalent?

There is no UIKit-only equivalent. Live Activities and Dynamic Island are exclusively an ActivityKit + WidgetKit API and the UI is always built in SwiftUI — even in otherwise UIKit apps. You call Activity.request() from UIKit code without any problem (it is a plain Swift API), but the island and banner views must be authored in SwiftUI inside a Widget Extension. There is no UIView-based path.

Last reviewed: 2026-05-12 by the Soarias team.