Live Activity: Putting it All Together

Part 5: Wrap up the Live Activity implementation

Shawn Roller

November 30, 2024

GuideSuccess

Let’s get it on.

All right, we’ve made it this far, now let’s wrap it up. This is going to be a code-heavy.

Progress Bar

The progress bar challenge

Here we run into limitations: a completely custom progress bar won’t be allowed to animate. Similarly, timer callbacks will not fire, animations will not run, etc. - any state changes require a notification to trigger.

But we can use a SwiftUI ProgressView which will be allowed to animate. We don’t have a lot of options to customize it, but we can define a set custom ProgressViewStyle modifiers, like this:

ProgressViewStyles.swift
1struct StartProgressStyle: ProgressViewStyle {
2  func makeBody(configuration: Configuration) -> some View {
3    ProgressView(configuration)
4      .tint(Color.pre)
5      .cornerRadius(0)
6      .labelsHidden()
7      .scaleEffect(x: 1, y: 2.5, anchor: .center)
8  }
9}

We don’t have the ability to customize the bar with different colored segments. Instead, we can layer 3 bars on top of the next to give the illusion of a single bar with different segments. So we’ll need to create 3 styles: start, middle, and end.

Here’s how we can integrate the 3 bars into a single bar:

  • First, our start and end time for the Live Activity is represented by the middle portion of the progress bar - the pink part

  • The “ready” and “cool” bars have constant times set - a 20 second “ready” phase, and a 20 second “cool” phase

  • We’ll determine the proportional width of each bar based on the duration of each segment

  • And finally we’ll use a ZStack to place the bars on top of each other

Here’s an example:

ProgressBar.swift
1var body: some View {
2    GeometryReader { proxy in
3        ZStack(alignment: Alignment(horizontal: .leading, vertical: .center)) {
4            ProgressView(timerInterval: startActivityDate...activityEndDate, countsDown: false)
5                .progressViewStyle(EndProgressStyle())
6                .frame(width: proxy.size.width)
7            ProgressView(timerInterval: startActivityDate...endDate, countsDown: false)
8                .progressViewStyle(MiddleProgressStyle())
9                .frame(width: proxy.size.width * (secondSegmentProportion))
10            ProgressView(timerInterval: startActivityDate...startDate, countsDown: false)
11                .progressViewStyle(StartProgressStyle())
12                .frame(width: proxy.size.width * firstSegmentProportion)
13        }
14    }
15    .padding(.top)
16}

Now we can use an overlay modifier to place a label over the middle of each bar, for example:

ProgressBar.swift
1.overlay(alignment: .bottomLeading) {
2    HStack {
3        Spacer()
4            .frame(width: proxy.size.width * firstSegmentProportion)
5        HStack(spacing: 0) {
6            Spacer()
7            Text("STEAM")
8                .font(.system(size: 12, weight: .semibold))
9            Spacer()
10        }
11    }
12    .alignmentGuide(.bottom) { dim in
13        dim.height * 1.75
14    }
15}

Check out the full code over at Github.

CTA Button

We will use a Link to open our main app when it’s tapped. We can specify the URL Type that we defined in our main app, and we could optionally pass parameters for our app to parse and action against.

CtaView.swift
1Link(destination: URL(string: URLPrefix)!) {
2    Text("I'M TOAST")
3        .padding(8)
4        .frame(maxWidth: .infinity)
5        .background(Color.black)
6        .foregroundColor(.white)
7        .clipShape(.capsule)
8}

There’s also a modifier that we can use to open the main app when the Live Activity is tapped: .widgetURL(URL(string: URLPrefix))

Countdown

We can easily add a countdown timer by using Text.init(timerInterval… which accepts a date range. Since we already have a start date and an end date, here’s how we can use it:

LiveActivityExtension.swift
1Text.init(timerInterval: context.attributes.startTime...context.attributes.endTime, countsDown: true)

Smart Stack

In a Live Activity there are a handful of presentations which need to be accounted for, and with watchOS 11 there’s now the Smart Stack. By default the Smart Stack will reuse your iOS views and put them in the Smart Stack which, for us, does not look good. We’ll want to provide a custom implementation that looks better on the small screen so there are a few things to do.

First, we’ll add this modifier to our Widget body to indicate that we support a small activity presentation: .supplementalActivityFamilies([.small, .medium])

Next we’ll create a view where we can conditionally render a view based on the family size:

ActivityContentView.swift
1struct ActivityContentView: View {
2    @Environment(\.activityFamily) var activityFamily
3    var context: ActivityViewContext<LiveActivityExtensionAttributes>
4    
5    var body: some View {
6        switch activityFamily {
7        case .small:
8            SmartStackView(context: context)
9        case .medium:
10            LockScreenView(context: context)
11        @unknown default:
12            LockScreenView(context: context)
13        }
14    }
15}

Now the Smart Stack on watchOS will render the SmartStackView, and iOS will render the LockScreenView.

Note: you can preview the Smart Stack in the preview canvas by changing the view style:

Change it to Smart Stack to get a preview without running on Watch

Background Translucency

In order to get a nice translucent blur effect, there’s a modifier that we can use on our lock screen presentation called activityBackgroundTint .

In our app I want to support light and dark modes and have my activity background update accordingly, so I’m using a color from my asset catalog called “WidgetBackground”. I’ll specify white and black for light and dark modes, respectively, and then use it to tint the background like so: .activityBackgroundTint(Color.widgetBackground.opacity(0.4))

This adds a nice effect to the lock screen. Note that the preview does not reflect the translucency but when you run the app you’ll see it.

Up next

Ok, that’s a lot to digest. I recommend taking a look at the Github repo to see how it all comes together. In the final post in this series I’ll talk about how we can clean-up the any stale live activities when the app launches.