Live Activities and Dynamic Island in iOS 26: The Complete SwiftUI & ActivityKit Guide

A practical, 2026-ready guide to building Live Activities and Dynamic Island presentations in SwiftUI with ActivityKit - including push-to-start, remote APNs updates, interactive App Intents buttons, and the rules that keep your activity on screen.

Live Activities iOS 26: Complete Guide 2026

Live Activities are how modern iOS apps keep users in the loop without forcing them to unlock their phone. A food delivery ticking down on the Lock Screen. A sports score updating in the Dynamic Island. A workout timer you can glance at from anywhere. In iOS 26, ActivityKit is more capable than ever — push-to-start, interactive App Intents buttons, paired-device display on macOS 26 and watchOS 11 — and yet, honestly, most online tutorials still stop at a simple counter demo.

So this guide covers the whole thing: project setup, the full Dynamic Island layout set, starting activities locally and from a server, remote updates over APNs, push-to-start tokens, interactive buttons, stale data, dismissal policies, simulator testing, and the performance rules that decide whether your activity stays on screen. Every example targets Xcode 26 and iOS 26, with Swift 6 concurrency annotations.

What Is a Live Activity, Exactly?

A Live Activity is a small, event-scoped SwiftUI view declared inside a Widget Extension and managed by the ActivityKit framework. It lives in two places:

  • Lock Screen — a full-width card that replaces a notification while the event is in progress.
  • Dynamic Island — a morphing pill at the top of the screen on iPhone 14 Pro and later, with four presentation states: compact leading, compact trailing, minimal, and expanded.

Unlike a widget, a Live Activity is temporary. It only exists while something is happening, and it's dismissed when the event ends. And unlike a notification, it updates in place instead of stacking up.

Live Activity vs Widget vs Notification

CapabilityLive ActivityWidgetNotification
PersistenceEvent duration (≤ 12h)Always on home screenOne-shot banner
Updates in placeYesScheduled timelineNo
Dynamic IslandYesNoNo
InteractiveYes (App Intents)Yes (iOS 17+)Actions only
Remote triggerPush-to-start + push updatesBackground refreshAPNs

What's New for Live Activities in iOS 26

If you last shipped a Live Activity back in iOS 17 or 18, a few things have shifted in iOS 26:

  • Paired-device display — Live Activities automatically mirror to paired watchOS 11+ and macOS 26+ devices. You can opt out in the ActivityConfiguration, but by default the system just handles it.
  • Interactive buttons via App IntentsButton(intent:) and Toggle(intent:) now work inside both the Lock Screen card and the expanded Dynamic Island. Meaning: a user can pause a timer or tick off a delivery step without ever opening your app.
  • Push-to-start, GA — the push-to-start token API is stable. Your server can create a Live Activity on the device with no prior in-app action, which is huge for web-ordered flows.
  • Stricter size budget — the expanded Dynamic Island region still sits around 160pt tall, and APNs liveactivity payloads are capped at 4 KB. New in iOS 26: payloads over budget fail silently with a telemetry entry, rather than crashing the widget (a quiet but welcome change).
  • Swift 6 by default — the Widget Extension template now uses strict concurrency. Your ActivityAttributes must be Sendable, and anything touching UI state needs @MainActor.

Prerequisites

  • Xcode 26 with the iOS 26 SDK.
  • A physical iPhone 14 Pro or later for Dynamic Island testing. (The simulator supports Lock Screen and expanded Dynamic Island, but the compact states really do render best on device.)
  • An active Apple Developer account if you plan to send push updates. APNs requires token-based authentication (a .p8 key).
  • In your app target's Info.plist, add NSSupportsLiveActivities set to YES. Only add NSSupportsLiveActivitiesFrequentUpdates if you genuinely need sub-second updates — abusing it tanks your budget.

Step 1: Add a Widget Extension

In Xcode, go File → New → Target → Widget Extension. Name it DeliveryWidgets and make sure Include Live Activity is checked. Xcode will generate a stub ActivityAttributes struct, an ActivityConfiguration, and a WidgetBundle.

Before you write a line of code, make sure your data models are members of both the app target and the widget target. Open each model file, show the File Inspector, and tick both targets. Missing target membership is (by a mile) the #1 reason widgets fail to build against your shared models. I've wasted hours on this; don't be me.

Step 2: Define ActivityAttributes

An ActivityAttributes type has two parts. The outer struct holds values that never change during the activity's lifetime — order number, driver name, route. The nested ContentState holds everything that can update: current step, ETA, progress.

import ActivityKit
import SwiftUI

public struct DeliveryAttributes: ActivityAttributes {
    public struct ContentState: Codable, Hashable, Sendable {
        public var step: DeliveryStep
        public var etaSeconds: Int
        public var driverLocation: String
        public var progress: Double  // 0.0 ... 1.0
    }

    public let orderNumber: String
    public let restaurantName: String
    public let totalItems: Int
}

public enum DeliveryStep: String, Codable, Sendable {
    case preparing, pickedUp, onTheWay, arriving, delivered
}

Keep ContentState small. Every property you add gets serialized into every APNs payload, and remember, the total payload ceiling is 4 KB. If you're tempted to stuff a list of items in there, store an ID instead and render the details from the app.

Step 3: Build the Lock Screen and Dynamic Island UI

The ActivityConfiguration wires your ActivityAttributes to four SwiftUI layouts. Here's a complete, production-shaped implementation:

import ActivityKit
import SwiftUI
import WidgetKit

struct DeliveryLiveActivity: Widget {
    var body: some WidgetConfiguration {
        ActivityConfiguration(for: DeliveryAttributes.self) { context in
            // Lock Screen / paired-device banner
            LockScreenView(context: context)
                .activityBackgroundTint(Color.black.opacity(0.85))
                .activitySystemActionForegroundColor(.white)

        } dynamicIsland: { context in
            DynamicIsland {
                // Expanded
                DynamicIslandExpandedRegion(.leading) {
                    Label(context.attributes.restaurantName, systemImage: "bag.fill")
                        .font(.caption.bold())
                }
                DynamicIslandExpandedRegion(.trailing) {
                    Text(etaText(context.state.etaSeconds))
                        .font(.caption.monospacedDigit())
                }
                DynamicIslandExpandedRegion(.center) {
                    Text(context.state.step.displayName)
                        .font(.headline)
                }
                DynamicIslandExpandedRegion(.bottom) {
                    ProgressView(value: context.state.progress)
                        .progressViewStyle(.linear)
                        .tint(.orange)
                }
            } compactLeading: {
                Image(systemName: "bag.fill").foregroundStyle(.orange)
            } compactTrailing: {
                Text(etaText(context.state.etaSeconds))
                    .monospacedDigit()
            } minimal: {
                Image(systemName: "bag.fill").foregroundStyle(.orange)
            }
            .widgetURL(URL(string: "delivery://order/\(context.attributes.orderNumber)"))
            .keylineTint(.orange)
        }
    }

    private func etaText(_ seconds: Int) -> String {
        let minutes = max(1, seconds / 60)
        return "\(minutes)m"
    }
}

Every region has a purpose. Minimal is what the system falls back to when another activity is showing alongside yours, so it must identify your app at a glance — usually a single icon is enough. Compact leading/trailing flank the camera notch; keep each under ~44pt wide. Expanded is the four-region layout shown on long-press, and it must fit inside roughly 160pt of height.

The Lock Screen Card

struct LockScreenView: View {
    let context: ActivityViewContext<DeliveryAttributes>

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            HStack {
                Image(systemName: "bag.fill")
                    .font(.title2)
                    .foregroundStyle(.orange)
                VStack(alignment: .leading) {
                    Text(context.attributes.restaurantName)
                        .font(.headline)
                    Text("Order #\(context.attributes.orderNumber)")
                        .font(.caption)
                        .foregroundStyle(.secondary)
                }
                Spacer()
                Text(etaText(context.state.etaSeconds))
                    .font(.title3.monospacedDigit())
            }
            ProgressView(value: context.state.progress) {
                Text(context.state.step.displayName)
                    .font(.caption)
            }
            .tint(.orange)
        }
        .padding()
    }

    private func etaText(_ seconds: Int) -> String {
        "\(max(1, seconds / 60)) min"
    }
}

Step 4: Start an Activity from Your App

From the app side, starting an activity is basically one call. Wrap it in an actor-isolated service so the main thread never blocks:

import ActivityKit

@MainActor
final class DeliveryActivityManager {
    private var current: Activity<DeliveryAttributes>?

    func start(orderNumber: String, restaurant: String, items: Int) async throws {
        guard ActivityAuthorizationInfo().areActivitiesEnabled else {
            throw ActivityError.notAuthorized
        }

        let attributes = DeliveryAttributes(
            orderNumber: orderNumber,
            restaurantName: restaurant,
            totalItems: items
        )
        let initialState = DeliveryAttributes.ContentState(
            step: .preparing,
            etaSeconds: 30 * 60,
            driverLocation: "Restaurant",
            progress: 0.1
        )
        let content = ActivityContent(
            state: initialState,
            staleDate: Date().addingTimeInterval(60 * 15)
        )

        let activity = try Activity.request(
            attributes: attributes,
            content: content,
            pushType: .token  // enables server-driven updates
        )
        self.current = activity

        // Forward the push token to your server
        Task.detached {
            for await tokenData in activity.pushTokenUpdates {
                let hex = tokenData.map { String(format: "%02x", $0) }.joined()
                await ServerAPI.shared.registerActivityToken(hex, activityID: activity.id)
            }
        }
    }

    enum ActivityError: Error { case notAuthorized }
}

Three things worth flagging. First, areActivitiesEnabled is per-app and per-user, so show an empty-state if it's false. Second, staleDate is the point after which the system dims your card to signal "this data may be old" — always set it conservatively. Third (and this one trips people up), pushTokenUpdates is an AsyncSequence: the token can rotate mid-activity, so you have to listen continuously, not just read it once.

Step 5: Update Locally

func updateStep(_ step: DeliveryStep, etaSeconds: Int, progress: Double) async {
    guard let activity = current else { return }
    let state = DeliveryAttributes.ContentState(
        step: step,
        etaSeconds: etaSeconds,
        driverLocation: step == .onTheWay ? "In transit" : "Restaurant",
        progress: progress
    )
    let content = ActivityContent(
        state: state,
        staleDate: Date().addingTimeInterval(60 * 10),
        relevanceScore: step == .arriving ? 100 : 50
    )
    await activity.update(content)
}

relevanceScore controls ordering when the user has more than one Live Activity on screen — higher scores win the top slot.

Step 6: Update Remotely via APNs

Server-driven updates are what make Live Activities feel production-grade. Here's the full remote flow.

APNs Payload Format

{
  "aps": {
    "timestamp": 1777080000,
    "event": "update",
    "content-state": {
      "step": "onTheWay",
      "etaSeconds": 720,
      "driverLocation": "2nd Avenue",
      "progress": 0.55
    },
    "stale-date": 1777080900,
    "relevance-score": 75,
    "alert": {
      "title": "Your driver is on the way",
      "body": "ETA 12 minutes"
    }
  }
}

Required headers:

  • apns-topic: your.bundle.id.push-type.liveactivity
  • apns-push-type: liveactivity
  • apns-priority: 10 for immediate delivery, 5 for throttled
  • apns-expiration: Unix timestamp (use a short window, e.g. 60 seconds)
  • authorization: bearer <provider JWT>

Node.js Sender

import http2 from "node:http2";
import jwt from "jsonwebtoken";
import fs from "node:fs";

const token = jwt.sign(
  { iss: TEAM_ID, iat: Math.floor(Date.now() / 1000) },
  fs.readFileSync("AuthKey_ABC123.p8"),
  { algorithm: "ES256", header: { alg: "ES256", kid: KEY_ID } }
);

const client = http2.connect("https://api.push.apple.com");
const req = client.request({
  ":method": "POST",
  ":path": `/3/device/${pushToken}`,
  "apns-topic": `${BUNDLE_ID}.push-type.liveactivity`,
  "apns-push-type": "liveactivity",
  "apns-priority": "10",
  "apns-expiration": Math.floor(Date.now() / 1000) + 60,
  authorization: `bearer ${token}`,
});

req.setEncoding("utf8");
req.write(JSON.stringify(payload));
req.end();

Ending a Remote Activity

Set event to end and include the final content-state you want shown during the stale window:

{
  "aps": {
    "event": "end",
    "dismissal-date": 1777083600,
    "content-state": { "step": "delivered", "etaSeconds": 0, "progress": 1.0 }
  }
}

dismissal-date controls when the Lock Screen card disappears. Without it, the system will keep the final state visible for up to four hours.

Step 7: Push-to-Start

Push-to-start lets your backend spin up a brand-new Live Activity on a device without the app even being in the foreground. Register for the push-to-start token once from your app:

Task {
    for await tokenData in Activity<DeliveryAttributes>.pushToStartTokenUpdates {
        let hex = tokenData.map { String(format: "%02x", $0) }.joined()
        await ServerAPI.shared.registerPushToStartToken(hex)
    }
}

Do this from your app's launch sequence — not just when the user places an order. The token is per activity type, not per activity instance.

Then your server sends an APNs payload with event: "start" and the full attribute set:

{
  "aps": {
    "event": "start",
    "timestamp": 1777080000,
    "attributes-type": "DeliveryAttributes",
    "attributes": {
      "orderNumber": "A-7189",
      "restaurantName": "Tonkotsu Pasadena",
      "totalItems": 3
    },
    "content-state": {
      "step": "preparing",
      "etaSeconds": 1800,
      "driverLocation": "Restaurant",
      "progress": 0.1
    },
    "stale-date": 1777081500
  }
}

Heads up: attributes-type must match your Swift type name exactly. Apple delivers the payload, iOS wakes your widget extension, and your ActivityConfiguration renders the initial state. This unlocks scenarios like "user ordered on the web and now sees a Live Activity on their phone" — zero app interaction required. Pretty magical the first time you see it work.

Step 8: Interactive Buttons with App Intents

In iOS 26, Live Activities can contain a Button and Toggle backed by App Intents. Define the intent in a shared framework so both the app and the widget can see it:

import AppIntents

struct CancelDeliveryIntent: LiveActivityIntent {
    static var title: LocalizedStringResource = "Cancel Delivery"

    @Parameter(title: "Order ID")
    var orderID: String

    init() {}
    init(orderID: String) { self.orderID = orderID }

    func perform() async throws -> some IntentResult {
        try await DeliveryService.shared.cancel(orderID: orderID)
        return .result()
    }
}

Then add the button to your expanded Dynamic Island region:

DynamicIslandExpandedRegion(.bottom) {
    HStack {
        ProgressView(value: context.state.progress).tint(.orange)
        Button(intent: CancelDeliveryIntent(orderID: context.attributes.orderNumber)) {
            Image(systemName: "xmark.circle.fill")
        }
        .tint(.red)
    }
}

LiveActivityIntent (new in iOS 17.2, polished up in iOS 26) tells the system the intent runs inside your app's process rather than in the extension, giving you full access to your stack. The trade-off? The app briefly wakes in the background, so keep perform() under a second.

Step 9: Ending the Activity

func end() async {
    guard let activity = current else { return }
    let finalState = DeliveryAttributes.ContentState(
        step: .delivered,
        etaSeconds: 0,
        driverLocation: "Delivered",
        progress: 1.0
    )
    let finalContent = ActivityContent(state: finalState, staleDate: nil)
    await activity.end(finalContent, dismissalPolicy: .after(.now + 60 * 5))
    current = nil
}

There are three dismissal policies. .immediate pulls the card instantly, .default keeps it up for up to four hours, and .after(Date) gives you precise control. For a successful delivery, I find a 5–10 minute window is the sweet spot — long enough for the user to see "Delivered" when they pick their phone up, short enough that it doesn't clutter the Lock Screen.

Step 10: Testing Live Activities

  1. Simulator: the Dynamic Island renders on iPhone 14 Pro+ simulators, but compact presentations are genuinely hard to judge there — always verify on a real device.
  2. Local APNs file: Xcode 26 lets you drag a .apns JSON file onto a running simulator window to simulate a push. Structure matches the APNs payload above, plus a "Simulator Target Bundle" key.
  3. Stale state: set staleDate to 10 seconds in the future and watch the card dim. Confirms your layout still reads as "trust me less."
  4. Low Power Mode: push updates are throttled or dropped outright. Design for eventual consistency, not real-time delivery.

Performance and Battery Rules

  • The widget extension has a 30 MB memory cap. Loading a full image library will crash it silently.
  • Each activity can last up to 8 hours active, plus 4 hours stale, for 12 hours total. The system ends activities that overshoot this.
  • Push updates are rate-limited. If you send dozens per minute, APNs will quietly drop some — batch or debounce on the server.
  • Don't animate the Dynamic Island with heavy transitions. The system prefers .spring(response: 0.3) or built-in shape transitions; custom TimelineView with frequent redraws gets penalized.

Troubleshooting Checklist

  • Activity won't start: confirm NSSupportsLiveActivities in Info.plist, and that the user hasn't disabled Live Activities under Settings → [Your App].
  • Widget shows placeholder: your ActivityAttributes or ContentState isn't Sendable, or target membership is wrong.
  • Push updates silently dropped: check APNs response headers. A 400 BadExpirationDate or 413 PayloadTooLarge (over 4 KB) is usually the culprit.
  • Interactive button does nothing: the intent must conform to LiveActivityIntent (not just AppIntent), and the intent type has to be shared between app and widget — via a framework, or by adding it to both targets.
  • Dynamic Island compact views overflow: Apple enforces a ~44pt width per compact slot. Use .monospacedDigit() on timers and truncate long strings.

Frequently Asked Questions

How long can a Live Activity stay on screen?

Active for up to 8 hours, then up to 4 additional hours in a dimmed stale state — 12 hours total. After that, iOS dismisses it regardless of what your server does.

Do Live Activities work on iPhones without Dynamic Island?

Yes. On iPhones older than 14 Pro, the Lock Screen card still appears; only the Dynamic Island presentation is skipped. Always design your Lock Screen view so it stands alone.

What's the size limit for an ActivityKit push payload?

4 KB total, including the aps wrapper and any custom keys. That's exactly why you should store IDs and reconstruct content from the app, rather than embedding item lists or long strings in ContentState.

Can I use Live Activities without a server?

Yep — omit pushType: .token and update the activity locally with activity.update(...). This works for anything driven by the user's device (timers, workouts, on-device tasks), but not for events driven from the cloud like deliveries or rides.

Do Live Activities require push notification permission?

No, they're governed by a separate toggle in your app's Settings page. Users can disable notifications entirely and still see your Live Activities. Just make sure to check ActivityAuthorizationInfo().areActivitiesEnabled before starting one.

How do I test push-to-start without deploying a server?

Use Xcode 26's drag-and-drop APNs simulation: save a .apns file with event: "start" plus the attributes-type, attributes, and content-state keys, then drop it onto a running simulator. Apple's APNsPush web tool also accepts a push-to-start token for on-device testing.

Conclusion

Live Activities aren't a "nice to have" anymore. For any app built around an ongoing event, they're the primary surface users see. iOS 26's push-to-start, interactive buttons, and paired-device display turn them from a widget-like afterthought into a first-class channel — one that can start, update, and complete entirely from the server side.

The hard parts aren't really the APIs. They're the design trade-offs: what to put in ContentState vs. reconstruct on the fly, how often to push, how aggressively to dismiss. My advice? Start with a tight ContentState, a generous staleDate, and a Lock Screen view that reads at arm's length. Everything else, you can iterate on.

About the Author Mei-Lin Chen

Mei-Lin joined Robinhood in 2020 as an iOS engineer on the Crypto team and stayed through the SwiftUI rewrite of the order-entry flow before leaving in 2025. She also did a two-year stint at Asana earlier in her career working on the iPad app and the Mac Catalyst port. She writes about the parts of Apple's frameworks that the WWDC talks gloss over - what Observable actually does to your view-update graph, why @Bindable bindings tear in some animation contexts, and the surprisingly deep rabbit hole of Swift macros for boilerplate elimination. She has shipped two indie apps to the App Store, one of which hit #4 in the Health & Fitness category for a week in 2023. Mei-Lin is based in Seattle and has been writing Swift for 8 years.