Make A Live Activity Fit The Landscape Dynamic Island

Live Activities used to be easy to think about as a Lock Screen feature with a bonus Dynamic Island layout. That mental model is too small now.

In iOS 27, Apple shows Live Activities in the Dynamic Island while iPhone is in portrait and landscape. The same activity can also show up on the Lock Screen, in StandBy, in the Apple Watch Smart Stack, in the Mac menu bar, on the CarPlay Dashboard, and on related system surfaces. Apple’s docs do not include Live Activities as a visionOS surface, and the relevant WidgetKit Live Activity APIs are marked unavailable there.

That means the hard part is not “how do I make a Live Activity?” The hard part is “how do I design one model and one set of views that still make sense when the available space changes dramatically?”

Availability note: these examples assume a modern Live Activity target. The small activity family, activityFamily environment value, and broadcast channels require iOS 18 or later; LiveActivityIntent requires iOS 17 or later; push-to-start tokens require iOS 17.2 or later. If your app supports older OS versions, gate those sections with availability checks and keep a push-token-only fallback.

Let’s build a delivery tracker. The user ordered lunch, a courier is approaching, and the activity needs to answer one glanceable question everywhere: what is happening now?

Start with the information priority

For a delivery app, the full Lock Screen view can show the merchant, phase, ETA, courier name, and a progress row. The compact Dynamic Island cannot. The landscape Dynamic Island can be tighter still. Apple calls out this exact issue in WWDC26: compact and minimal presentations may be visible when iPhone is in landscape, but they do not have the same flexible width you get in portrait.

I like to rank the information before writing code:

  1. Phase: placed, preparing, picked up, nearby, delivered.
  2. ETA or “arriving now”.
  3. Merchant or order identity.
  4. Courier details.
  5. Actions such as “I’m outside” or “Leave at door”.

If the smallest layout can only show one thing, it should show phase or ETA. Everything else is progressively enhanced.

Model static and dynamic data separately

ActivityKit updates are efficient because the system separates data that never changes from data that changes during the life of the Live Activity. Put static values in ActivityAttributes. Put changing values in ContentState.

import ActivityKit
import Foundation

struct DeliveryActivityAttributes: ActivityAttributes {
    let orderID: String
    let merchantName: String
    let shortDestination: String

    struct ContentState: Codable, Hashable {
        var phase: Phase
        var estimatedArrival: Date?
        var stopsAway: Int
        var courierName: String?

        enum Phase: String, Codable, Hashable {
            case placed
            case preparing
            case pickedUp
            case nearby
            case delivered

            var label: String {
                switch self {
                case .placed: "Placed"
                case .preparing: "Preparing"
                case .pickedUp: "On the way"
                case .nearby: "Nearby"
                case .delivered: "Delivered"
                }
            }

            var symbolName: String {
                switch self {
                case .placed: "checkmark.seal.fill"
                case .preparing: "takeoutbag.and.cup.and.straw.fill"
                case .pickedUp: "bicycle"
                case .nearby: "location.fill"
                case .delivered: "house.fill"
                }
            }
        }
    }
}

Avoid putting huge blobs, images, or frequently changing server-only details in the state. The state is your contract with WidgetKit and with your push payloads. Keep it small enough that every surface can make a decision quickly.

This pattern maps well beyond delivery: transit can keep the route static and the next arrival dynamic; sports can keep the match identity static and score/time dynamic; race timing can keep the participant static and split/position dynamic; finance or ops dashboards can keep the instrument or incident static and the latest status dynamic; ride share can keep the trip ID static and pickup/driver progress dynamic.

Build every presentation from the same context

The widget extension uses ActivityConfiguration. The Lock Screen content closure and the Dynamic Island closure both receive an ActivityViewContext<DeliveryActivityAttributes>, so every view reads the same attributes and latest state.

import ActivityKit
import SwiftUI
import WidgetKit

struct DeliveryLiveActivity: Widget {
    var body: some WidgetConfiguration {
        ActivityConfiguration(for: DeliveryActivityAttributes.self) { context in
            DeliveryActivityView(context: context)
        } dynamicIsland: { context in
            DynamicIsland {
                DynamicIslandExpandedRegion(.leading) {
                    Label(
                        context.state.phase.label,
                        systemImage: context.state.phase.symbolName
                    )
                }

                DynamicIslandExpandedRegion(.center) {
                    DeliveryETAView(state: context.state)
                }

                DynamicIslandExpandedRegion(.trailing) {
                    Text(context.attributes.shortDestination)
                        .font(.caption)
                }

                DynamicIslandExpandedRegion(.bottom) {
                    DeliveryProgressView(state: context.state)
                }
            } compactLeading: {
                Image(systemName: context.state.phase.symbolName)
            } compactTrailing: {
                DeliveryCompactTrailingView(context: context)
            } minimal: {
                Image(systemName: context.state.phase.symbolName)
            }
            .keylineTint(.green)
        }
        .supplementalActivityFamilies([.small])
    }
}

The important design move is that the compact regions are not just shrunken copies of the expanded view. They are purpose-built summaries. The compact leading region is the phase symbol. The compact trailing region can show an ETA when there is room, then collapse to a symbol when there is not.

Handle the landscape Dynamic Island width

WWDC26’s “Live Activities essentials” session shows a new environment value named isDynamicIslandLimitedInWidth:

struct DeliveryCompactTrailingView: View {
    @Environment(\.isDynamicIslandLimitedInWidth)
    private var isDynamicIslandLimitedInWidth

    let context: ActivityViewContext<DeliveryActivityAttributes>

    var body: some View {
        if isDynamicIslandLimitedInWidth {
            Image(systemName: context.state.phase.symbolName)
                .accessibilityLabel(context.state.phase.label)
        } else if let arrival = context.state.estimatedArrival {
            Text(arrival, style: .timer)
                .monospacedDigit()
                .frame(maxWidth: 54, alignment: .trailing)
        } else {
            Text(context.state.phase.label)
                .font(.caption2.bold())
        }
    }
}

Beta caveat: Apple’s WWDC26 sample uses @Environment(\.isDynamicIslandLimitedInWidth), but Xcode 26.5 does not expose that environment value in the iPhoneOS SDK Swift interfaces. Treat this snippet as WWDC beta sample code; do not paste it into an Xcode 26.5 project without replacing it with whatever public API Apple eventually ships, or with your own conservative compact fallback.

The limited-width branch should not be a “smaller font” branch. It should be a different design. Timers, long phase strings, merchant names, and counts like “2 stops” are fragile in that space. Symbols, short gauges, and fixed-width numeric text survive better.

Treat StandBy as a background problem

StandBy uses the Lock Screen Live Activity presentation, but the scale and background expectations are different. A Lock Screen card background can look stranded when it is enlarged for StandBy.

Use showsWidgetContainerBackground to decide whether your own background should be drawn, then use .activityBackgroundTint to give StandBy-style presentations a recognizable full background tint.

struct DeliveryActivityView: View {
    @Environment(\.showsWidgetContainerBackground)
    private var showsWidgetContainerBackground

    @Environment(\.activityFamily)
    private var activityFamily

    let context: ActivityViewContext<DeliveryActivityAttributes>

    var body: some View {
        content
            .padding()
            .background {
                if showsWidgetContainerBackground {
                    LinearGradient(
                        colors: [.green.opacity(0.30), .blue.opacity(0.18)],
                        startPoint: .topLeading,
                        endPoint: .bottomTrailing
                    )
                }
            }
            .activityBackgroundTint(.green)
    }

    @ViewBuilder
    private var content: some View {
        if activityFamily == .small {
            DeliverySmallActivityView(context: context)
        } else {
            DeliveryDetailedActivityView(context: context)
        }
    }
}

The Lock Screen branch can use the contained widget background. StandBy can lean on the activity background tint so the activity feels intentional at a larger size.

Add the small activity family

Apple Watch and CarPlay are not just “medium, but farther away.” They are interruption-sensitive surfaces. The small activity family should remove anything that requires reading a sentence.

You already saw .supplementalActivityFamilies([.small]) on the widget configuration. The branch in DeliveryActivityView can be as small as this:

struct DeliverySmallActivityView: View {
    let context: ActivityViewContext<DeliveryActivityAttributes>

    var body: some View {
        HStack(spacing: 8) {
            Image(systemName: context.state.phase.symbolName)
                .font(.headline)

            VStack(alignment: .leading, spacing: 2) {
                Text(context.state.phase.label)
                    .font(.caption.bold())

                if let arrival = context.state.estimatedArrival {
                    Text(arrival, style: .timer)
                        .font(.caption2.monospacedDigit())
                }
            }
        }
        .lineLimit(1)
        .minimumScaleFactor(0.8)
    }
}

For delivery, “Nearby” plus a timer is enough. For transit, use the route badge and minutes. For sports, use score and clock. For finance or ops, use status and one critical number. If the small family needs more than two pieces of information, the priority list probably needs another pass.

Start and update locally

Before starting a Live Activity from the app, check authorization with ActivityAuthorizationInfo().areActivitiesEnabled. Then request the activity with attributes and an ActivityContent value.

import ActivityKit
import Foundation

@MainActor
final class DeliveryActivityController {
    private var activity: Activity<DeliveryActivityAttributes>?

    func start(order: Order) throws {
        guard ActivityAuthorizationInfo().areActivitiesEnabled else {
            return
        }

        let attributes = DeliveryActivityAttributes(
            orderID: order.id,
            merchantName: order.merchantName,
            shortDestination: order.shortDestination
        )

        let state = DeliveryActivityAttributes.ContentState(
            phase: .preparing,
            estimatedArrival: order.estimatedArrival,
            stopsAway: order.stopsAway,
            courierName: nil
        )

        activity = try Activity.request(
            attributes: attributes,
            content: ActivityContent(state: state, staleDate: nil)
        )
    }

    func update(from delivery: DeliveryStatus) async {
        let state = DeliveryActivityAttributes.ContentState(
            phase: delivery.phase,
            estimatedArrival: delivery.estimatedArrival,
            stopsAway: delivery.stopsAway,
            courierName: delivery.courierName
        )

        await activity?.update(
            ActivityContent(
                state: state,
                staleDate: delivery.estimatedArrival?.addingTimeInterval(120)
            )
        )
    }
}

Set a staleDate when the data has a natural expiration. For a courier ETA, stale shortly after the current estimate. For a race clock, stale quickly. For a package “delivered” phase, you may not need one because the terminal state is stable.

Pick the right push update strategy

There are two common remote-update shapes:

Use push-token updates for per-user activities. A delivery order, ride share pickup, finance alert, support incident, or private ops workflow belongs here. Start the activity with .token, observe pushTokenUpdates, send each token to your server, and have the server address updates to that specific Live Activity.

let activity = try Activity.request(
    attributes: attributes,
    content: ActivityContent(state: state, staleDate: nil),
    pushType: .token
)

Task {
    for await tokenData in activity.pushTokenUpdates {
        let token = tokenData.map { String(format: "%02x", $0) }.joined()
        await DeliveryAPI.shared.registerLiveActivityToken(
            token,
            orderID: attributes.orderID
        )
    }
}

The first push token may arrive after Activity.request returns, and tokens can change while the activity is active. Store tokens per Live Activity and invalidate the old token on your server when pushTokenUpdates yields a replacement.

Use broadcast channels for shared events. A playoff game, train disruption, public race leaderboard, flash sale, or market-wide incident may have thousands of users watching the same thing. In that case, your server creates a channel, your app starts the Live Activity with PushType.channel(_:), and APNs fans updates out to subscribers.

let activity = try Activity.request(
    attributes: attributes,
    content: ActivityContent(state: state, staleDate: nil),
    pushType: .channel(channelID)
)

A few details matter:

  • Broadcast capability is enabled on developer.apple.com, not in Xcode.
  • Broadcast channels are for updating and ending shared Live Activities.
  • Per Apple’s ActivityKit push documentation, broadcast push notifications cannot start a Live Activity.
  • If you need the server to start future activities automatically, use push-to-start tokens and the ActivityKit start payload flow instead of broadcast.

For the delivery example, use per-activity push tokens. A broadcast channel would be wrong because each user has a different ETA, courier, address, and order state.

Add quick actions with LiveActivityIntent

Live Activities can include buttons backed by App Intents. For Live Activity-specific actions, conform to LiveActivityIntent.

Here is a compact “Leave at door” action:

import AppIntents

struct LeaveAtDoorIntent: LiveActivityIntent {
    static var title: LocalizedStringResource = "Leave at Door"

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

    func perform() async throws -> some IntentResult {
        try await DeliveryAPI.shared.updateDropoffPreference(
            orderID: orderID,
            preference: .leaveAtDoor
        )

        return .result()
    }
}

And a button you might show in the expanded Dynamic Island or Lock Screen view:

Button(intent: LeaveAtDoorIntent(orderID: context.attributes.orderID)) {
    Label("Leave at door", systemImage: "door.left.hand.open")
}

Keep these actions immediate and contextual. Rating a completed order, confirming pickup, muting updates, checking in for a ride, or acknowledging an ops incident are good candidates. A multi-step flow still belongs in the app.

Checklist

  • Design from the smallest surface first: compact/minimal Dynamic Island, then small activity family, then expanded and Lock Screen.
  • Keep ActivityAttributes static and ContentState dynamic.
  • In iOS 27, test compact and minimal Dynamic Island layouts in landscape.
  • Use the WWDC26 isDynamicIslandLimitedInWidth environment value when your SDK exposes it, and keep a beta naming caveat until final SDKs settle.
  • Use showsWidgetContainerBackground with .activityBackgroundTint so Lock Screen and StandBy both feel designed.
  • Add .supplementalActivityFamilies([.small]) and branch on activityFamily for Apple Watch and CarPlay-sized presentations.
  • Choose push tokens for private, per-user activities and broadcast channels for shared events.
  • Remember that broadcast capability is enabled on developer.apple.com and broadcast cannot start a Live Activity.
  • Use LiveActivityIntent for quick actions that can complete without opening the app.

Sources