How to build an iOS Live Activity

Quick Guide on How to Add a Live Activity to Your App

Marco Guerrieri
Kin + Carta Created

--

INTRO

One of the most useful new things introduced in iOS 16 are definitely the Live Activities, this feature allows users to have real-time information on their Lock Screen to be constantly up-to-date with what is happening in real time, for example a sports game live score, or the current status of an home delivery order.

I created a repository where you can find all the code (maybe even more updated) that I used in this article, and you can clone it from here:

HOW IT WORKS

The Live Activities can be thought of as a widget that is updated in real time. This update is triggered using a specific function in the code or a particular push notification sent to the device from the backend service.

Once the Live Activity has been started, it will be displayed as said in the lock screen, and upon receiving an update push notification, it will be automatically refreshed. But there is more, in fact, when present on the device, it will be shown also in the Dynamic Island area during the normal usage, and in this case the Live Activity can have actually 3 different UI:

  • Minimal: our live activity is secondary shown respect another one
  • Compact: our live activity is the main one shown to the user
  • Expanded: our live activity has been expanded by the user with a long press or it has just received an update and so it has been automatically expanded by the system

So, considering also the widget in the lock screen, we should think about designing 4 kinds of Live Activity UI.

HOW TO START

First thing, let’s make our app supporting Live Activities, to do so you need to go into the Info part of your main target and add the “Supports Live Activities” setting it to YES.

Another thing to add to the app to make this sample code work, is adding the push notification capabilities in the Signin & Capabilities tab.

NOTE: This isn’t actually always necessary, as said a Live Activity can be updated through push notifications or just via code, but for the sake of this guide, I’m already showing how to add the capability to use the notification here. Later on it will be more clear when you should or should not add it.

After this, to actually add all the files necessary to build a Live Activity into the project, you need to add the proper target:

File -> New -> Target

Then select “Widget extension”:

And make sure to check the “Include Live Activity” checkbox, then proceed and activate it. In my example the name of the Widget target will be “MatchLiveScore”.

File -> New -> Target -> Widget extension

This will add a target in your project that actually can be used both for creating a normal Widget and a Live Activity. Once the target is created you can see different files automatically generated:

MatchLiveScore:

This is actually the normal iOS Widget class, so we don’t bother about it, we are not doing any normal widget in this article, but if you want to know about how to build a proper widget, here is my guide written some time ago about it:

  • MatchLiveScoreLiveActivity:

This is our Live Activity file where we are going to build our various UI. As you can see there is also another struct defined in this file, MatchLiveScoreAttributes, that it’s something that we could easily move into its own file.

  • MatchLiveScoreAttributes:

This is basically the struct that defines the various properties that we are going to use inside the Live Activity UI. Part of it is to be considered the “static” properties of the live activity, so the property that will not change during the real time update (in this case the var name: String), the other part, inside the ContentState struct, has to be considered the container of the “dynamic” properties of the activity that will be updated in real time (in this case the var value: Int)

  • MatchLiveScoreBundle:

Here you can decide what your app actually will make available to the user, as you can see there are two structs that are initialised here:

  • MatchLiveScore(): this is the normal widget, the one that you can implement inside the homonym file MatchLiveScore.swift
  • MatchLiveScoreActivity(): this is the live activity widget that we are going to implement in our app.

Removing here the MatchLiveScore() line will basically just not show the normal home screen widget to the user, that basically will not be able to find any widget of our app in the “Add widget” screen. Obviously for this article I would say that it’s totally fine to leave just the line MatchLiveScoreActivity() in the body code.

ATTRIBUTES

As said, the MatchLiveScoreAttributes is the struct that we use to define the properties that will be shown in our activity, those properties are of 2 different kinds, one is static, so once a Live Activity is created and shown that properties are going to have a static value, the other kind of properties are inside the sub-struct ContentState and are the ones that can be modified and so that will reflect the “live” updates of the activity. To give an example with a sport match, we can create a live activity with static values that could be the name of the teams, the stadium, the date of the match, cause those values will never change during the match itself, while we can use the dynamic properties to set information about the current match score, or the last event occurred in the match (a player who scored a goal or has been booked by the referee).

These dynamic properties are basically the ones that will be passed in the push notification to make the activity live update automatically.

Let’s make an example to have it clearer, we define some static properties:

var homeTeam: String
var awayTeam: String
var date: String

And some dynamic ones:

var homeTeamScore: Int
var awayTeamScore: Int
var lastEvent: String

The final attribute struct should be then this one:

So when we will start a Live Activity, we will already know the name of the teams and the date of the match, and we know those properties will never change during this particular live score, so those are constant. The score of each team and the last event occurring will obviously mutate through the game, so those are dynamic and will be updated in the future.

Now let’s use those values in the activity, so that we will show to the user all the information needed, both the static and dynamic ones. As said we are going to build different layouts for each Live Activity UI shown to the user:

Also let’s update the preview part of the View like this:

As you can see, in the preview we are actually creating a mock MatchLiveScoreAttributes and its MatchLiveScoreAttributes.ContentState to show data inside the layout assistant. Once all is set, the layout result of that code should be this one:

Lock Screen
Dynamic Island Expanded
Dynamic Island Contracted
Dynamic Island Minimal

As you can see, all the different layouts that a live activity can be displayed have now their own UI.

START AND MANAGE AN ACTIVITY

To create and manage a live activity we will need to write some code.
To make it easy for us to instantiate and manage a live activity, I made a class that is responsible for the live activity management.

In this class we have two class variables that can be observed:

  • activityId: the identifier of the activity that its generated once the activity is created (note that actually you can have multiple running activities in your app, but for this example we are going to basically always have just one)
  • activityToken: the token generated for the current activity, used in the backend for creating the activity-update push notification

Then we have various functions that we use to actually manage the activity:

  • start(): cancel all running activities and then start a new one
  • startNewLiveActivity(): actually request the initialisation and the start of a new activity, passing the the initial properties values, and obtaining its activityID and activityToken
  • updateActivityRandomly(): where, for sake of code-update example, the current running activity is updated with some random values
  • endActivity(): that find in the running activities (of the specified type MatchLiveScoreAttributes) the one with the activityID that we stored in the manager and end it, so that means it will not be shown anymore in the dynamic island and in the lock screen
  • cancelAllRunningActivities(): that run through all the current running activities (of the specified type MatchLiveScoreAttributes) and ends it all

Let’s see in the detail how we can start, update and end an activity.

Start Activity:

 
let attributes = MatchLiveScoreAttributes(homeTeam: "Badger",
awayTeam: "Lion",
date: "12/09/2023")

let contentState = MatchLiveScoreAttributes.ContentState(homeTeamScore: 0,
awayTeamScore: 0,
lastEvent: "Match Start")

let content = ActivityContent(state: contentState,
staleDate: nil,
relevanceScore: 0)
let activity = try? Activity.request(
attributes: attributes,
content: content,
pushType: .token
)

To create an Activity first we need to define the attributes (as said, the static values) and the initial content (the dynamic values) of it.

In our example so we create a MatchLiveScoreAttributes , passing the value for all the parameters we have defined as static values, and an ActivityContent that is instantiated passing various parameters:

  • state: the initial ContentState (in our example a MatchLiveScoreAttributes.ContentState ) for the live activity
  • staleDate: the date that indicates to the system when the live activity must be considered outdated. If a nil value is passed asstaleDate, by default it will be considered outdated after 8 hours
  • relevanceScore: it’s the priority that the live activity must have in the dynamic island, and also the order it will have in the lock screen.

To actually start the activity then we have to use the Activity.request(attributes:, content:, pushType:) method. This is an async throwable method that needs 3 parameters in input:

  • attributes: an instance of the ActivityAttributes, in our case an instance of MatchLiveScoreAttributes
  • content: an instance of the ActivityAttributes.ContentState, in our case an instance of MatchLiveScoreAttributes.ContentState
  • pushType: Indicates if the updates of the Live Activity will be from push notifications (passing .token) or if we only want to update the Live Activity using the update function (passing nil)

Once we call this method, the system will try to instantiate and show the Activity on the device, and in case of success, we will be able to obtain the activity identifier and the activity token etc.

guard let activity = activity else { return }
print("ACTIVITY IDENTIFIER:\n\(activity.id)")

for await data in activity.pushTokenUpdates {
let token = data.map {String(format: "%02x", $0)}.joined()
print("ACTIVITY TOKEN:\n\(token)")
}

Update Activity:

let contentState = MatchLiveScoreAttributes.ContentState(homeTeamScore: 1,
awayTeamScore: 3,
lastEvent: "Match just updated")

let content = ActivityContent(state: contentState,
staleDate: nil,
relevanceScore: 0)

await activity.update(using: newContentState,
alertConfiguration: AlertConfiguration(title: "Title",
body: "Body",
sound: .default))

Whenever we want to update our Live Activity, we have to call the update(_:, alertConfiguration:) function. In the same way that we created our initial state, we have to create an ActivityContent with the new updated values that we want to pass to the current running activity, and pass it to the update function.

There is also an additional parameter that we can pass to the function, the alertConfiguration , that if not nil , will shown an alert with the update in this way:

  • Device with dynamic island: it will be shown in the dynamic island region with the expanded activity layout
  • Device without dynamic island: it will be shown on the lock screen as a banner presentation

End Activity:

let contentState = MatchLiveScoreAttributes.ContentState(homeTeamScore: 3,
awayTeamScore: 4,
lastEvent: "Match is finished")

let content = ActivityContent(state: contentState,
staleDate: nil,
relevanceScore: 0)

await activity.end(
ActivityContent(state: contentState,
staleDate: Date.distantFuture),
dismissalPolicy: .immediate
)

If we have a running activity and we just want to end it, we have instead to call the end(_:, dismissalPolicy:) function.

As per the other functions above, we need to pass anActivityContent and a dismissalPolicy, but why a content if we are going to end the activity? Just because the activity can end but it can also remain visible on the lock screen, so the content that we are passing it’s the one that will be shown to the user in this particular case, and the responsibility of deciding if the activity must remain visible or not it’s actually the dismissalPolicy parameter. This policy can have 3 different values:

  1. default: the activity will remain visible in the lock screen up to 4 hours or until the user removes it
  2. immediate: the activity is immediately complete removed, so the content state in this case would be useless
  3. after(_ date:) the activity will be removed at the time that we are defining in here, but only if the date is before 4 hours since the activity has ended, or the system will remove after that amount of time

LET’S RUN IT

Now that all is set in place, we can run the app and see our widget in action, but to do so, we have obviously to make it possible to actually start the live activity somehow, to do so, let’s just go on our main View of the app, that by default is ContentView.swift, and let’s add some code:

As you can see in here we have a pointer to the shared ActivityManager instance that we use to actually start or stop the Live Activity, and at the same time we use it as an observable object to know if an activity is actually running, its ID and its token (that we should use to update it thorugh the push notifications as I am going to explain in the next chapter).

Everything now should work as expected, running the app we should see the screen without any ID or Token, once we start the Live Activity the screen should update with the ID, Token and also a new UI to stop or randomly update the live activity, and of course we will be able to see our activity in the lock screen and in the dynamic island.

UPDATE THE LIVE ACTIVITY WITH PUSH NOTIFICATIONS

Ok everything works but how to use push notifications to update the activity and make more real time?
To do so we need to have stored in the backend service responsible for the push notifications two different tokens:

  • device token: the token used to send normal push notifications, retrieved in this article code sample in the AppDelegate
  • activity token: retrieved in this article in the ActivityManager class, inside the startNewLiveActivity() function and its a unique token generated for any activity we started

To explain better how it should work on the device side, these are the main steps needed to make a live activity working in a proper way with the push notifications:

  • Gather general push notification token for the current device and send it to the backend
  • Create and start the live activity
  • Gather the unique token for the live activity just started and send it to the backend
  • Receive proper push notifications that will update the activity UI (automatically managed by the OS)

The push notification will have some difference between the normal one and this one used to update the activity, first thing the header will be something like this:

{
":method": "POST",
"apns-topic": "com.YOUR_APP_BUNDLE_IDENTIFIER.push-type.liveactivity",
"apns-push-type": "liveactivity",
":scheme": "https",
":path": "/3/device/${DEVICE_TOKEN}",
"authorization": "bearer ${ACTIVITY_TOKEN}"
}

NOTE: as you can see, in the header part we are using the ${DEVICE_TOKEN} and the ${ACTIVITY_TOKEN} we have written above.

While the payload will be specifically reflecting the ContentState of the activity, so in our case the MatchLiveScoreAttributes.ContentState struct, for example:

{
"aps": {
"alert" : {
"title" : "MG Live Activity",
"subtitle" : "A push notification update",
"body" : "Goal scored!"
},
"timestamp": 1694375148,
"event": "update",
"content-state": {
"homeTeamScore": 1,
"awayTeamScore": 2,
"lastEvent": "Goal scored!"
},
}
}

NOTE: the timestamp should be calculated when the payload is created

As you can see, the content-state has inside it the 3 parameters that we have defined in our specific ContentState (homeTeamScore, awayTeamScore, lastEvent). The app once receives the push notification will display the updated content it has received automatically.

NOTE: if you don’t have the json content-state matching the ContentState struct inside the activity Attributes struct, the app will not be able to update the content!

Something worth mentioning is that inside the “event” part right above the content-state, we can define the purpose of the notification, in this case we are updating the activity, but if we change the string from “update” to “end”, once the notification is received, the live activity will be automatically ended by the device, but keep in mind that what we are going to obtain is that the live activity will not be anymore displayed in the dynamic island, but will still shown in the lock screen with the last content-state that we have passed with the notification.

In the sample code we gather the device token in the AppDelegate:

While the activity token is gathered in the ActivityManager class:

Now all the necessary parts to build a proper push notification are defined, and we are able to send it. Of course you have to choose how do that, maybe using a backend service, a third party service, or just testing it with the macOS terminal as explained in the Apple Developer portal that you can find in here:

Once you have setup all, once received the push notification, your live activity will be updated automatically with the new values inside the content-statepart of the payload.

CONCLUSIONS

Live activities are really interesting, the situations in which they can be used in a constructive and useful way are many, and their implementation on the frontend side is quite simple. There are some restrictions that can create some small headaches, such as the size of the images that can be inserted in the layout, custom animations not supported (not even the basic ones) or the color of the dynamic island not too customizable, but in conclusion we can say that Apple has found a a very valid way to keep our users updated in real time.

--

--