With the release of iOS 16, Apple introduced Live Activities, and later with iPhone 14 Pro, the Dynamic Island—two powerful tools that allow us to present real-time, glanceable updates directly on the Lock Screen and at the top of the screen on the Dynamic Island. These features are designed to keep users informed about ongoing activities, like delivery tracking, live sports scores, or wait times, without requiring them to unlock their devices or open the app.
In this two-part guide, we’ll discuss everything you need to know to integrate Live Activities and Dynamic Island effectively in your iOS app. We'll detail each step from understanding design constraints to setting up a Live Activity, handling updates, and adding interactions.
What we're going to cover in this first part,
And, our second part will cover
To demonstrate these concepts, we’ll build a Live Activity that displays wait time. This will give users a real-time view of how long they’ll need to wait—perfect for restaurant queues or appointment scheduling scenarios.
Here's what will achieve at the end of this blog.
This blog is also available as a Youtube video, feel free to check it out.
Live Activities allow apps to display real-time information on the Lock Screen. These updates are perfect for tracking time-sensitive events like food delivery, ride-sharing ETAs, etc.
Dynamic Island extends this functionality by offering a compact, interactive display area. Users can see and interact with live updates at the top of their screens while using other apps, creating a nondisturbing way to stay informed on background activities like calls, music, and live events.
Before implementing Live Activities and Dynamic Island, it’s important to understand the design guidelines and presentation options available. Apple has specific constraints and recommendations to ensure that these features offer a consistent, user-friendly experience across apps.
Live Activities offers different presentation styles on the Lock Screen and Dynamic Island. Understanding these styles is crucial to creating a well-designed Live Activity that feels native to iOS.
On the Lock Screen, Live Activities appear as banners with detailed information. They provide a flexible area for displaying key data about your app’s real-time events, like estimated arrival, or live score updates.
The compact view is the most commonly seen view, typically displayed when the device is not actively engaged with ongoing tasks. It consists of two separate elements positioned on either side of the TrueDepth camera.
To ensure clarity, use consistent colors and typography for visibility, and avoid adding padding between the content and the camera to prevent misalignment.
Make sure that tapping on either the leading or trailing content opens the same scene in your app to provide a consistent and intuitive user experience.
The minimal presentation is used when multiple Live Activities are active simultaneously. In this state, your Live Activity is displayed as a small circle or oval, either on the Dynamic Island or detached from it. This presentation should focus on delivering the most essential and up-to-date information.
Instead of a logo, display live, dynamic content like a countdown or real-time update of the essential information, even if it means abbreviating details. When users tap on the minimal display, it should open the app to the relevant event or task for more details. If the users touch and hold the minimal Live Activity, users can access essential controls or view more content in the expanded presentation.
The expanded presentation appears when users tap and hold a compact or minimal Live Activity. This view gives more space for detailed information and interactions, while still keeping the smooth, rounded "capsule" shape.
Make sure the content expands in a way that feels natural and doesn’t cause confusion. If you need more vertical space, keep the edges curved to maintain a clean look. Avoid making the height awkward or uneven. Like the other presentations, keep your content aligned around the camera area to maintain a tidy and efficient layout.
To add Live Activity support to an iOS app, you can do the following:
Add a Widget Extension
Note - You can create a widget extension to use Live Activities without having to include widgets.
Here's what you'll have after following the above steps
Configure Info.plist
Info.plist
file in the primary target The ActivityAttributes
protocol allows you to define the data structure for a Live Activity in iOS, which provides users with real-time updates from your app directly on the lock screen or in Dynamic Island. The protocol's design enables you to separate the static information (which doesn’t change over time) from dynamic, real-time data.
In this example, we’ll look at the WaitTimeDemoAttributes
structure, which could be part of a waitlist management app. This implementation displays the user’s current position in the queue and their progress through the waitlist as dynamic data, while also including waitlist details such as the name of the waitlist and booking ID as static data.
Here’s how the WaitTimeDemoAttributes
structure is implemented:
import Foundation
import ActivityKit
struct WaitTimeDemoAttributes: ActivityAttributes {
public typealias WaitlistStatus = ContentState
public struct ContentState: Codable, Hashable {
var currentPositionInQueue: Int
var progress: Double
}
var waitlistName: String
var bookingId: String
}
The WaitTimeDemoLiveActivity
widget provides a customizable interface for displaying live updates about wait times in a compact and informative way. This widget is configured with ActivityConfiguration, allowing it to adapt its appearance across different device states like the lock screen and Dynamic Island, making it particularly useful for real-time information delivery in iOS 16 and later.
Here’s an in-depth look at each part of the WaitTimeDemoLiveActivity
implementation:
struct WaitTimeDemoLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: WaitTimeDemoAttributes.self) { context in
// Lock screen/banner UI goes here
VStack {
// Add components for the lock screen UI here
}
.activityBackgroundTint(Color.cyan)
.activitySystemActionForegroundColor(Color.black)
}
....
ActivityConfiguration
to display a Live Activity for WaitTimeDemoAttributes
. This configuration defines the widget’s layout when viewed on the lock screen or as a banner notification. dynamicIsland: { context in
DynamicIsland {
// Expanded UI goes here. Compose the expanded UI through
// various regions, like leading/trailing/center/bottom
DynamicIslandExpandedRegion(.leading) {
Text("Leading")
}
DynamicIslandExpandedRegion(.trailing) {
Text("Trailing")
}
DynamicIslandExpandedRegion(.bottom) {
// expanded content
}
DynamicIslandExpandedRegion(.center) {
// center content
}
}
....
Dynamic Island Configuration: The dynamicIsland
block defines the layout for Dynamic Island, available on supported iPhones. Within the DynamicIsland
view, you can divide the content into different regions:
The expanded view of Dynamic Island has four main areas:
compactLeading: {
Text("L")
} compactTrailing: {
Text("T")
} minimal: {
Text("M")
}
.widgetURL(URL(string: "http://www.apple.com"))
.keylineTint(Color.red)
}
}
}
compactLeading
and compactTrailing
: These define content in the compact Dynamic Island state, such as a single letter or icon.minimal
: This represents the smallest possible view in the Dynamic Island, useful for showing very minimal information.Note: Make sure your layout follows the design guidelines, for details information, check Live Activity Specification
Now, let's design the layout for lock screen or banner
ActivityConfiguration(for: WaitTimeDemoAttributes.self) { context in
// Lock screen/banner UI
LiveActivityContent(progress: context.state.progress,
position: context.state.position,
waitlistName: context.attributes.waitlistName)
}
....
Now Let's see LiveActivityContent
struct LiveActivityContent : View {
let progress: Double
let position: Int
let waitlistName: String
var body: some View {
VStack {
HStack {
Text(waitlistName).font(.system(size: 13))
.fontWeight(.medium).foregroundColor(Color("TextSecondary")).lineLimit(1)
Spacer()
AppLogo(size: 24)
}
HStack {
VStack(alignment: .leading) {
QueuePostion(position: position)
HorizontalProgressBar(level: progress).frame(height: 8)
}
Spacer()
QueueIllustration(position: position)
}
}.padding(16)
.activityBackgroundTint(Color("WidgetBackground"))
.activitySystemActionForegroundColor(Color.black)
}
}
struct HorizontalProgressBar: View {
var level: Double
var body: some View {
GeometryReader { geometry in
let frame = geometry.frame(in: .local)
let boxWidth = frame.width * level
RoundedRectangle(cornerRadius: 20)
.foregroundColor(Color("ContainerHigh"))
RoundedRectangle(cornerRadius: 20)
.frame(width: boxWidth)
.foregroundColor(Color("PrimaryColor"))
}
}
}
struct QueuePostion: View {
let position: Int
var body: some View {
VStack(alignment: .leading) {
HStack(alignment: .firstTextBaseline) {
Text("\(position)")
.font(.system(size: 36, weight: .semibold)).lineSpacing(48).foregroundColor(Color("TextPrimary"))
Text(" is your current")
.font(.system(size: 18, weight: .semibold))
.lineSpacing(26).foregroundColor(Color("TextPrimary"))
}
Text("position in the queue")
.font(.system(size: 18, weight: .semibold)).foregroundColor(Color("TextPrimary"))
}
}
}
struct QueueIllustration :View {
let position: Int
var imageName: String {
if position < 5 {
return "queue4"
} else if position < 9 {
return "queue3"
} else if position < 25 {
return "queue2"
} else {
return "queue1"
}
}
var body: some View {
Image(uiImage: UIImage(named: imageName)!)
.resizable().frame(width: 100, height: 100)
.scaledToFit()
}
}
struct AppLogo :View {
let size: CGFloat
var body: some View {
Image(uiImage: UIImage(named: "AppLogo")!)
.resizable().frame(width: size, height: size)
.scaledToFit()
.clipShape(.circle)
}
}
here's the preview to check the layout
#Preview("LockScreen", as: .content, using: WaitTimeDemoAttributes.preview) {
WaitTimeDemoLiveActivity()
} contentStates: {
for i in stride(from: 10, through: 1, by: -1) {
let progress = (10 - Double(i)) / 10.0
WaitTimeDemoAttributes.ContentState(currentPositionInQueue: i, progress: progress)
}
}
Now let's see the preview of our lock screen presentation
In the compactLeading
position, we'll display our demo app's logo, and in compactTrailing
, we'll show a circular progress bar that reflects the user's position in the queue.
dynamicIsland: { context in
DynamicIsland {
// Extended presentation goes here
} compactLeading: {
AppLogo(size: 24)
} compactTrailing: {
MinimalProgressBar(progress: context.state.progress,
currentPositionInQueue: context.state.currentPositionInQueue,
size: 24)
} minimal: {
MinimalProgressBar(progress: context.state.progress,
currentPositionInQueue: context.state.currentPositionInQueue,
size: 24)
}
}
struct MinimalProgressBar: View {
let progress: Double
let currentPositionInQueue: Int
let size: CGFloat
var body: some View {
ProgressView(value: progress, total: 1) {
Text("\(currentPositionInQueue)")
}.frame(width: size, height: size)
.progressViewStyle(.circular)
.tint(Color("PrimaryColor"))
}
}
struct AppLogo :View {
let size: CGFloat
var body: some View {
Image(uiImage: UIImage(named: "AppLogo")!)
.resizable().frame(width: size, height: size)
.scaledToFit()
.clipShape(.circle)
}
}
and here is a preview of our compact and minimal presentation
When the user taps and holds the compact/minimal presentation, the system shows detailed information about ongoing activity in this presentation. For minimal content, keep the expanded shape short, maintaining a capsule-like form with smooth, continuous curves. For more content, use a taller, rectangular shape with rounded edges.
Wrap content tightly around the TrueDepth camera, avoiding empty space on the leading and trailing sides and below it. In our demo we have minimal content, so we'll use the leading, trailing and center space only.
@DynamicIslandExpandedContentBuilder
private func expandedContent(waitlistName:String,
contentState: WaitTimeDemoAttributes.ContentState) -> DynamicIslandExpandedContent<some View> {
DynamicIslandExpandedRegion(.leading) {
AppLogo(size: 48)
}
DynamicIslandExpandedRegion(.trailing) {
MinimalProgressBar(progress: contentState.progress,
currentPositionInQueue: contentState.currentPositionInQueue,
size: 48)
}
DynamicIslandExpandedRegion(.center) {
Text("Your position in queue")
.font(.system(size: 12, weight: .medium)).foregroundColor(Color("TextPrimary"))
}
}
dynamicIsland: { context in
DynamicIsland {
expandedContent(
waitlistName: context.attributes.waitlistName,
contentState: context.state
)
} compactLeading: {
....
}
...
}
Now that we've completed all the layout design, it's time to integrate Live activities, but before that, keep the following points in mind:
areActivitiesEnabled
before presenting the option to start a Live Activity in your app. This ensures that the feature is supported on the user's device..immediate
.Manually handling the states—start, update, and end—for a live activity allows us to provide timely, interactive updates directly within the app. While push notifications (APNs) are great for sending real-time updates externally, there are cases where an in-app trigger is more efficient. This is particularly useful in scenarios where instant feedback is critical, like appointment tracking or live sports scores, and allows the app to stay responsive even when push notifications may be delayed or limited.
For example, if a user initiates an action like joining a waitlist, we can immediately start the live activity without relying on server communication. Similarly, if conditions change (e.g., wait time adjustments or completion), updating or ending the activity manually ensures the user sees the latest information instantly.
To keep this demo simple, we'll add three buttons to start, update and end the activity.
import SwiftUI
struct LiveActivityDemoView: View {
@State private var waitlistName: String = ""
@State private var queuePosition: Int = 0
@State private var progress: Double = 0.0
var body: some View {
VStack(spacing: 20) {
Text("Live Activity Demo")
.font(.title)
.padding(.top, 40)
// Text Field for Waitlist Name
TextField("Enter Waitlist Name", text: $waitlistName)
.textFieldStyle(RoundedBorderTextFieldStyle())
.padding(.horizontal, 20)
// Text Field for Queue Position
TextField("Enter Queue Position", value: $queuePosition, formatter: NumberFormatter())
.textFieldStyle(RoundedBorderTextFieldStyle())
.padding(.horizontal, 20)
.keyboardType(.numberPad)
// Text Field for Progress
TextField("Enter Progress (0.0 to 1.0)", value: $progress, formatter: NumberFormatter())
.textFieldStyle(RoundedBorderTextFieldStyle())
.padding(.horizontal, 20)
.keyboardType(.decimalPad)
// Start Button
Button(action: {
// Action to start the live activity will go here
}) {
Text("Start Activity")
.font(.headline)
.foregroundColor(.white)
.padding()
.frame(maxWidth: .infinity)
.background(Color.blue)
.cornerRadius(10)
}
.padding(.horizontal, 20)
// Update Button
Button(action: {
// Action to update the live activity will go here
}) {
Text("Update Activity")
.font(.headline)
.foregroundColor(.white)
.padding()
.frame(maxWidth: .infinity)
.background(Color.orange)
.cornerRadius(10)
}
.padding(.horizontal, 20)
// End Button
Button(action: {
// Action to end the live activity will go here
}) {
Text("End Activity")
.font(.headline)
.foregroundColor(.white)
.padding()
.frame(maxWidth: .infinity)
.background(Color.red)
.cornerRadius(10)
}
.padding(.horizontal, 20)
Spacer()
}
.padding()
}
}
Great! We’ll create a LiveActivityManager
class to manage the activity's lifecycle.
import Foundation
import ActivityKit
class LiveActivityManager {
static let shared = LiveActivityManager()
private var activity: Activity<WaitTimeDemoAttributes>?
private init() {}
func startActivity(waitlistName: String, positionInQueue: Int, progress: Double) {
}
func updateActivity(positionInQueue: Int, progress: Double) {
}
func endActivity(positionInQueue: Int, progress: Double) {
}
}
To start the activity we'll use the Activity.request API:
func startActivity(waitlistName: String, positionInQueue: Int, progress: Double) {
if ActivityAuthorizationInfo().areActivitiesEnabled {
// Define the initial state and attributes
let attributes = WaitTimeDemoAttributes(waitlistName: waitlistName, bookingId: "1234")
let initialContentState = WaitTimeDemoAttributes.WaitlistStatus(
currentPositionInQueue: positionInQueue,
progress: progress
)
// Start the live activity
do {
let activity = try Activity<WaitTimeDemoAttributes>.request(
attributes: attributes,
content: .init(state: initialContentState, staleDate: nil),
pushType: nil // No pushType since we're handling it in-app for now
)
self.activity = activity
print("Live activity started: \(activity.id)")
} catch {
print("Failed to start live activity: \(error)")
}
}
}
Here's a breakdown of each parameter:
staleDate
parameter can specify when the data becomes outdated, but here we set it to nil to let it stay relevant indefinitely.This setup allows us to create a Live Activity that’s manageable in-app, without relying on server push notifications for updates.
After you start a Live Activity, you can update its content using the update(_:)
function of the Activity object you created.
Note: The system will ignore updates to a Live Activity that has already ended (i.e., in the ended state). So, you can only update a Live Activity that is still active.
func updateActivity(queuePosition: Int, progress: Double) {
guard let activity = activity else { return }
let updatedContentState = WaitTimeDemoAttributes.ContentState(currentPositionInQueue: queuePosition, progress: progress)
Task {
await activity.update(using: updatedContentState)
print("Live Activity updated with queue position: \(queuePosition) and progress: \(progress).")
}
}
Button(action: {
LiveActivityManager.shared.updateActivity(queuePosition: queuePosition, progress: progress)
}) {
...
}.padding(.horizontal, 20)
Now let's run the app, and check the output
To notify the user about important updates, you can use the update(_:alertConfiguration:)
function.
In our wait time example, you can use the update(_:alertConfiguration:)
function to notify the user about important updates in the Live Activity. For instance, if the wait time is updated to a much shorter duration, you could trigger an alert to let the user know that their position in the queue is moving faster. This will notify the user immediately, ensuring they don't miss any critical changes, like the fact that their table is now ready or they’re nearing the top of the list.
func updateActivity(queuePosition: Int, progress: Double, alert: Bool) {
guard let activity = activity else { return }
let updatedContentState = WaitTimeDemoAttributes.ContentState(currentPositionInQueue: queuePosition, progress: progress)
Task {
// Only create alertConfig if alert is true
var alertConfig: AlertConfiguration? = nil
if alert {
let waitlistName = activity.attributes.waitlistName
let position = activity.content.state.currentPositionInQueue
// Configure the alert if the waitlist status changes significantly
alertConfig = AlertConfiguration(
title: "You're next in line!",
body: "Your table at \(waitlistName) is almost ready. Position: \(position).",
sound: .default
)
}
await activity.update(ActivityContent(state: updatedContentState, staleDate: nil), alertConfiguration: alertConfig)
print("Live Activity updated with queue position: \(queuePosition) and progress: \(progress).")
}
}
To end a Live Activity, we'll use the end()
function on the Activity object. Ending an activity signals to the system that the Live Activity has concluded, and it will no longer be updated or shown on the Lock Screen or the Dynamic Island.
Here’s when you would typically end a Live Activity:
You should ensure that the Live Activity is ended once the purpose of the activity has been fulfilled.
func endActivity() {
guard let activity = activity else { return }
let endContent = WaitTimeDemoAttributes.ContentState(currentPositionInQueue: 1, progress: 1.0)
Task {
await activity.end(ActivityContent(state: endContent, staleDate: nil), dismissalPolicy: .immediate)
print("Live Activity ended.")
}
}
Button(action: {
LiveActivityManager.shared.endActivity()
}) {
.....
}.padding(.horizontal, 20)
.immediate
for the dismissal policy.after(_:)
. By default, the system will keep it for up to four hours after it ends before removing it automaticallyIn the first part, we covered the essentials—from understanding Live Activities and Dynamic Island to setting up, updating, and ending an activity. In the upcoming second part, we’ll dive deeper into advanced topics like animating state changes, managing updates through push notifications, and adding interactive elements like buttons and toggles.
Stay tuned for Part 2, where we’ll bring everything together and create an even more immersive experience!