Back to blog

Live Activities in iOS: From Concept to Lock Screen

·4 min read

Live Activities display real-time information on the Lock Screen and Dynamic Island. Here's how I implemented them for GPS track recording in TopographicNZ.

What Are Live Activities?

Live Activities are a way to display real-time, glanceable information from your app on the Lock Screen and Dynamic Island. Unlike widgets, which update on a schedule, Live Activities are event-driven and time-bound.

How They Differ From Widgets

FeatureWidgetsLive Activities
UpdatesScheduled timelineEvent-driven, real-time
DurationPersistent8 hours max (+ 4 hours ended state)
LocationHome Screen, Today ViewLock Screen, Dynamic Island
Use caseAt-a-glance infoActive, time-sensitive events

Dynamic Island Regions

The Dynamic Island has three presentation modes:

  1. Compact: Small leading and trailing areas when multiple activities run
  2. Expanded: Full view when user long-presses or during transitions
  3. Minimal: Tiny indicator when your activity isn't the primary one

For minimal presentation, keep images under 45×36.67 points. Larger images fail silently.

Update Mechanisms

Live Activities can be updated two ways:

  1. Direct updates: Your app updates the activity while running
  2. Push notifications: Server-initiated updates via APNs with special headers

For push updates, use these APNs headers:

  • apns-topic: <bundle-id>.push-type.liveactivity
  • apns-push-type: liveactivity

What You'll Build

A Live Activity showing:

  • Recording duration and distance
  • Current elevation
  • Navigation info (bearing, distance to waypoint)

Prerequisites

  • iOS 16.1+ target
  • Widget Extension added to your project
  • NSSupportsLiveActivities set to YES in Info.plist

Step 1: Define ActivityAttributes

Create a struct describing your activity's data:

// TrackRecordingAttributes.swift
import ActivityKit
 
struct TrackRecordingAttributes: ActivityAttributes {
    // Static data (doesn't change during activity)
    let trackName: String
 
    // Dynamic data (updates throughout)
    struct ContentState: Codable, Hashable {
        let duration: TimeInterval
        let distance: Double  // meters
        let elevation: Double
        let navigationTarget: String?
        let distanceToTarget: Double?
        let bearingToTarget: Double?
    }
}

Keep ContentState small. ActivityKit has payload limits, and smaller updates are faster.

Step 2: Create the Widget Extension

In your Widget Extension, define the activity configuration:

// TopographicNZWidgetsLiveActivity.swift
struct TrackRecordingActivity: Widget {
    var body: some WidgetConfiguration {
        ActivityConfiguration(for: TrackRecordingAttributes.self) { context in
            // Lock Screen view
            LockScreenView(context: context)
        } dynamicIsland: { context in
            DynamicIsland {
                DynamicIslandExpandedRegion(.leading) {
                    Label(formatDuration(context.state.duration),
                          systemImage: "timer")
                }
                DynamicIslandExpandedRegion(.trailing) {
                    Label(formatDistance(context.state.distance),
                          systemImage: "figure.walk")
                }
                DynamicIslandExpandedRegion(.bottom) {
                    if let target = context.state.navigationTarget {
                        NavigationRow(target: target,
                                     distance: context.state.distanceToTarget,
                                     bearing: context.state.bearingToTarget)
                    }
                }
            } compactLeading: {
                Image(systemName: "record.circle")
                    .foregroundColor(.red)
            } compactTrailing: {
                Text(formatDuration(context.state.duration))
                    .monospacedDigit()
            } minimal: {
                Image(systemName: "record.circle")
                    .foregroundColor(.red)
            }
        }
    }
}

Step 3: Start the Activity

In your main app, request the Live Activity:

// LiveActivityService.swift
@MainActor
class LiveActivityService {
    private var currentActivity: Activity<TrackRecordingAttributes>?
 
    func startRecording(trackName: String) throws {
        let attributes = TrackRecordingAttributes(trackName: trackName)
        let initialState = TrackRecordingAttributes.ContentState(
            duration: 0,
            distance: 0,
            elevation: 0,
            navigationTarget: nil,
            distanceToTarget: nil,
            bearingToTarget: nil
        )
 
        currentActivity = try Activity.request(
            attributes: attributes,
            content: .init(state: initialState, staleDate: nil)
        )
    }
}

Step 4: Update the Activity

Send updates as your data changes:

func updateRecording(duration: TimeInterval, distance: Double, elevation: Double) async {
    guard let activity = currentActivity else { return }
 
    let updatedState = TrackRecordingAttributes.ContentState(
        duration: duration,
        distance: distance,
        elevation: elevation,
        navigationTarget: navigationTarget,
        distanceToTarget: distanceToTarget,
        bearingToTarget: bearingToTarget
    )
 
    await activity.update(
        ActivityContent(state: updatedState, staleDate: nil)
    )
}

Don't update too frequently. Once per second is plenty for most use cases. More frequent updates drain battery.

Step 5: End the Activity

Clean up when recording stops:

func stopRecording() async {
    guard let activity = currentActivity else { return }
 
    let finalState = activity.content.state
    await activity.end(
        ActivityContent(state: finalState, staleDate: nil),
        dismissalPolicy: .default
    )
    currentActivity = nil
}

Testing Tips

  1. Simulator limitations: Dynamic Island requires a physical device
  2. Use previews: SwiftUI Previews work for Lock Screen views
  3. Check Info.plist: NSSupportsLiveActivities must be YES

Common Gotchas

  • Codable conformance: ContentState must be Codable and Hashable
  • Size limits: Keep content state under 4KB
  • Duration: Activities auto-end after 8 hours
  • Widget extension: Live Activities run in the extension process, not your app

Why Live Activities Matter

Real-world data shows significant engagement improvements:

  • 23.7% higher retention for apps using Live Activities
  • 50% more sessions for food delivery tracking apps
  • Uber reported 2.26% reduction in driver cancellations

Users appreciate glanceable status without opening the app.

Full Code

See the complete implementation in TopographicNZ on GitHub.

Share:

Related Posts

Quick tips for setting up comprehensive snapshot testing across devices and color schemes.

A comprehensive guide to parsing journaling suggestions, handling 11+ content types, and navigating Swift 6 concurrency challenges.

How to use SwiftData safely in Swift 6 with strict concurrency using the DataThespian wrapper.