Skip to content
ExponaBLOG
SWIFT, SWIFTUI & IOS APPS · DAY 065 / 100

Live Activities: Swift & iOS best practices explained

Real-time updates on the Lock Screen.

Expona Admin8 min read

Introduction

Live activities bring real-time information to the Lock Screen and Dynamic Island, creating a new layer of user engagement in iOS apps. Whether you're building a delivery tracker, a sports score updater, or a fitness app, understanding Live Activities: Swift & iOS best practices explained is essential for modern app development. This guide walks you through ActivityKit implementation, push-based updates, Dynamic Island layouts, and proper cleanup patterns that we've refined across production e-commerce and delivery platforms.

Understanding ActivityKit and Live Activities

Live Activities are persistent, updatable UI elements that appear on the Lock Screen and can expand to fill the Dynamic Island on compatible devices. ActivityKit, introduced in iOS 16.1, is the framework that powers this functionality. Unlike regular notifications, Live Activities maintain state and update in real time, making them ideal for scenarios where users need constant awareness of ongoing processes.

The core lifecycle involves three phases: starting an activity, updating it multiple times, and ending it when complete. Each Live Activity requires an ActivityAttributes protocol that defines what data persists and what changes between updates. This separation matters because iOS needs to know which fields to display when the activity is locked, and which to refresh dynamically.

In delivery and ride-hailing platforms, we've seen dramatic improvements in engagement when users can track progress directly from the Lock Screen. Instead of opening the app repeatedly, customers glance at an always-visible status update. For sports apps, live score updates on the Lock Screen keep viewers informed without unlocking their device.

swift
import ActivityKit

struct DeliveryAttributes: ActivityAttributes {
    public struct ContentState: Codable, Hashable {
        var estimatedArrival: Date
        var status: String
        var driverLocation: String
    }
    
    var orderId: String
    var customerName: String
    var restaurantName: String
}

// Start a Live Activity
do {
    let initialState = DeliveryAttributes.ContentState(
        estimatedArrival: Date().addingTimeInterval(900),
        status: "Picked up",
        driverLocation: "Main St"
    )
    
    let attributes = DeliveryAttributes(
        orderId: "ORDER-12345",
        customerName: "Alice",
        restaurantName: "Pizza Palace"
    )
    
    let activity = try Activity.request(
        attributes: attributes,
        contentState: initialState,
        pushType: .token
    )
    
    print("Live Activity started: \(activity.id)")
} catch {
    print("Failed to start Live Activity: \(error)")
}

The pushType: .token parameter is crucial. It tells ActivityKit to generate a push token that your backend can use to send updates without requiring the app to be running. This is the mechanism that keeps your Lock Screen up-to-date even when the user is doing something else.

Updating Activities via Push Notifications

Once an activity is running, updates come through Activity Notification Push (a distinct channel from regular APNs). Your backend generates a push token immediately after starting the activity, then uses that token to send encrypted updates at the application level.

The payload structure for activity updates differs from standard push notifications. You send only the content state change, not the entire activity. iOS handles merging the update with the static attributes already cached on the device.

swift
// Example payload your backend sends
// (shown in pseudo-JSON for clarity)
{
  "aps": {
    "alert": {
      "dismissal-date": 1700000000
    }
  },
  "data": {
    "estimatedArrival": "2024-01-15T14:30:00Z",
    "status": "Arriving in 2 minutes",
    "driverLocation": "Oak Ave"
  }
}

// On the client, handle the update in your activity handler
// In your app target, add a handler method in AppDelegate or similar
if let activity = Activity<DeliveryAttributes>.all.first {
    let updatedState = DeliveryAttributes.ContentState(
        estimatedArrival: Date().addingTimeInterval(120),
        status: "Arriving in 2 minutes",
        driverLocation: "Oak Ave"
    )
    
    Task {
        await activity.update(using: updatedState)
    }
}

Push token handling requires careful orchestration. When you start an activity, you receive a token and must immediately send it to your backend. If the token is lost or the connection fails, your updates won't reach the user. We recommend storing this mapping (orderId → pushToken) in a database with retry logic and periodic validation.

Updates arrive within seconds in most cases, but network conditions and system load affect latency. For time-critical applications like ride-hailing, account for potential delays and use conservative estimates. Never rely on Live Activities as the sole source of truth for mission-critical operations.

Designing Dynamic Island Layouts

The Dynamic Island presents a unique challenge: the same Live Activity must adapt to three different display modes on capable devices, plus fall back to the Lock Screen view on older phones. SwiftUI's @ActivityPreviewProvider and layout containers make this manageable, but thoughtful design is essential.

The three Dynamic Island contexts are minimal (compact notch area), expanded (tappable full-screen card), and minimal alternate. Your views must remain simple and focused; the Dynamic Island is not a place for heavy interaction or complex layouts.

swift
struct DeliveryActivityWidget: Widget {
    let kind: String = "DeliveryActivityWidget"
    
    var body: some WidgetConfiguration {
        ActivityConfiguration(for: DeliveryAttributes.self) { context in
            // Lock Screen view
            VStack(alignment: .leading, spacing: 4) {
                HStack {
                    Text(context.attributes.restaurantName)
                        .font(.headline)
                    Spacer()
                    Text(context.state.status)
                        .font(.caption)
                }
                
                Text("\(context.state.driverLocation) • ETA \(context.state.estimatedArrival.formatted(date: .omitted, time: .shortened))")
                    .font(.caption)
                    .foregroundColor(.secondary)
            }
            .padding()
        } dynamicIsland: { context in
            DynamicIsland {
                // Expanded view
                DynamicIslandExpandedRegion(.leading) {
                    Label(context.attributes.restaurantName, systemImage: "bag.fill")
                        .font(.caption)
                }
                
                DynamicIslandExpandedRegion(.trailing) {
                    Text(context.state.status)
                        .font(.caption)
                        .fontWeight(.bold)
                }
                
                DynamicIslandExpandedRegion(.center) {
                    Text("Driver: \(context.state.driverLocation)")
                        .font(.caption)
                }
                
                DynamicIslandExpandedRegion(.bottom) {
                    ProgressView(
                        value: 0.65,
                        label: { Text("Arriving soon") }
                    )
                    .frame(maxWidth: .infinity)
                }
            } compactLeading: {
                // Minimal view (leading)
                Image(systemName: "bag.fill")
                    .foregroundColor(.blue)
            } compactTrailing: {
                // Minimal view (trailing)
                Text(context.state.status)
                    .font(.caption2)
            } minimal: {
                // Alternate minimal
                Image(systemName: "bag.fill")
            }
        }
    }
}

@main
struct DeliveryActivityWidgetBundle: WidgetBundle {
    var body: some Widget {
        DeliveryActivityWidget()
    }
}

A critical best practice: keep text concise and use SF Symbols consistently. The Dynamic Island has limited space, and dense layouts feel cramped. Prioritize the most important information—ETA and current status—and hide secondary details in the expanded view.

Interaction within the expanded Dynamic Island is limited; you can only present a deeplink when the user taps it. Use this to send them to the relevant screen in your app, never to a blank or unrelated view. If tapping the activity should show order details, launch the app to that specific order page.

Ending Activities When Complete

Cleanup is as important as startup. When a delivery arrives, a game ends, or a fitness session completes, you must explicitly end the Live Activity. Orphaned activities create clutter on the Lock Screen and waste system resources.

Activities can end in three ways: via local request from the app, via a push notification dismissal, or by iOS automatic timeout (typically 8 hours). For most e-commerce and delivery use cases, the app or backend initiates a clean end immediately upon completion.

swift
// End an activity from the app
Task {
    if let activity = Activity<DeliveryAttributes>.all.first(where: { $0.attributes.orderId == "ORDER-12345" }) {
        var finalState = activity.contentState
        finalState.status = "Delivered"
        
        await activity.end(using: finalState, dismissalPolicy: .immediate)
    }
}

// Or end all activities for a specific order
Task {
    for activity in Activity<DeliveryAttributes>.all {
        if activity.attributes.orderId == "ORDER-12345" {
            await activity.end(dismissalPolicy: .immediate)
        }
    }
}

The dismissalPolicy parameter controls whether the activity disappears immediately (.immediate) or after a short delay (.default, roughly 2 seconds). We typically use .immediate to keep the Lock Screen clean, but .default can work if you want to give the user a moment to notice the completion before it vanishes.

On the backend, if push-based updates are the mechanism, you can also end activities by sending a special push payload with a dismissal date in the past. This is useful when the app isn't running and you need immediate cleanup after an event like order completion or score finalization.

Common Mistakes

  • Losing the push token: If you don't store and transmit the activity push token to your backend immediately, updates will fail silently. Always capture and persist it.
  • Updating too frequently: Hammering the activity with updates every second drains the battery and overwhelms the system. Batch updates sensibly; for delivery, once per minute is often sufficient.
  • Ignoring privacy in content state: Remember that content state is visible on the Lock Screen even when the device is locked. Never include sensitive data like full credit card numbers or detailed personal information.
  • Over-complex Dynamic Island layouts: Trying to cram too much information into the Dynamic Island makes it unreadable. Stick to one or two key metrics and let the expanded view provide detail.
  • Forgetting to end activities: It's easy to assume an activity will expire on its own. Explicitly end it to keep the Lock Screen clean and free up resources.
  • Testing only on physical devices: The Simulator supports Live Activities from iOS 17+, but Dynamic Island behavior may differ. Test on real hardware if possible, especially for layout and timing validation.

Key Takeaways

  • Live Activities provide real-time Lock Screen updates; use ActivityKit to define persistent attributes and updating content state separately.
  • Start activities with pushType: .token and immediately transmit the push token to your backend for remote updates.
  • Design Dynamic Island views with simplicity in mind: minimal (compact) contexts should show one or two key facts; expanded contexts can provide more detail.
  • Always explicitly end activities when the user action completes; don't rely on timeouts for cleanup.
  • Never include sensitive data in content state; it's visible on the locked device.
  • Test push token generation, update delivery, and dismissal workflows end-to-end before shipping; these often surface integration bugs with backend systems.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles