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.

image

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.

image

Next, open up the info tab and add the entry for NSSupportsLiveActivities. Set this to true.

image

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.

image

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.

image

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.

image

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.

image

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.