How to Implement Dynamic Island in SwiftUI
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
-
ActivityAttributes defines the contract. The
ContentStateinner 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 beCodableandHashable. Because this struct is consumed by two targets, it must be compiled into both — typically by checking both targets in the file inspector. -
ActivityConfiguration wires the widget to Live Activities. The first trailing closure receives a
contextparameter whosecontext.stateandcontext.attributeslet you read the current data. This closure produces the Lock Screen / StandBy banner. ThedynamicIsland:closure receives the same context and builds the pill UI. -
DynamicIsland has four expanded regions plus compact and minimal. Use
DynamicIslandExpandedRegion(.leading),.trailing,.center, and.bottomwhen the user long-presses the island. ThecompactLeadingandcompactTrailingclosures render on either side of the camera cutout when only your app has an active island.minimalrenders as a small circle attached to the island when two apps compete. -
Activity.request()launches from the main app. After checkingActivityAuthorizationInfo().areActivitiesEnabled, pass your attributes and an initialActivityContenttoActivity.request(). The returnedActivity<T>handle is used for subsequent updates and the final end call. -
Updates and dismissal are async. Call
activity.update()with a newActivityContentwhenever 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
-
Dynamic Island only exists on iPhone 14 Pro and later. On earlier or non-Pro iPhone 14 models, the Live Activity appears as a Lock Screen notification banner — there is no pill UI. Always test on a real Pro device or the Dynamic Island simulator.
ActivityAuthorizationInfo().areActivitiesEnabledreturnstrueon all supported devices but does not tell you whether the Dynamic Island hardware is present. -
Both targets must compile the shared ActivityAttributes type. A common mistake is adding
DeliveryAttributes.swiftonly to the app target, then getting "type not found" in the widget extension. Check the File Inspector → Target Membership and tick both targets. Alternatively, move the type into a local Swift package and import it from both. -
Live Activities must be declared in Info.plist. Add
NSSupportsLiveActivitiesset toYESin your app target's Info.plist (not the widget). Without this key,Activity.request()silently fails with a permissions error. Also addNSSupportsLiveActivitiesFrequentUpdates(YES) if you need more than one update per fifteen seconds. -
Don't block the main thread with activity updates.
activity.update()andactivity.end()areasyncand must be called from an async context. Wrapping them inTask { await … }on the main actor is safe, but avoid firing rapid successive updates in tight loops — iOS rate-limits updates, especially on battery saver. -
The #Preview macro requires specific syntax for Live Activities. Use
#Preview("name", as: .dynamicIsland(.compact), using: attributesInstance)followed by two trailing closures: the widget body andcontentStates:. Forgetting theusing:label or passing a type instead of an instance causes a compile error that can look cryptic.
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.