Live activities
With Xcode 14 beta 4, we've been introduced to ActivityKit
. This lets us push an ativity to the lock screen, and update it periodically with new information. This works a little bit differently to a widget, but its also pretty similar.
Lets walk through building a demo that lets you control the state of the live activity to help you get to grips with it. From there, you'll be able to experiment and build your own activities.
The basics
There's a few pieces of setup we need to do before we can add a live activity.
First, head over to the capabilites tab for your project and add the push notifications entitlement.
Next, open up the info tab and add the entry for NSSupportsLiveActivities
. Set this to true.
Finally, we'll want to make sure our app requests permissions for notifications. For now, lets just add a button that requests notifications. This code sample will allow you to request permisions if they've not been given.
Setting up the views
We're going to be adding our Activity to an exising extension. If you don't have one, there's a great guide here.
The initial part of this is actually adding a little bit of shared code that both your activity and app can access, which will allow us to communicate to the system.
We'll need to make some ActivityAttributes
which setup our activity, and an associated ContentState
which we use to update the existing activity.
import Foundation
import ActivityKit
struct CoffeeDeliveryAttributes: ActivityAttributes {
public struct ContentState: Codable, Hashable {
var currentStatus: CoffeeDeliveryStatus
}
var coffeeName: String
}
enum CoffeeDeliveryStatus: Codable, Sendable, CaseIterable {
case recieved, preparing, outForDelivery
var displayText: String {
switch self {
case .recieved:
return "Recieved"
case .preparing:
return "Brewing"
case .outForDelivery:
return "On its way!"
}
}
}
import Foundation
import ActivityKit
struct CoffeeDeliveryAttributes: ActivityAttributes {
public struct ContentState: Codable, Hashable {
var currentStatus: CoffeeDeliveryStatus
}
var coffeeName: String
}
enum CoffeeDeliveryStatus: Codable, Sendable, CaseIterable {
case recieved, preparing, outForDelivery
var displayText: String {
switch self {
case .recieved:
return "Recieved"
case .preparing:
return "Brewing"
case .outForDelivery:
return "On its way!"
}
}
}
We've used an enum inside ContentState
to store the state that will update, and just a coffee name in the initial setup.
Make sure both your widget and app can access this code, as seen here with the target membership set to both.
Next, we'll need to make a view for this. The code is really similar to a widget, with only a few changes.
The main part of our Activity is the ActivityConfiguration
wrapper. This is similar to IntentConfiguration
.
Make sure to set the attributes type to the type you just created earlier.
The context provided to your view contains the initial attributes and the current state.
struct CoffeeDeliveryActivityWidget: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(attributesType: CoffeeDeliveryAttributes.self) { context in
Text("")
}
}
}
struct CoffeeDeliveryActivityWidget: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(attributesType: CoffeeDeliveryAttributes.self) { context in
Text("")
}
}
}
To help clean up this code ( and let us use previews ) we can pull out the view into its own seperate SwiftUI view that we can adjust as needed.
struct CoffeeDeliveryActivityWidget: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(attributesType: CoffeeDeliveryAttributes.self) { context in
CoffeeDeliveryActivityWidgetView(
attributes: context.attributes,
state: context.state
)
}
}
}
struct CoffeeDeliveryActivityWidgetView: View {
let attributes: CoffeeDeliveryAttributes
let state: CoffeeDeliveryAttributes.ContentState
var body: some View {
Text(attributes.coffeeName)
}
}
struct CoffeeDeliveryActivityWidget_Previews: PreviewProvider {
static var previews: some View {
CoffeeDeliveryActivityWidgetView(attributes: .init(coffeeName: "Flat White"), state: .init(currentStatus: .recieved))
.previewContext(WidgetPreviewContext(family: .systemMedium))
}
}
struct CoffeeDeliveryActivityWidget: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(attributesType: CoffeeDeliveryAttributes.self) { context in
CoffeeDeliveryActivityWidgetView(
attributes: context.attributes,
state: context.state
)
}
}
}
struct CoffeeDeliveryActivityWidgetView: View {
let attributes: CoffeeDeliveryAttributes
let state: CoffeeDeliveryAttributes.ContentState
var body: some View {
Text(attributes.coffeeName)
}
}
struct CoffeeDeliveryActivityWidget_Previews: PreviewProvider {
static var previews: some View {
CoffeeDeliveryActivityWidgetView(attributes: .init(coffeeName: "Flat White"), state: .init(currentStatus: .recieved))
.previewContext(WidgetPreviewContext(family: .systemMedium))
}
}
A systemMedium widget isnt quite the right size, but it helps us visualise.
Lets actually setup a simple view to use this content.
struct CoffeeDeliveryActivityWidgetView: View {
let attributes: CoffeeDeliveryAttributes
let state: CoffeeDeliveryAttributes.ContentState
var stateImageName: String {
switch state.currentStatus {
case .recieved:
return "cup.and.saucer.fill"
case .preparing:
return "person.2.badge.gearshape.fill"
case .outForDelivery:
return "box.truck.badge.clock.fill"
}
}
var body: some View {
HStack {
VStack(alignment: .leading, spacing: 6) {
Text("Order")
.font(.subheadline.weight(.semibold))
.opacity(0.8)
Text(attributes.coffeeName)
.font(.headline.weight(.semibold))
}
Spacer()
VStack(alignment: .center, spacing: 6) {
Image(systemName: stateImageName)
.font(.headline.weight(.bold))
Text(state.currentStatus.displayText)
.font(.headline.weight(.semibold))
}
}
.foregroundColor(.white)
.padding()
.background(Color.cyan)
.activityBackgroundTint(Color.cyan)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
struct CoffeeDeliveryActivityWidgetView: View {
let attributes: CoffeeDeliveryAttributes
let state: CoffeeDeliveryAttributes.ContentState
var stateImageName: String {
switch state.currentStatus {
case .recieved:
return "cup.and.saucer.fill"
case .preparing:
return "person.2.badge.gearshape.fill"
case .outForDelivery:
return "box.truck.badge.clock.fill"
}
}
var body: some View {
HStack {
VStack(alignment: .leading, spacing: 6) {
Text("Order")
.font(.subheadline.weight(.semibold))
.opacity(0.8)
Text(attributes.coffeeName)
.font(.headline.weight(.semibold))
}
Spacer()
VStack(alignment: .center, spacing: 6) {
Image(systemName: stateImageName)
.font(.headline.weight(.bold))
Text(state.currentStatus.displayText)
.font(.headline.weight(.semibold))
}
}
.foregroundColor(.white)
.padding()
.background(Color.cyan)
.activityBackgroundTint(Color.cyan)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
The only new thing here is .activityBackgroundTint(Color.cyan)
, to set a solid tint as the background
Our end result is a simple view that updates with the state we pass to it. We've not handled an idle state, as the coffee arriving means we should end the activity.
Finally, lets add our widget to the bundle so that it can be used by our live activity.
If you don't already have a widget bundle, simply remove @main
from your widget, then add it to the new bundle like shown here.
@main
struct CoffeeWidgets: WidgetBundle {
var body: some Widget {
// Any other widgets...
widget()
CoffeeDeliveryActivityWidget()
}
}
@main
struct CoffeeWidgets: WidgetBundle {
var body: some Widget {
// Any other widgets...
widget()
CoffeeDeliveryActivityWidget()
}
}
Adding our activity
We've done most of the work, but now we have to actually start the activity.
Lets build a simple model that can manage this for us, starting with an empty observable object.
import ActivityKit
class CoffeeModel: ObservableObject { }
import ActivityKit
class CoffeeModel: ObservableObject { }
To manage our activity we'll need to be able to start it, update the state, and then cancel it.
Lets add a reference to a running activity, which in this case is Activity<CoffeeDeliveryAttributes>
, and add a function to start it that accepts a coffee name as an argument.
@Published var liveActivity: Activity<CoffeeDeliveryAttributes>?
func start(coffeeName: String) { }
@Published var liveActivity: Activity<CoffeeDeliveryAttributes>?
func start(coffeeName: String) { }
Now, lets start the activity. Doing this is simply requires that you make your state and request the system starts the activity.
Task {
let attributes = CoffeeDeliveryAttributes(coffeeName: coffeeName)
let state = CoffeeDeliveryAttributes.ContentState(currentStatus: .recieved)
do {
liveActivity = try Activity<CoffeeDeliveryAttributes>.request(
attributes: attributes,
contentState: state,
pushType: nil
)
print("Started activity")
} catch (let error) {
print("Error starting activity \(error) \(error.localizedDescription)")
}
}
Task {
let attributes = CoffeeDeliveryAttributes(coffeeName: coffeeName)
let state = CoffeeDeliveryAttributes.ContentState(currentStatus: .recieved)
do {
liveActivity = try Activity<CoffeeDeliveryAttributes>.request(
attributes: attributes,
contentState: state,
pushType: nil
)
print("Started activity")
} catch (let error) {
print("Error starting activity \(error) \(error.localizedDescription)")
}
}
We should add another check to the top of this function to make sure activities are actually enabled.
guard ActivityAuthorizationInfo().areActivitiesEnabled else {
print("Activities are not enabled.")
return
}
guard ActivityAuthorizationInfo().areActivitiesEnabled else {
print("Activities are not enabled.")
return
}
Next, lets add a function to update the activity with a new state. All we have to do is make a new state, and then ask our activity to update by calling update
.
func updateActivity(state: CoffeeDeliveryStatus) {
let state = CoffeeDeliveryAttributes.ContentState(currentStatus: state)
Task {
await liveActivity?.update(using: state)
}
}
func updateActivity(state: CoffeeDeliveryStatus) {
let state = CoffeeDeliveryAttributes.ContentState(currentStatus: state)
Task {
await liveActivity?.update(using: state)
}
}
Finally, lets add the stop function. This is similar to update, except we get a few more options.
func stop() {
Task {
await liveActivity?.end(using: nil, dismissalPolicy: .immediate)
await MainActor.run {
liveActivity = nil
}
}
}
func stop() {
Task {
await liveActivity?.end(using: nil, dismissalPolicy: .immediate)
await MainActor.run {
liveActivity = nil
}
}
}
In this example we're just dismissing instantly, but you could also update using a new "finished" state, and set the dismissal policy to after a certain time. This might be good for things like taxi's arriving, so the activity remains.
We've actually got all the code we need to manage our activity, we just have to hook up a view to start the activity.
The view here doesn't have anything specific to live activities, its simply a wrapper so they're easy to configure.
We need to use the CoffeeModel
that was just created, and show some pickers to set the state.
struct ContentView: View {
@State var coffeeState: CoffeeDeliveryStatus = .recieved
@State var hasPermissions: Bool = false
@StateObject var model = CoffeeModel()
var body: some View {
List {
if let activity = model.liveActivity {
Section {
activityText(activityState: activity.activityState)
Text(coffeeState.displayText)
Picker(
"Coffee State",
selection: .init(get: {
self.coffeeState
}, set: {
self.coffeeState = $0
model.updateActivity(state: $0)
}),
content: {
ForEach(CoffeeDeliveryStatus.allCases, id: \.self) { coffeeStatus in
Text(coffeeStatus.displayText)
}
}
)
.pickerStyle(SegmentedPickerStyle())
}
Section {
stopActivityButton
}
} else {
Section {
startActivityButton
}
}
if !hasPermissions {
pushPermissionsButton
}
}
.onAppear {
updateNotificationStatus()
}
}
func activityText(activityState: ActivityState) -> some View {
let activityStatus = {
switch activityState {
case .active:
return "Active"
case .dismissed:
return "Dismissed"
case .ended:
return "Ended"
}
}()
return Text(activityStatus)
.font(.body.weight(.medium))
}
var pushPermissionsButton: some View {
Button(action: {
UNUserNotificationCenter.current().requestAuthorization(
options: [.alert, .badge, .sound]
) { _, error in
updateNotificationStatus()
}
}, label: {
Text("Request Permissions")
.font(.subheadline.weight(.semibold))
.foregroundColor(.white)
.padding()
.frame(maxWidth: .infinity, alignment: .center)
.background(RoundedRectangle(cornerRadius: 12))
})
.listRowInsets(EdgeInsets())
}
var startActivityButton: some View {
Button(action: {
model.start(coffeeName: "Flat White")
}, label: {
Text("Start Activity")
.font(.subheadline.weight(.semibold))
.foregroundColor(.white)
.padding()
.frame(maxWidth: .infinity, alignment: .center)
.background(RoundedRectangle(cornerRadius: 12))
})
.listRowInsets(EdgeInsets())
}
var stopActivityButton: some View {
Button(action: {
model.stop()
}, label: {
Text("Stop Activity")
.font(.subheadline.weight(.semibold))
.foregroundColor(.white)
.padding()
.frame(maxWidth: .infinity, alignment: .center)
.background(RoundedRectangle(cornerRadius: 12))
})
.listRowInsets(EdgeInsets())
}
private func updateNotificationStatus() {
// Check Permissions
UNUserNotificationCenter.current().getNotificationSettings { settings in
self.hasPermissions = settings.authorizationStatus == .authorized
}
}
}
struct ContentView: View {
@State var coffeeState: CoffeeDeliveryStatus = .recieved
@State var hasPermissions: Bool = false
@StateObject var model = CoffeeModel()
var body: some View {
List {
if let activity = model.liveActivity {
Section {
activityText(activityState: activity.activityState)
Text(coffeeState.displayText)
Picker(
"Coffee State",
selection: .init(get: {
self.coffeeState
}, set: {
self.coffeeState = $0
model.updateActivity(state: $0)
}),
content: {
ForEach(CoffeeDeliveryStatus.allCases, id: \.self) { coffeeStatus in
Text(coffeeStatus.displayText)
}
}
)
.pickerStyle(SegmentedPickerStyle())
}
Section {
stopActivityButton
}
} else {
Section {
startActivityButton
}
}
if !hasPermissions {
pushPermissionsButton
}
}
.onAppear {
updateNotificationStatus()
}
}
func activityText(activityState: ActivityState) -> some View {
let activityStatus = {
switch activityState {
case .active:
return "Active"
case .dismissed:
return "Dismissed"
case .ended:
return "Ended"
}
}()
return Text(activityStatus)
.font(.body.weight(.medium))
}
var pushPermissionsButton: some View {
Button(action: {
UNUserNotificationCenter.current().requestAuthorization(
options: [.alert, .badge, .sound]
) { _, error in
updateNotificationStatus()
}
}, label: {
Text("Request Permissions")
.font(.subheadline.weight(.semibold))
.foregroundColor(.white)
.padding()
.frame(maxWidth: .infinity, alignment: .center)
.background(RoundedRectangle(cornerRadius: 12))
})
.listRowInsets(EdgeInsets())
}
var startActivityButton: some View {
Button(action: {
model.start(coffeeName: "Flat White")
}, label: {
Text("Start Activity")
.font(.subheadline.weight(.semibold))
.foregroundColor(.white)
.padding()
.frame(maxWidth: .infinity, alignment: .center)
.background(RoundedRectangle(cornerRadius: 12))
})
.listRowInsets(EdgeInsets())
}
var stopActivityButton: some View {
Button(action: {
model.stop()
}, label: {
Text("Stop Activity")
.font(.subheadline.weight(.semibold))
.foregroundColor(.white)
.padding()
.frame(maxWidth: .infinity, alignment: .center)
.background(RoundedRectangle(cornerRadius: 12))
})
.listRowInsets(EdgeInsets())
}
private func updateNotificationStatus() {
// Check Permissions
UNUserNotificationCenter.current().getNotificationSettings { settings in
self.hasPermissions = settings.authorizationStatus == .authorized
}
}
}
The only clever trick here is listening to the change of the picker and setting that on our view model.
When we run that, we see that it starts the activity, and if we change the state it updates the activity for us.
This should be a great test bed for you to get going with live activites - I'd love to see what you come up with next.
Thanks for reading!
You can find my code on github.