Empower your iOS apps with LiveActivity

Santosh Botre
11 min readJun 16, 2023
Image from: https://appleinsider.com/inside/ios-16/best/the-best-live-activities-in-ios-161

What is Live Activity?

Developer :

Live Activities help application developers to show the current activity, operation, or task going into their application on the iPhone or iPad Lock Screen.

User :

Live Activities enable application users to conveniently see the ongoing activity, operation, or task within their application directly from the Lock Screen of their iPhone or iPad.

The Lock Screen Widget has the advantage of being compatible with all devices. Additionally, on unlocked devices that do not support the Dynamic Island feature, the Lock Screen presentation also functions as a banner for Live Activity updates, incorporating an alert configuration.

What we are planning to build today??

The complete source code is available for your reference.

Let’s try it out by adding the Live Activity to our Workout application.

Step 1: Add the Widget to your project.

Step 2: Search for Widget Extension

Step 3: Provide a name for the workout widget.

NOTE: Make sure we have checked the Include Live Activity option

We can see the new folder WorkoutLiveWidget.

These 3 files have a template code added for us. We will use the same templates and add our own code to have our own Live Activity.

Let’s run the WorkoutWidgetExtension scheme and not the Application target we will see the widget like this in a simulator.

Workout — Widget Extension on the home screen.

NOTE: Our focus is on LiveActivity widget and not this widget in this article.

Let’s go through WorkoutWidgetBundle.swift file to understand what it has.

NOTE: NO CHANGES ARE REQUIRED IN THIS FILE…

import WidgetKit
import SwiftUI

@main
struct WorkoutWidgetBundle: WidgetBundle {
var body: some Widget {
WorkoutWidget()
WorkoutWidgetLiveActivity()
}
}

What is WidgetBundle?

WidgetBundle is a protocol type. It works as a container to expose multiple widgets from a single widget extension.

As the name suggests, WidgetBundle is the bundle of widgets we have created into our application or want to create into our application.

To support multiple types of widgets, it uses @mainon a struct confirmed to WidgetBundle struct to give an entry point for our Widget.

In our project, we have 2 widgets generated with the template,

  1. Workout Widget: The widget to display on the Home screen or in Notification Center.
  2. LiveActivity Widget: The widget to display currently ongoing activity on the Lock Screen, in the Dynamic Island or as a Banner (for no dynamic Island supported devices) on the Home screen.

In this blog, we will only focus on LiveActivity widget i.e., WorkoutWidgetLiveActivity.

User Interface for our Live Activity is below,

Here is the design for our LiveActivity Workout Widget.

Design

Here is the design for our DynamicIsland in compact mode.

compact mode

Here is the design for our DynamicIsland in expanded mode.

Expanded mode
Coding time…

What we need here to display is the workout name and the workout icon that will be shown in the widget.

Step 1: Create a model file called Workout and add it to the application and widget target.

It will have an enum for our workout type, associated icon and display name.

enum Workout: Int, Codable {
case Jogging = 0
case Aerobics = 1
case Yoga = 2

// Workout Icon
var workoutIcon: String {
switch self {
case .Jogging:
"figure.run"
case .Aerobics:
"figure.dance"
case .Yoga:
"figure.yoga"
}
}

// Workout name
var workoutName: String {
switch self {
case .Jogging:
"Jogging"
case .Aerobics:
"Aerobics"
case .Yoga:
"Yoga"
}
}
}

Step 2: Go to WorkoutWidgetLiveActivity.swift file and understand WorkoutWidgetAttributes generated code before updating it for the workout user case.

Generated Template Code:

struct WorkoutWidgetAttributes: ActivityAttributes {
public struct ContentState: Codable, Hashable {
// Dynamic stateful properties about your activity go here!
var emoji: String
}

// Fixed non-changing properties about your activity go here!
var name: String
}

It has a struct WorkoutWidgetAttributes conforms to the ActivityAttributes protocol that this struct will implement to describe the content of a Live Activity.

The ActivityAttributes protocol describes the content that appears in your Live Activity. Its inner type ContentState represents the dynamic content of the Live Activity.

In the Workout example, we want to know the kind of workout and the expected workout time as dynamic data that changes.

Let’s have our ActivityAttributes ready for our use case.

Update the Code:

import ActivityKit
import WidgetKit
import SwiftUI

struct WorkoutWidgetAttributes: ActivityAttributes {
public struct ContentState: Codable, Hashable {
// Dynamic stateful properties about your activity go here!
var workoutTimer: ClosedRange<Date>
}

// Fixed non-changing properties about your activity go here!
var workout: Workout
}

NOTE: Make sure WorkoutWidgetLiveActivity.swift is also added in the main and Widget target as we are going to access the WorkoutWidgetAttributes to start the Live activity from our application.

NOTE: We can refactor this code by taking the WorkoutWidgetAttributes in some other like WorkoutWidgetAttributes.swift and onlyWorkoutWidgetAttributes.swift to the WorkoutLiveWidgetPOC target instead of adding WorkoutWidgetLiveActivity.swift that has the widget specific code.

Step 3: Go to WorkoutWidgetLiveActivity.swift file and understand WorkoutWidgetActivity: Widget generated code before updating it for the workout user case.

Generated Template Code:

struct WorkoutWidgetLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: WorkoutWidgetAttributes.self) { context in
// Lock screen/banner UI goes here
VStack {
Text("Hello \(context.state.emoji)")
}
.activityBackgroundTint(Color.cyan)
.activitySystemActionForegroundColor(Color.black)

} 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) {
Text("Bottom \(context.state.emoji)")
// more content
}
} compactLeading: {
Text("L")
} compactTrailing: {
Text("T \(context.state.emoji)")
} minimal: {
Text(context.state.emoji)
}
.widgetURL(URL(string: "http://www.apple.com"))
.keylineTint(Color.red)
}
}
}

The body has ActivityConfiguration that creates a configuration object for a Live Activity.

It takes a parameter attributeType that is WorkoutWidgetAttributes in our case.

A content is a closure that creates the view for the Live Activity that appears on the Lock Screen.

NOTE: This view also appears as a banner on the Home Screen of devices that don’t support Dynamic Island when you alert a person about updated Live Activity content.

Then dynamicIsland closure that builds the Live Activity that appears in the Dynamic Island.

The dynamicIsland UI consists of 4 regions, leading, trailing, centre and bottom. However, in the generated dynamicIsland code it doesn't have the centre region code. We will be adding that to our widget.

Step 3: Create a new SwiftUI file LockScreenWorkoutLiveActivityView.swift

Implement view for LockScreenWorkoutLiveActivityView as below,

import SwiftUI
import ActivityKit
import WidgetKit

struct LockScreenWorkoutLiveActivityView: View {
let context: ActivityViewContext<WorkoutWidgetAttributes>

var body: some View {
VStack {
HStack(alignment: .center){
ZStack {
HStack(alignment: .center) {
Text("\(context.attributes.workout.workoutName)")
.font(.custom("Georgia", size: 24, relativeTo: .headline))
.italic()
.fontWeight(.bold)
.foregroundColor(.black)
.padding()
}
}

VStack {
Text(timerInterval: context.state.workoutTimer, countsDown: true)
.multilineTextAlignment(.center)
.monospacedDigit()
.font(.title)
}

Image(systemName: context.attributes.workout.workoutIcon)
.font(.title2)
.foregroundColor(.black)
.padding()

Spacer()
}

Text("Keep it going!!!")
.font(.custom("Georgia", size: 14, relativeTo: .headline))
.italic()
.foregroundColor(.accentColor)
.padding()
}
}
}

Step 4: Replace the VStack with LockScreenWorkoutLiveActivityView and pass the context.

struct WorkoutWidgetLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: WorkoutWidgetAttributes.self) { context in
// Lock screen/banner UI goes here
LockScreenWorkoutLiveActivityView(context: context)
.activityBackgroundTint(Color.cyan)
.activitySystemActionForegroundColor(Color.black)
} dynamicIsland: { context in
DynamicIsland {
....
....
}
}
}

Create our LockScreen widget to look more professional.

Step 5: Now design the dynamicIsland for a compact and expanded view.

Below is the entire file code…

struct WorkoutWidgetLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: WorkoutWidgetAttributes.self) { context in
// Lock screen/banner UI goes here
LockScreenWorkoutLiveActivityView(context: context)
.activitySystemActionForegroundColor(Color.gray)
.activityBackgroundTint(Color.cyan)
} dynamicIsland: { context in
DynamicIsland {
/// Expanded Dynamic Island UI goes here.
/// Compose the expanded UI through various regions,
/// like leading/trailing/center/bottom

/// Leading - Workout Icon
DynamicIslandExpandedRegion(.leading) {
Image(systemName: context.attributes.workout.workoutIcon)
.foregroundColor(.indigo)
.font(.title2)
}
/// Trailing - Countdown timer
DynamicIslandExpandedRegion(.trailing) {
Label {
Text(timerInterval: context.state.workoutTimer, countsDown: true)
.multilineTextAlignment(.trailing)
.frame(width: 50)
.monospacedDigit()
} icon: {
Image(systemName: "timer")
.foregroundColor(.indigo)
}
.font(.title2)
}
/// Center - Workout name
DynamicIslandExpandedRegion(.center) {
Text("\(context.attributes.workout.workoutName)")
.lineLimit(1)
.font(.custom("Georgia", size: 24, relativeTo: .headline))
.foregroundColor(.indigo)
.padding()
}
/// Bottom - Stop action button
DynamicIslandExpandedRegion(.bottom) {
// Deep link into your app.
Link(destination: URL(string: "workout://stop")!, label: {
Label("STOP", systemImage: "xmark")
.bold()
.padding(6)
.foregroundColor(.white)
.background(.red)
.clipShape(
Capsule()
)
}).environment(\.openURL, OpenURLAction { url in
print("The the action to the app with \(url)")
return .handled
})
} compactLeading: {
Text("\(context.attributes.workout.workoutName)")
} compactTrailing: {
Text(timerInterval: context.state.workoutTimer, countsDown: true)
.multilineTextAlignment(.trailing)
.frame(width: 50)
.monospacedDigit()
} minimal: {
Text(context.state.workoutTimer)
}
///Sets the URL that opens the corresponding app of a Live Activity when a user taps on the Live Activity.
.widgetURL(URL(string: "http://www.apple.com"))
///Applies a subtle tint color to the surrounding border of a Live Activity that appears in the Dynamic Island.
.keylineTint(Color.red)
}
}
}


extension WorkoutWidgetAttributes {
fileprivate static var preview: WorkoutWidgetAttributes {
WorkoutWidgetAttributes(workout: .Jogging)
}
}

extension WorkoutWidgetAttributes.ContentState {
fileprivate static var jogging: WorkoutWidgetAttributes.ContentState {
var future = Calendar.current.date(byAdding: .minute, value: 20, to: Date())!
future = Calendar.current.date(byAdding: .second, value: 10, to: future)!
let date = Date.now...future
return WorkoutWidgetAttributes.ContentState(workoutTimer: date)
}

fileprivate static var running: WorkoutWidgetAttributes.ContentState {
var future = Calendar.current.date(byAdding: .minute, value: 20, to: Date())!
future = Calendar.current.date(byAdding: .second, value: 10, to: future)!
let date = Date.now...future
return WorkoutWidgetAttributes.ContentState(workoutTimer: date)
}
}

#Preview("Notification", as: .content, using: WorkoutWidgetAttributes.preview) {
WorkoutWidgetLiveActivity()
} contentStates: {
WorkoutWidgetAttributes.ContentState.jogging
}

Preview of our WorkoutWidgetLiveActivity in Xcode Preview section as below,

Preview….

The question is, How this Live Activity will be triggered?

Step 6: Go to our main application and create a WorkoutViewModel.swift.

Step 7: Let’s add a function which will start the live activity called startWorkout it accepts the argument of type Workout.

func startWorkout(workout: Workout){
}

Firstly, ActivityAuthorizationInfochecks areActivitiesEnabled to verify that our app can start a Live Activity.

Secondly, Create the workout duration,

// Duration
var future = Calendar.current.date(byAdding: .minute, value: 1, to: Date())!
future = Calendar.current.date(byAdding: .second, value: 10, to: future)!
let date = Date.now...future

Create the ContentState for our WorkoutAttributes which is dynamic i.e., countdown

// Content State
let initialContentState = WorkoutWidgetAttributes.ContentState(workoutTimer:date)

WorkoutAttributes with non-changeable properties in our case workout.

// Fixed Attributes
let activityAttributes = WorkoutWidgetAttributes(workout: workout)

Create the ActivityContent it takes the current state and the staleDate. i.e., The date when the system considers the Live Activity to be out of date.

NOTE: We can provide relevanceScore which determines the order in which our Live Activities appear when you start several Live Activities for your app. The highest relevance score appears in Dynamic Island.

let activityContent = ActivityContent(state: initialContentState, staleDate: Calendar.current.date(byAdding: .minute, value: 1, to: Date())!)

Lastly, Request and start the Live Activity. It can throw an error in case not able to fulfil the request.

NOTE: We are not passing pushTypeA value that indicates whether the Live Activity receives updates to its dynamic content with ActivityKit push notifications. Pass nil to start a Live Activity that only receives updates from the app with the update(_:) function. To start a Live Activity that receives updates to its dynamic content with ActivityKit push notifications in addition to the update(_:) function, pass token to this parameter.

do {
_ = try Activity.request(attributes: activityAttributes, content: activityContent)
print("Requested Workout Live Activity \(String(describing:workout.workoutName)).")
} catch (let error) {
print("Error requesting workout Live Activity \(error.localizedDescription).")
}

The complete startWorkout(workout) function is as below,

func startWorkout(workout: Workout) {
if ActivityAuthorizationInfo().areActivitiesEnabled {
// Duration
var future = Calendar.current.date(byAdding: .minute, value: 1, to: Date())!
future = Calendar.current.date(byAdding: .second, value: 10, to: future)!
let date = Date.now...future
// Content State
let initialContentState = WorkoutWidgetAttributes.ContentState(workoutTimer:date)
// Fixed Attributes
let activityAttributes = WorkoutWidgetAttributes(workout: workout)
let activityContent = ActivityContent(state: initialContentState, staleDate: Calendar.current.date(byAdding: .minute, value: 1, to: Date())!)

do {
_ = try Activity.request(attributes: activityAttributes, content: activityContent)
print("Requested Workout Live Activity \(String(describing:workout.workoutName)).")
} catch (let error) {
print("Error requesting workout Live Activity \(error.localizedDescription).")
}
} else {
// In case, the user chooses the "Don't Allow" option 1st time.
// After we start the workout activity and lock the phone.
print("Error requesting workout Live Activity.")
}
}

Step 8: Create endWorkout function.

We have to end an active Live Activity.

Create the final ContentState like a timer to 0 and the staleDate to nil as no longer valid activity.

Iterate through the running activities and then set the final content to be set to the Live activity so as to be shown the latest and final content update after it ends.

NOTE: This is important because the Live Activity may remain visible until the system or the person removes it.

func endWorkout() {
let finalActivityStatus = WorkoutWidgetAttributes.ContentState(workoutTimer: Date.now...Date())
let finalContent = ActivityContent(state: finalActivityStatus, staleDate: nil)

Task {
for activity in Activity<WorkoutWidgetAttributes>.activities {
await activity.end(finalContent, dismissalPolicy: .immediate)
print("Ending the Live Activity: \(activity.id)")
}
}
}

dismissalPolicy: Describes when the system should dismiss a Live Activity and remove it from the Lock Screen.

.default, .immediate, and .after a specified time.

Step 9:

Design UI to allow the user to select the workout and then start and stop the workout activity.

Complete code for ContentView which allow users to select the workout and then start the workout.

//
// ContentView.swift
// WorkoutLiveWidgetPOC
//
// Created by santoshbo on 14/06/23.
//

import SwiftUI
import ActivityKit

struct ContentView: View {

@State private var selectedActivity = Workout.Aerobics
@State private var workoutViewModel = WorkoutViewModel()

var body: some View {
VStack {
Picker("What activity you want to start?", selection: $selectedActivity) {
Text(Workout.Aerobics.workoutName).tag(Workout.Aerobics)
Text(Workout.Jogging.workoutName).tag(Workout.Jogging)
Text(Workout.Yoga.workoutName).tag(Workout.Yoga)
}
.pickerStyle(.segmented)
.padding()

GenericButton(title: "START", tint: .green, action: {
print(selectedActivity)
workoutViewModel.startWorkout(workout: selectedActivity)
workoutViewModel.keepTrackOfWorkoutUpdates()
})

GenericButton(title: "STOP", tint: .red, action: {
workoutViewModel.endWorkout()
})

}
.onOpenURL { (url) in
print(url)
workoutViewModel.endWorkout()
}
}

struct GenericButton: View {
var title: String
var tint: Color
var action: () async -> ()

var body: some View {
Button(action: {
Task {
await action()
}
}, label: {
Text(title)
.bold()
.font(.caption)
})
.buttonStyle(.borderedProminent)
.tint(tint)
}
}
}

#Preview {
ContentView()
}

Few Important Notes:

NOTE: Add Support Live Activities in info.plist and set it to YES

Application info.plist settings

NOTE: Once you start the activity 1st time do lock the phone/simulator and then Allow permission to use Live activity.

NOTE: Make sure you do the below configuration so that your stop button will work and stop the workout activity.

References:

--

--

Santosh Botre

Take your time to learn before develop, examine to make it better, and eventually blog your learnings.