Live Activities in iOS: From Concept to Lock Screen
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
| Feature | Widgets | Live Activities |
|---|---|---|
| Updates | Scheduled timeline | Event-driven, real-time |
| Duration | Persistent | 8 hours max (+ 4 hours ended state) |
| Location | Home Screen, Today View | Lock Screen, Dynamic Island |
| Use case | At-a-glance info | Active, time-sensitive events |
Dynamic Island Regions
The Dynamic Island has three presentation modes:
- Compact: Small leading and trailing areas when multiple activities run
- Expanded: Full view when user long-presses or during transitions
- 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:
- Direct updates: Your app updates the activity while running
- Push notifications: Server-initiated updates via APNs with special headers
For push updates, use these APNs headers:
apns-topic: <bundle-id>.push-type.liveactivityapns-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
NSSupportsLiveActivitiesset toYESin 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
- Simulator limitations: Dynamic Island requires a physical device
- Use previews: SwiftUI Previews work for Lock Screen views
- Check Info.plist:
NSSupportsLiveActivitiesmust beYES
Common Gotchas
- Codable conformance:
ContentStatemust 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.