Build a Live Activity App: Delivery Tracker with Dynamic Island
Your pizza is in the oven, and you just want to glance at your Lock Screen to see when it arrives — no need to reopen the app. That glanceable, always-visible experience is exactly what Live Activities deliver, and in this tutorial you are going to build one from scratch.
In this tutorial, you’ll build Pizza Planet Delivery Tracker — a Pixar-themed delivery app that displays real-time
order status on the Lock Screen and Dynamic Island. Along the way, you’ll learn how to define ActivityAttributes,
build compact and expanded Dynamic Island layouts, update activities from within the app, configure push-token-based
server updates, and prepare your Live Activity for iOS 26 multi-platform rendering on CarPlay and macOS.
Prerequisites
- Xcode 26+ with iOS 18 deployment target (iOS 26 for multi-platform sections)
- An Apple Developer account (required for push notifications)
- Familiarity with WidgetKit and Live Activities
- Familiarity with Live Activities on iOS 26
- Familiarity with SwiftUI state management
Contents
- Getting Started
- Step 1: Defining the Activity Attributes
- Step 2: Creating the Widget Extension
- Step 3: Building the Lock Screen Live Activity View
- Step 4: Building the Dynamic Island Layouts
- Step 5: Requesting a Live Activity from the App
- Step 6: Building the Order Screen UI
- Step 7: Updating and Ending the Activity
- Step 8: Adding Push Token Support for Server Updates
- Step 9: Preparing for iOS 26 Multi-Platform Rendering
- Step 10: Polish and Final Touches
- Where to Go From Here?
Getting Started
Let’s set up the Xcode project that will house both the main app and the widget extension.
- Open Xcode and create a new project using the App template.
- Set the product name to PizzaPlanetTracker.
- Ensure the interface is set to SwiftUI and the language is Swift.
- Set the minimum deployment target to iOS 18.0.
Before we add any code, we need to enable the Live Activity capability:
- Select the PizzaPlanetTracker target in the project navigator.
- Go to the Info tab.
- Under Custom iOS Target Properties, add a new key:
NSSupportsLiveActivitiesand set its value to YES (Boolean).
Note: Starting in iOS 16.1, this
Info.plistkey is required to request Live Activities. Without it, the system silently refuses to start your activity.
You now have a blank SwiftUI app with Live Activities enabled. Next, we will define the data model that powers the activity.
Step 1: Defining the Activity Attributes
Every Live Activity is backed by an
ActivityAttributes struct. This struct
defines two things: static data that never changes during the activity’s lifetime, and a nested ContentState that
holds the dynamic, updatable data.
For Pizza Planet Delivery Tracker, the static data is the order information (customer name, pizza name, order number), and the dynamic state is the delivery status and estimated arrival time.
Create a new Swift file at Models/DeliveryAttributes.swift and add the following:
import ActivityKit
import Foundation
struct DeliveryAttributes: ActivityAttributes {
// Static data — set once when the activity starts
let orderNumber: String
let pizzaName: String
let customerName: String
// Dynamic data — updated throughout the activity's lifecycle
struct ContentState: Codable, Hashable {
let status: DeliveryStatus
let estimatedArrival: Date
let driverName: String
let progressFraction: Double // 0.0 to 1.0
}
}
Now create the DeliveryStatus enum in the same file. This enum represents each stage of the delivery pipeline:
enum DeliveryStatus: String, Codable, CaseIterable {
case placed = "Order Placed"
case preparing = "Preparing Your Pizza"
case inOven = "In the Oven"
case outForDelivery = "Out for Delivery"
case arriving = "Almost There!"
case delivered = "Delivered"
var emoji: String {
switch self {
case .placed: return "📋"
case .preparing: return "👨🍳"
case .inOven: return "🔥"
case .outForDelivery: return "🚀"
case .arriving: return "📍"
case .delivered: return "✅"
}
}
var stepIndex: Int {
switch self {
case .placed: return 0
case .preparing: return 1
case .inOven: return 2
case .outForDelivery: return 3
case .arriving: return 4
case .delivered: return 5
}
}
}
We use String raw values so the enum is human-readable when serialized, and the stepIndex property will drive the
progress bar in our UI.
Apple Docs:
ActivityAttributes— ActivityKit
Step 2: Creating the Widget Extension
Live Activities are rendered by a Widget Extension, not by the main app. Xcode provides a template that sets up most of the boilerplate.
- In Xcode, go to File > New > Target.
- Search for Widget Extension and select it.
- Name it PizzaPlanetLiveActivity.
- Uncheck “Include Configuration App Intent” — we don’t need widget configuration for this project.
- Make sure Include Live Activity is checked.
- Click Finish and activate the scheme when prompted.
Xcode generates a few files. We are going to replace most of the generated code, but first let’s understand the structure:
PizzaPlanetLiveActivityBundle.swift— the entry point for the widget extensionPizzaPlanetLiveActivityLiveActivity.swift— the Live Activity configuration (this is where our UI lives)
The DeliveryAttributes.swift file we created in Step 1 needs to be shared between the main app target and the widget
extension. Select DeliveryAttributes.swift in the project navigator, open the File Inspector, and under Target
Membership, check both PizzaPlanetTracker and PizzaPlanetLiveActivity.
Checkpoint: Build the project (Cmd+B). Both targets should compile without errors. If you see “Cannot find type ‘DeliveryAttributes’ in scope” in the widget extension, double-check that the file’s target membership includes both targets.
Step 3: Building the Lock Screen Live Activity View
The Lock Screen presentation is the most visible part of your Live Activity. It appears on the Lock Screen and in the Notification Center, giving users a rich, glanceable view of the activity’s state.
Open PizzaPlanetLiveActivityLiveActivity.swift and replace its entire contents with the following. We will start with
the Lock Screen view and add the Dynamic Island in the next step.
First, let’s create a helper view for the progress tracker. Add a new file ProgressTrackerView.swift to the widget
extension target:
import SwiftUI
import WidgetKit
struct ProgressTrackerView: View {
let currentStep: Int
let totalSteps: Int = 6
var body: some View {
HStack(spacing: 4) {
ForEach(0..<totalSteps, id: \.self) { step in
Capsule()
.fill(step <= currentStep ? Color.green : Color.gray.opacity(0.3))
.frame(height: 4)
}
}
}
}
Now open PizzaPlanetLiveActivityLiveActivity.swift and replace its contents:
import ActivityKit
import SwiftUI
import WidgetKit
struct PizzaPlanetLiveActivityLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: DeliveryAttributes.self) { context in
// Lock Screen / Notification Center presentation
LockScreenView(context: context)
.activityBackgroundTint(.black.opacity(0.8))
.activitySystemActionForegroundColor(.white)
} dynamicIsland: { context in
// We'll fill this in during Step 4
DynamicIsland {
DynamicIslandExpandedRegion(.leading) {
Text(context.attributes.pizzaName)
}
DynamicIslandExpandedRegion(.trailing) {
Text(context.state.status.emoji)
}
DynamicIslandExpandedRegion(.bottom) {
Text(context.state.status.rawValue)
}
} compactLeading: {
Image(systemName: "box.truck.fill")
} compactTrailing: {
Text(context.state.status.emoji)
} minimal: {
Image(systemName: "box.truck.fill")
}
}
}
}
Now let’s build the Lock Screen view. Create a new file LockScreenView.swift in the widget extension:
import ActivityKit
import SwiftUI
import WidgetKit
struct LockScreenView: View {
let context: ActivityViewContext<DeliveryAttributes>
var body: some View {
VStack(alignment: .leading, spacing: 12) {
// Header row
HStack {
Image(systemName: "box.truck.fill")
.font(.title2)
.foregroundStyle(.green)
VStack(alignment: .leading, spacing: 2) {
Text("Pizza Planet Delivery")
.font(.headline)
.foregroundStyle(.white)
Text("Order #\(context.attributes.orderNumber)")
.font(.caption)
.foregroundStyle(.gray)
}
Spacer()
// Estimated arrival
VStack(alignment: .trailing, spacing: 2) {
Text(context.state.estimatedArrival, style: .timer)
.font(.title2)
.fontWeight(.bold)
.foregroundStyle(.green)
.monospacedDigit()
Text("remaining")
.font(.caption2)
.foregroundStyle(.gray)
}
}
// Progress bar
ProgressTrackerView(currentStep: context.state.status.stepIndex)
// Status row
HStack {
Text("\(context.state.status.emoji) \(context.state.status.rawValue)")
.font(.subheadline)
.fontWeight(.medium)
.foregroundStyle(.white)
Spacer()
Text("Driver: \(context.state.driverName)")
.font(.caption)
.foregroundStyle(.gray)
}
// Pizza name
Text(context.attributes.pizzaName)
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(16)
}
}
The Lock Screen view uses a vertical stack with three sections: a header showing the order number and a live countdown
timer, a segmented progress bar, and a status row with the driver’s name. The
Text(_:style:) initializer with .timer
style creates a live-updating countdown that the system refreshes automatically — no manual timers needed.
Checkpoint: Build the project (Cmd+B). Everything should compile. We cannot see the Live Activity on screen yet because we haven’t started one from the app, but the widget extension is ready. If you see build errors, verify that
DeliveryAttributes.swiftbelongs to both targets.
Step 4: Building the Dynamic Island Layouts
The Dynamic Island has four presentation modes: compact leading, compact trailing, minimal, and expanded. The compact mode shows when your activity is the primary one on screen. The minimal mode appears when another activity takes priority. The expanded mode appears when the user long-presses the Dynamic Island.
Go back to PizzaPlanetLiveActivityLiveActivity.swift and replace the dynamicIsland closure with a fully designed
version:
} dynamicIsland: { context in
DynamicIsland {
// Expanded presentation — shown on long press
DynamicIslandExpandedRegion(.leading) {
VStack(alignment: .leading, spacing: 4) {
Label {
Text("Pizza Planet")
.font(.caption)
.fontWeight(.bold)
} icon: {
Image(systemName: "box.truck.fill")
}
.foregroundStyle(.green)
Text(context.attributes.pizzaName)
.font(.caption2)
.foregroundStyle(.secondary)
}
}
DynamicIslandExpandedRegion(.trailing) {
VStack(alignment: .trailing, spacing: 4) {
Text(context.state.estimatedArrival, style: .timer)
.font(.caption)
.fontWeight(.bold)
.monospacedDigit()
.foregroundStyle(.green)
Text("ETA")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
DynamicIslandExpandedRegion(.center) {
Text("\(context.state.status.emoji) \(context.state.status.rawValue)")
.font(.subheadline)
.fontWeight(.semibold)
.lineLimit(1)
}
DynamicIslandExpandedRegion(.bottom) {
VStack(spacing: 8) {
// Progress tracker
ProgressTrackerView(currentStep: context.state.status.stepIndex)
HStack {
Text("Order #\(context.attributes.orderNumber)")
.font(.caption2)
.foregroundStyle(.secondary)
Spacer()
Text("Driver: \(context.state.driverName)")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
} compactLeading: {
// Compact leading — truck icon with accent color
Image(systemName: "box.truck.fill")
.foregroundStyle(.green)
} compactTrailing: {
// Compact trailing — live countdown
Text(context.state.estimatedArrival, style: .timer)
.font(.caption)
.monospacedDigit()
.foregroundStyle(.green)
.frame(maxWidth: 48)
} minimal: {
// Minimal — just the truck icon
Image(systemName: "box.truck.fill")
.foregroundStyle(.green)
}
}
Let’s break down the design decisions:
- Compact leading shows the Pizza Planet truck icon so users instantly recognize the source.
- Compact trailing shows a live countdown timer — the most critical piece of information at a glance.
- Minimal shows just the truck icon, since space is extremely limited.
- Expanded shows the full picture: order name, status with emoji, progress bar, and driver info.
Tip: The Dynamic Island expanded view has a maximum height. Keep your content concise and test on device to make sure nothing gets clipped. Use
.lineLimit(1)generously to prevent layout overflow.Apple Docs:
DynamicIsland— WidgetKit
Step 5: Requesting a Live Activity from the App
Now we switch to the main app target. We need to build the logic that starts a Live Activity when the user places an
order. The
Activity.request
method is the entry point.
Create a new file Services/DeliveryManager.swift in the main app target:
import ActivityKit
import Foundation
import Observation
@Observable
class DeliveryManager {
var currentActivity: Activity<DeliveryAttributes>?
var currentStatus: DeliveryStatus = .placed
var errorMessage: String?
// The simulated delivery pipeline stages
private let statusPipeline: [DeliveryStatus] = [
.placed, .preparing, .inOven, .outForDelivery, .arriving, .delivered
]
func startDelivery(
orderNumber: String,
pizzaName: String,
customerName: String
) {
// Define the static attributes
let attributes = DeliveryAttributes(
orderNumber: orderNumber,
pizzaName: pizzaName,
customerName: customerName
)
// Define the initial dynamic state
let initialState = DeliveryAttributes.ContentState(
status: .placed,
estimatedArrival: Date().addingTimeInterval(30 * 60),
driverName: "Buzz Lightyear",
progressFraction: 0.0
)
let content = ActivityContent(
state: initialState,
staleDate: Date().addingTimeInterval(60 * 60)
)
do {
currentActivity = try Activity<DeliveryAttributes>.request(
attributes: attributes,
content: content,
pushType: nil // We'll add push support in Step 8
)
currentStatus = .placed
print("Live Activity started: \(currentActivity?.id ?? "unknown")")
} catch {
errorMessage = "Failed to start Live Activity: \(error.localizedDescription)"
print(errorMessage ?? "")
}
}
}
A few important details about this code:
- The
staleDatetells the system when the content should be considered outdated. If you don’t update the activity before this date, the system dims the presentation to signal that the data may not be current. - We set
pushType: nilfor now. In Step 8, we will change this to.tokento enable server-driven updates. - The
@Observablemacro (from the Observation framework) lets our SwiftUI views react automatically to changes in the manager’s properties.
Checkpoint: Build the project. The
DeliveryManagershould compile cleanly. If you see an error aboutActivitynot being found, make sure you haveimport ActivityKitat the top of the file and the deployment target is iOS 16.1 or later.
Step 6: Building the Order Screen UI
Now let’s build the main app UI. Users will see a fun Pizza Planet themed order screen where they can “place an order” to start the Live Activity.
Open ContentView.swift and replace its contents:
import SwiftUI
struct ContentView: View {
@State private var deliveryManager = DeliveryManager()
@State private var selectedPizza = "Pepperoni Nebula"
@State private var customerName = "Andy"
private let pizzaMenu = [
"Pepperoni Nebula",
"Woody's BBQ Ranch",
"Buzz's Cosmic Supreme",
"Rex's Veggie Volcano",
"Slinky Dog Margherita",
"Alien Pesto Special"
]
var body: some View {
NavigationStack {
VStack(spacing: 0) {
if deliveryManager.currentActivity == nil {
orderFormView
} else {
activeDeliveryView
}
}
.navigationTitle("Pizza Planet")
.toolbar {
ToolbarItem(placement: .principal) {
HStack(spacing: 6) {
Image(systemName: "airplane")
.rotationEffect(.degrees(-45))
.foregroundStyle(.green)
Text("Pizza Planet")
.fontWeight(.bold)
}
}
}
}
}
// MARK: - Order Form
private var orderFormView: some View {
ScrollView {
VStack(spacing: 24) {
// Hero section
VStack(spacing: 8) {
Text("🚀")
.font(.system(size: 60))
Text("Welcome to Pizza Planet!")
.font(.title2)
.fontWeight(.bold)
Text("Home of the best pizza in the galaxy")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.padding(.top, 20)
// Customer name field
VStack(alignment: .leading, spacing: 8) {
Text("Your Name")
.font(.headline)
TextField("Enter your name", text: $customerName)
.textFieldStyle(.roundedBorder)
}
.padding(.horizontal)
// Pizza selection
VStack(alignment: .leading, spacing: 12) {
Text("Select Your Pizza")
.font(.headline)
.padding(.horizontal)
ForEach(pizzaMenu, id: \.self) { pizza in
PizzaRow(
name: pizza,
isSelected: pizza == selectedPizza
)
.onTapGesture {
selectedPizza = pizza
}
}
}
// Order button
Button {
placeOrder()
} label: {
Label("Place Order", systemImage: "paperplane.fill")
.font(.headline)
.frame(maxWidth: .infinity)
.padding()
.background(.green)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.padding(.horizontal)
.padding(.bottom, 32)
}
}
}
// MARK: - Active Delivery
private var activeDeliveryView: some View {
ScrollView {
VStack(spacing: 24) {
Text(deliveryManager.currentStatus.emoji)
.font(.system(size: 80))
.padding(.top, 40)
Text(deliveryManager.currentStatus.rawValue)
.font(.title)
.fontWeight(.bold)
Text("Your \(selectedPizza) is on its way!")
.font(.subheadline)
.foregroundStyle(.secondary)
// Simulate next step button
if deliveryManager.currentStatus != .delivered {
Button("Simulate Next Status") {
advanceStatus()
}
.buttonStyle(.borderedProminent)
.tint(.green)
Button("Cancel Order", role: .destructive) {
Task {
await deliveryManager.cancelDelivery()
}
}
.buttonStyle(.bordered)
} else {
Button("Order Again") {
resetOrder()
}
.buttonStyle(.borderedProminent)
.tint(.blue)
}
}
.padding()
}
}
// MARK: - Actions
private func placeOrder() {
let orderNumber = String(
format: "%04d",
Int.random(in: 1000...9999)
)
deliveryManager.startDelivery(
orderNumber: orderNumber,
pizzaName: selectedPizza,
customerName: customerName
)
}
private func advanceStatus() {
Task {
await deliveryManager.advanceToNextStatus()
}
}
private func resetOrder() {
deliveryManager.currentActivity = nil
deliveryManager.currentStatus = .placed
}
}
Add the PizzaRow helper view in a new file Views/PizzaRow.swift:
import SwiftUI
struct PizzaRow: View {
let name: String
let isSelected: Bool
var body: some View {
HStack {
Image(systemName: "flame.fill")
.foregroundStyle(.orange)
Text(name)
.fontWeight(isSelected ? .semibold : .regular)
Spacer()
if isSelected {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
}
}
.padding()
.background(
RoundedRectangle(cornerRadius: 10)
.fill(isSelected ? Color.green.opacity(0.1) : Color.clear)
)
.padding(.horizontal)
}
}
Checkpoint: Build and run the app on a simulator (iPhone 15 Pro or later for Dynamic Island support). You should see the Pizza Planet order screen with a list of Pixar-themed pizzas. Tap “Place Order” and check that the Live Activity appears on the Lock Screen (swipe down to the Notification Center). The Dynamic Island should show the truck icon and a countdown timer. If the activity doesn’t appear, verify that
NSSupportsLiveActivitiesis set toYESin your Info.plist.
Step 7: Updating and Ending the Activity
A Live Activity that never changes isn’t very useful. In a real app, updates would come from your server (which we’ll configure in Step 8). For now, let’s implement local updates so we can test the full delivery pipeline.
Add the following methods to DeliveryManager.swift:
// Add these methods to the DeliveryManager class
func advanceToNextStatus() async {
let pipeline: [DeliveryStatus] = [
.placed, .preparing, .inOven,
.outForDelivery, .arriving, .delivered
]
guard let currentIndex = pipeline.firstIndex(of: currentStatus),
currentIndex + 1 < pipeline.count else {
return
}
let nextStatus = pipeline[currentIndex + 1]
let remainingSteps = pipeline.count - (currentIndex + 2)
let minutesPerStep = 5.0
let newState = DeliveryAttributes.ContentState(
status: nextStatus,
estimatedArrival: Date().addingTimeInterval(
Double(remainingSteps) * minutesPerStep * 60
),
driverName: "Buzz Lightyear",
progressFraction: Double(currentIndex + 1) / Double(pipeline.count - 1)
)
let content = ActivityContent(
state: newState,
staleDate: Date().addingTimeInterval(60 * 60)
)
if nextStatus == .delivered {
// End the activity with a final content update
await currentActivity?.end(
content,
dismissalPolicy: .after(
Date().addingTimeInterval(5 * 60)
)
)
} else {
await currentActivity?.update(content)
}
currentStatus = nextStatus
}
func cancelDelivery() async {
let finalState = DeliveryAttributes.ContentState(
status: .placed,
estimatedArrival: Date(),
driverName: "Buzz Lightyear",
progressFraction: 0.0
)
let content = ActivityContent(
state: finalState,
staleDate: nil
)
await currentActivity?.end(
content,
dismissalPolicy: .immediate
)
currentActivity = nil
currentStatus = .placed
}
Let’s examine the key API calls:
Activity.update(_:)pushes a newContentStateto the Live Activity. The system re-renders both the Lock Screen and Dynamic Island presentations with the new data.Activity.end(_:dismissalPolicy:)terminates the activity. The.after(_:)dismissal policy keeps the activity visible for a specified duration so the user can see the final state — perfect for showing the “Delivered” confirmation.- The
.immediatedismissal policy removes the activity from the Lock Screen right away, which we use for cancellations.
Checkpoint: Build and run the app. Place an order and then tap “Simulate Next Status” repeatedly. Watch the Lock Screen Live Activity update through each stage: Order Placed, Preparing Your Pizza, In the Oven, Out for Delivery, Almost There!, and finally Delivered. The progress bar should fill up segment by segment, and the countdown timer should decrease. When it reaches “Delivered,” the activity should remain visible for 5 minutes before disappearing.
Step 8: Adding Push Token Support for Server Updates
In production, your backend server sends updates to Live Activities via Apple Push Notification service (APNs). This is essential because your app may be suspended or terminated while the delivery is in progress.
The flow works like this: when you start a Live Activity with pushType: .token, the system provides a push token. You
send this token to your server, and your server sends APNs pushes to update the activity.
First, update the startDelivery method in DeliveryManager.swift to request a push token:
func startDelivery(
orderNumber: String,
pizzaName: String,
customerName: String
) {
let attributes = DeliveryAttributes(
orderNumber: orderNumber,
pizzaName: pizzaName,
customerName: customerName
)
let initialState = DeliveryAttributes.ContentState(
status: .placed,
estimatedArrival: Date().addingTimeInterval(30 * 60),
driverName: "Buzz Lightyear",
progressFraction: 0.0
)
let content = ActivityContent(
state: initialState,
staleDate: Date().addingTimeInterval(60 * 60)
)
do {
currentActivity = try Activity<DeliveryAttributes>.request(
attributes: attributes,
content: content,
pushType: .token // ← Changed from nil to .token
)
currentStatus = .placed
// Observe push token updates
Task {
guard let activity = currentActivity else { return }
for await tokenData in activity.pushTokenUpdates {
let token = tokenData.map {
String(format: "%02x", $0)
}.joined()
print("Push token: \(token)")
// In production, send this token to your backend
await sendTokenToServer(
token: token,
activityID: activity.id
)
}
}
print("Live Activity started: \(currentActivity?.id ?? "unknown")")
} catch {
errorMessage = "Failed to start Live Activity: \(error.localizedDescription)"
}
}
private func sendTokenToServer(
token: String,
activityID: String
) async {
// In production, this would be an API call to your backend.
// Your server stores the token and uses it to send APNs pushes.
print("Would send token \(token) for activity \(activityID) to server")
}
The pushTokenUpdates
property is an AsyncSequence that emits a new token whenever the system rotates it. You must always observe this
sequence and forward the latest token to your server.
For your backend to update the Live Activity, it sends an APNs push with a specific payload format. Here is what the APNs request looks like:
{
"aps": {
"timestamp": 1234567890,
"event": "update",
"content-state": {
"status": "outForDelivery",
"estimatedArrival": "2026-06-01T18:30:00Z",
"driverName": "Buzz Lightyear",
"progressFraction": 0.6
},
"alert": {
"title": "Pizza Planet Update",
"body": "Your pizza is out for delivery!"
}
}
}
The APNs headers must include:
apns-push-type: liveactivity
apns-topic: com.yourcompany.PizzaPlanetTracker.push-type.liveactivity
apns-priority: 10
Warning: The
content-statekeys in the APNs payload must exactly match theCodingKeysof yourContentStatestruct. A mismatch will cause the update to fail silently. Since we are using defaultCodablesynthesis, the keys match our property names.Note: You’ll need to configure your APNs authentication key in your Apple Developer account and set up a backend service (Node.js, Python, or any language with APNs support) to send these pushes. The full server setup is beyond the scope of this tutorial, but Apple’s Updating Live Activities with push notifications documentation covers the server-side requirements in detail.
Checkpoint: Build and run the app. Place a new order and check the Xcode console. You should see a push token printed — a long hex string like
a1b2c3d4e5f6.... This confirms that your app is correctly requesting push tokens from the system. The local simulation buttons from Step 7 still work — push tokens enable server updates but don’t replace local updates.
Step 9: Preparing for iOS 26 Multi-Platform Rendering
iOS 26 expands Live Activities beyond iPhone. They now render on CarPlay, in the macOS Tahoe menu bar, and on iPadOS 26. The good news: if you’ve built your Live Activity with standard SwiftUI views, most of the work is already done. The system adapts your layout for each form factor.
However, you can provide platform-specific customizations. Let’s add supplemental content for the expanded view and prepare size-aware layouts.
Update your LockScreenView.swift to be more adaptive:
import ActivityKit
import SwiftUI
import WidgetKit
struct LockScreenView: View {
let context: ActivityViewContext<DeliveryAttributes>
@Environment(\.activityFamily) var activityFamily
var body: some View {
switch activityFamily {
case .small:
// Compact layout for small presentations (e.g., StandBy)
compactLockScreenView
case .medium:
// Standard Lock Screen layout
standardLockScreenView
@unknown default:
standardLockScreenView
}
}
private var standardLockScreenView: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Image(systemName: "box.truck.fill")
.font(.title2)
.foregroundStyle(.green)
VStack(alignment: .leading, spacing: 2) {
Text("Pizza Planet Delivery")
.font(.headline)
.foregroundStyle(.white)
Text("Order #\(context.attributes.orderNumber)")
.font(.caption)
.foregroundStyle(.gray)
}
Spacer()
VStack(alignment: .trailing, spacing: 2) {
Text(context.state.estimatedArrival, style: .timer)
.font(.title2)
.fontWeight(.bold)
.foregroundStyle(.green)
.monospacedDigit()
Text("remaining")
.font(.caption2)
.foregroundStyle(.gray)
}
}
ProgressTrackerView(
currentStep: context.state.status.stepIndex
)
HStack {
Text("\(context.state.status.emoji) \(context.state.status.rawValue)")
.font(.subheadline)
.fontWeight(.medium)
.foregroundStyle(.white)
Spacer()
Text("Driver: \(context.state.driverName)")
.font(.caption)
.foregroundStyle(.gray)
}
Text("\(context.attributes.customerName)'s \(context.attributes.pizzaName)")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(16)
}
private var compactLockScreenView: some View {
HStack(spacing: 12) {
Image(systemName: "box.truck.fill")
.font(.title3)
.foregroundStyle(.green)
VStack(alignment: .leading, spacing: 2) {
Text(context.state.status.rawValue)
.font(.caption)
.fontWeight(.bold)
.foregroundStyle(.white)
Text(context.attributes.pizzaName)
.font(.caption2)
.foregroundStyle(.gray)
}
Spacer()
Text(context.state.estimatedArrival, style: .timer)
.font(.caption)
.fontWeight(.bold)
.monospacedDigit()
.foregroundStyle(.green)
}
.padding(12)
}
}
The activityFamily environment value tells you
which size class the system is rendering. On iOS 26, Live Activities on CarPlay and macOS may use a different family,
and your layout should adapt accordingly.
For macOS Tahoe menu bar support, add the supplementalActivityFamilies modifier to your widget configuration. Update
PizzaPlanetLiveActivityLiveActivity.swift:
struct PizzaPlanetLiveActivityLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: DeliveryAttributes.self) { context in
LockScreenView(context: context)
.activityBackgroundTint(.black.opacity(0.8))
.activitySystemActionForegroundColor(.white)
} dynamicIsland: { context in
// ... existing Dynamic Island code ...
}
.supplementalActivityFamilies([.small, .medium])
}
}
Note: The
.supplementalActivityFamiliesmodifier was introduced in iOS 26. If you need to support earlier iOS versions, wrap it in an#availablecheck or use conditional compilation with#if compiler(>=6.2).Apple Docs: Displaying live data on Apple Watch, Mac, CarPlay, and iPad — ActivityKit
Step 10: Polish and Final Touches
Let’s add a few finishing touches to make the Pizza Planet Delivery Tracker feel complete.
Adding Alert Configurations for Updates
When the delivery status changes, we can show a banner notification alongside the Live Activity update. This is especially useful for critical status changes like “Out for Delivery.”
Update the advanceToNextStatus() method in DeliveryManager.swift to include alert configurations:
func advanceToNextStatus() async {
let pipeline: [DeliveryStatus] = [
.placed, .preparing, .inOven,
.outForDelivery, .arriving, .delivered
]
guard let currentIndex = pipeline.firstIndex(of: currentStatus),
currentIndex + 1 < pipeline.count else {
return
}
let nextStatus = pipeline[currentIndex + 1]
let remainingSteps = pipeline.count - (currentIndex + 2)
let minutesPerStep = 5.0
let newState = DeliveryAttributes.ContentState(
status: nextStatus,
estimatedArrival: Date().addingTimeInterval(
Double(remainingSteps) * minutesPerStep * 60
),
driverName: "Buzz Lightyear",
progressFraction: Double(currentIndex + 1) / Double(pipeline.count - 1)
)
let content = ActivityContent(
state: newState,
staleDate: Date().addingTimeInterval(60 * 60)
)
// Configure alerts for important status changes
let alertConfig = AlertConfiguration(
title: "Pizza Planet Update",
body: "\(nextStatus.emoji) \(nextStatus.rawValue)",
sound: .default
)
if nextStatus == .delivered {
await currentActivity?.end(
content,
dismissalPolicy: .after(
Date().addingTimeInterval(5 * 60)
)
)
} else {
await currentActivity?.update(
content,
alertConfiguration: alertConfig
)
}
currentStatus = nextStatus
}
The AlertConfiguration displays a
notification banner when the update arrives. This works for both local and push-based updates.
Observing Activity State Changes
It’s important to know when the system ends your activity (for example, if the user swipes it away). Add an activity
state observer to DeliveryManager:
// Add this method to DeliveryManager
func observeActivityState() {
guard let activity = currentActivity else { return }
Task {
for await state in activity.activityStateUpdates {
switch state {
case .active:
print("Activity is active")
case .ended:
print("Activity ended")
await MainActor.run {
currentActivity = nil
currentStatus = .placed
}
case .dismissed:
print("Activity dismissed by user")
await MainActor.run {
currentActivity = nil
currentStatus = .placed
}
case .stale:
print("Activity content is stale")
@unknown default:
break
}
}
}
}
Call this method right after starting the activity in startDelivery:
// Add after: currentActivity = try Activity<DeliveryAttributes>.request(...)
observeActivityState()
Adding the Widget Bundle Entry Point
Make sure your widget extension’s bundle file includes the Live Activity. Open PizzaPlanetLiveActivityBundle.swift:
import SwiftUI
import WidgetKit
@main
struct PizzaPlanetLiveActivityBundle: WidgetBundle {
var body: some Widget {
PizzaPlanetLiveActivityLiveActivity()
}
}
Checkpoint: Build and run the final app on a simulator with Dynamic Island support (iPhone 15 Pro or later). Walk through the full flow:
- Launch the app and see the Pizza Planet order screen with Pixar-themed pizza names.
- Select “Buzz’s Cosmic Supreme” and tap “Place Order.”
- Lock the device — you should see the Live Activity on the Lock Screen with the order details, progress bar, and countdown timer.
- Tap “Simulate Next Status” and watch the Dynamic Island update with each stage.
- The activity should show “Delivered” at the end and dismiss itself after 5 minutes.
If the Live Activity does not appear, check the console for errors and verify that
NSSupportsLiveActivitiesis set correctly.
Where to Go From Here?
Congratulations! You’ve built Pizza Planet Delivery Tracker — a fully functional Live Activity app that displays real-time delivery status on the Lock Screen and Dynamic Island, supports local and push-based updates, and is ready for iOS 26 multi-platform rendering.
Here’s what you learned:
- How to define
ActivityAttributeswith static and dynamic content state - How to build Lock Screen and Dynamic Island layouts with four presentation modes (compact leading, compact trailing, minimal, and expanded)
- How to start, update, and end Live Activities from your app using
Activity.request,Activity.update, andActivity.end - How to configure push token support for server-driven updates via APNs
- How to add
AlertConfigurationfor notification banners on status changes - How to adapt Live Activity layouts for iOS 26 multi-platform support
Ideas for extending this project:
- Add a map view to the expanded Dynamic Island showing the driver’s real-time location using MapKit
- Implement a real backend with Node.js or Vapor to send APNs push updates
- Add haptic feedback when the status changes to
.arrivingusing Core Haptics - Support multiple concurrent orders using
Activity.activitiesto list all running activities - Add an Apple Watch complication that mirrors the delivery status using WidgetKit on watchOS