iOS Live Activities with Ionic Capacitor

Live Activity example

With the introduction of iOS 16 version, Apple developed a new feature called Live Activities. The brand new feature can be presented both in Dynamic Island (if available on device) and on the lock screen. Although it’s widget based, it is not a widget itself, restricting access to network or geolocation. Yet ActivityKit allows easily to connect with it and also update using Activity Kit push notification. Live Activities has also a time restriction with a maximum time of life in Dynamic Island at 8hrs and 12hrs for Lock Screen.

In this post I will show you how to create a basic Live Activity and how to connect it with Ionic Capacitor app. What we are going to build is a distance simulation app. We’ll have a list of few items to pick in an Ionic app, when selected we’ll start a Live Activity with a distance that’s left to be made (like for a food delivery or a taxi) keeping Live Activity updated over time with decreased distance.

tldr; Whole code is available here 

Create widget target

Make sure to prepare an Ionic app with an iOS build.

Open the workspace in Xcode and add a new target of Widget Extension. I called myself DistanceTracker. Make sure to tick „Include Live Activity”, then add NSSupportsLiveActivities in Info.plist

<key>NSSupportsLiveActivities</key>
<true />

By default it should create Bundle, Widget and LiveActivity classes. We can ignore Widget now as this is not part of this post, and jump into LiveActivity class.

Since Live Activities were recently introduced and requires a 16.1 version of iOS, check the version you build against. If you support previous version of iOS, modify the auto-generated bundle and add an if to check the iOS version

// DistanceTrackerBundle.swift
@main
struct DistanceTrackerBundle: WidgetBundle {
    var body: some Widget {
        DistanceTracker()
        if #available(iOS 16.1, *) {
            DistanceTrackerLiveActivity()
        }
    }
}

Attributes

In order to pass a data to Live Activity we define Attributes. Each attribute can have both static data which don’t change over time and dynamic (ContentState) that can be updated. Default generated activity looks as below

struct DistanceTrackerAttributes: ActivityAttributes {
    public struct ContentState: Codable, Hashable {
        // Dynamic stateful properties about your activity go here!
        var value: Int
    }

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

Let’s modify it a little to support distance simulation app

struct DistanceTrackerAttributes: ActivityAttributes {
    public struct ContentState: Codable, Hashable {
        var distance: Int
        var expectedArrivalTime: ClosedRange<Date>
    }
    
    var title: String
}

The state will be defined as distance in meters and expected time for delivery to be made. Additionally we have a static variable title that describe the name of simulation which won’t be changed over time.

When developing in Xcode there’s an option to provide a preview object to test right away how the UI looks.

You can modify the auto generated preview class to pass correct data to test UI.

struct DistanceTrackerLiveActivity_Previews: PreviewProvider {
    static let attributes = DistanceTrackerAttributes(title: "Knowledge delivery")
    static let contentState = DistanceTrackerAttributes.ContentState(distance: 3500, expectedArrivalTime: Date.now...Date())

    static var previews: some View {
        attributes
            .previewContext(contentState, viewKind:       .dynamicIsland(.compact))
            .previewDisplayName("Island Compact")
        attributes
            .previewContext(contentState, viewKind: .dynamicIsland(.expanded))
            .previewDisplayName("Island Expanded")
        attributes
            .previewContext(contentState, viewKind: .dynamicIsland(.minimal))
            .previewDisplayName("Minimal")
        attributes
            .previewContext(contentState, viewKind: .content)
            .previewDisplayName("Notification")
    }
}

UI for Live Activity

When defining the LiveActivity we use SwiftUI. We need to define separate UIs for devices supporting Dynamic Island and those who don’t. The default code looks as follows.


struct DistanceTrackerLiveActivity: Widget {
    var body: some WidgetConfiguration {
        ActivityConfiguration(for: DistanceTrackerAttributes.self) { context in
            // Lock screen/banner UI goes here
            VStack {
                Text("Hello")
            }
            .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")
                    // more content
                }
            } compactLeading: {
                Text("L")
            } compactTrailing: {
                Text("T")
            } minimal: {
                Text("Min")
            }
            .widgetURL(URL(string: "http://www.apple.com"))
            .keylineTint(Color.red)
        }
    }
}

First part defines the Lock Screen banner and next we have configuration for Dynamic Islands for different cases. Thankfully we don’t have to define all in one place and we can extract the view into separate classes. Let’s start with Lock Screen banner.

Lock screen

We start by creating a new View that will describe how the lock screen should look like. We want to display the name of delivery, remaining distance and expected delivery time.


struct LockScreenLiveActivityView: View {
    let context: ActivityViewContext<DistanceTrackerAttributes>
    
    var body: some View {
        VStack {
            Spacer()
            Text("\(context.attributes.title) on the way!")
            Spacer()
            HStack {
                Spacer()
                Label {
                    Text("\(context.state.distance)m")
                } icon: {
                    Image(systemName: "paperplane")
                }
                .font(.title2)
                
                Label {
                    Text(timerInterval: context.state.expectedArrivalTime, countsDown: true)
                        .multilineTextAlignment(.center)
                        .frame(width: 50)
                        .monospacedDigit()
                } icon: {
                    Image(systemName: "timer")
                }
                .font(.title2)
                Spacer()
            }
            Spacer()
        }
        .activityBackgroundTint(.green)
    }
}

The above is pretty simple. Now we can update the LiveActivity to use this View instead of the default one

struct DistanceTrackerLiveActivity: Widget {
    var body: some WidgetConfiguration {
        ActivityConfiguration(for: DistanceTrackerAttributes.self) { context in
            LockScreenLiveActivityView(context: context)
        } dynamicIsland: { context in
        //....

Much better! Now let’s take a look at Dynamic Island

Dynamic Island

First thing to know about Dynamic Island is that it may not be supported by all devices. If your app is the only app with Dynamic Island active then the Compact and Trailing looks are included. If there are more apps then the system decides to pick two Dynamic Islands based on ranking and displays their Minimal view.

In our example we have two volatile data: distance and time. We’ll put one into the trailing view and the other into the compact view, for minimal we’ll just show a timer.

dynamicIsland: { context in
            DynamicIsland {
                DynamicIslandExpandedRegion(.leading) {
                    Text("Leading")
                }
                DynamicIslandExpandedRegion(.trailing) {
                    Text("Trailing")
                }
                DynamicIslandExpandedRegion(.bottom) {
                    Text("Bottom")
                    // more content
                }
            } compactLeading: {
                Label {
                    Text("\(context.state.distance)m")
                } icon: {
                    Image(systemName: "paperPlane")
                }
            } compactTrailing: {
                Label {
                    Text(timerInterval: context.state.expectedArrivalTime, countsDown: true)
                        .frame(width: 50)
                        .monospacedDigit()
                } icon: {
                    Image(systemName: "timer")
                }
            } minimal: {
                Text(timerInterval: context.state.expectedArrivalTime, countsDown: true)
                    .monospacedDigit()
                    .font(.caption2)
            }
        }

The above defines a simple compact and minimal view with all required info for the user to know.

Live tracking compact view

Finally we need to define the expanded View for our Live Activity. The extended activity is shown when user touches the Dynamic Islands or on updates to the Live Activity. Since learning SwiftUI is not part of this blog post, let’s limit the view to minimal and use quite a similar view to the one from static banner with exception of one more extra button to demonstrate some action from Live Tracking to a Capacitor App.

DynamicIsland {
                DynamicIslandExpandedRegion(.leading) {
                    Label("\(context.state.distance) m", systemImage: "paperplane")
                    .frame(width: 120)
                   .foregroundColor(.green)
                   .font(.title2)
                }
                DynamicIslandExpandedRegion(.trailing) {
                    Label {
                        Text(timerInterval: context.state.expectedArrivalTime, countsDown: true)
                            .frame(width: 50)
                            .monospacedDigit()
                    } icon: {
                        Image(systemName: "timer")
                    }
                    .font(.title2)
                }
                DynamicIslandExpandedRegion(.center) {
                    Text(context.attributes.title)
                        .font(.title2)
                        .multilineTextAlignment(.center)
                        .foregroundColor(.green)
                }
                DynamicIslandExpandedRegion(.bottom) {
                    Spacer()
                    Button {
                      } label: {
                          Label("Check status", systemImage: "questionmark.app")
                              .font(.title2)
                      }
                      .foregroundColor(.green)
                      .background(.gray)
                }
                
            }

We’ll fill the action for Button later. The above will produce a very simple expanded view like below. All of the regions can consume View so each can be separated similar way as we did with the banner.

Expanded live tracking

Configure and launch Live Activity

At this point Live Activity is more or less prepared to be launched. When starting a new activity there are two arguments that can be set:

  1. staleDate – this is a date when the activity should be put to stale mode because the content is now outdated. This argument is optional. If activity tracks some live performance that ends at some specific date, you can assume that it’ll be outdated a couple of minutes after the planned finish time.
  2. relevanceScore – if you start more than one Live Activity at same time, you can prioritize them. The score is the integer value, with the higher value getting more priority.

For simplicity reasons, in our app we’re going to have only one active live activity. We’ll create an Capacitor plugin to start the activity and to update it. Before we start let’s add an obj-c definition.

// LiveActivityPlugin.m
#import <Capacitor/Capacitor.h>

CAP_PLUGIN(LiveActivityPlugin, "LiveActivityPlugin",
           CAP_PLUGIN_METHOD(start, CAPPluginReturnPromise);
           CAP_PLUGIN_METHOD(update, CAPPluginReturnNone);
           CAP_PLUGIN_METHOD(stop, CAPPluginReturnNone);
)

Now we define the plugin code. Let’s take a look 1 by 1 at each method

@objc func start(_ call: CAPPluginCall) {
      let title = call.getString("title")!
      let distance = call.getInt("distance")!
      let expectedTimeInMinutes = call.getInt("expectedTimeInMinutes")
      if ActivityAuthorizationInfo().areActivitiesEnabled {
          var future = Calendar.current.date(byAdding: .minute, value: (expectedTimeInMinutes ?? 0), to: Date())!
          future = Calendar.current.date(byAdding: .second, value: (expectedTimeInMinutes ?? 0), to: future)!
          let date = Date.now...future
          let initialContentState = DistanceTrackerAttributes.ContentState(distance: distance, expectedArrivalTime: date)
          let activityAttributes = DistanceTrackerAttributes(title: title)
          let activityContent = ActivityContent(state: initialContentState, staleDate: Calendar.current.date(byAdding: .minute, value: 30, to: Date())!, relevanceScore: 100)
          
          do {
              var activity = try Activity.request(attributes: activityAttributes, content: activityContent)
              call.resolve([
                  "activityId": activity.id])
          } catch (let error) {
              print("Error requesting Live Activity \(error.localizedDescription).")
          }
      }
  }

First we need to start an activity. To do that we need to get title, distance and expected time from Ionic app. After that we need to check if we have a permission to start the activities. Since we use a countdown feature that requires a date range we create a date from now till future. We use „now” extended by minutes from expectedTimeInMinutes to make a date range when the activity is supposed to finish. Based on parameters we define three things: ContentState which is responsible for dynamic data which we will update later, DistanceTrackerAttributes which are the static parameters and finally create ActivityContent with initial state and a staleDate when the live activity is being predicted to end.

Once we have attributes and content prepared we can start the activity using Activity.request. You can either save the reference somewhere or keep an id for later use as I did above.

@objc func update(_ call: CAPPluginCall){
    let distance = call.getInt("distance")!
    let activityId = call.getString("activityId")!
    let expectedTimeInMinutes = call.getInt("expectedTimeInMinutes")
    let activity = Activity<DistanceTrackerAttributes>.activities.first { $0.id == activityId}
    
    if activity != nil {
        var future = Calendar.current.date(byAdding: .minute, value: (expectedTimeInMinutes ?? 0), to: Date())!
        future = Calendar.current.date(byAdding: .second, value: (expectedTimeInMinutes ?? 0), to: future)!
        let date = Date.now...future
        let updateState = DistanceTrackerAttributes.ContentState(distance: distance, expectedArrivalTime: date)
        let alertConfiguration = AlertConfiguration(title: "Live update", body: "Getting the things done!", sound: .default)
        let updatedContent = ActivityContent(state: updateState, staleDate: nil)
        
        Task {
            await activity?.update(updatedContent, alertConfiguration: alertConfiguration)
        }
    }
}

In the update method we first need to find the activity. We do that by using Activity<T>.activities which grants access to all activities of type T. We then search by the id returned from the request method.

Next step is to prepare the new ContentState with the update. The data for ContentState is delivered from Ionic app. AlertConfiguration describes how we want to inform user about updated content of an app.

@objc func stop(_ call: CAPPluginCall) {
    let activityId = call.getString("activityId")
    let activity = Activity<DistanceTrackerAttributes>.activities.first { $0.id == activityId}
    if activity != nil {
        Task {
            await activity?.end(dismissalPolicy: .immediate)
        }
    }
}

Finally we define a stop method which will terminate the activity. At the moment of writing this end method is being already Obsolete. The difference is that you should also pass the content to the end with the final information about the LiveTracking. Take it as an exercise 🙂

Ionic part

At the very end we want to connect our Ionic app with Live Tracking. To keep things as simple as possible I created an app from a List template and modified it a little.

First create a service to communicate with the plugin

import { Injectable } from "@angular/core";
import { Capacitor, registerPlugin } from "@capacitor/core";

const _pluginName: string = "LiveActivityPlugin";

export interface LiveActivityStartParams {
  title: string;
  distance: number;
  expectedTimeInMinutes: number;
}

export interface LiveActivityUpdateParams {
  distance: number;
  activityId: string;
  expectedTimeInMinutes: number;
}

export interface LiveActivityStopParams {
  activityId: string;
}

export interface LiveActivityPlugin {
  start(params: LiveActivityStartParams): Promise<{ activityId: string }>;
  update(params: LiveActivityUpdateParams): Promise<void>;
  stop(params: LiveActivityStopParams): Promise<void>;
}
const LiveAcivityPlugin = registerPlugin<LiveActivityPlugin>(_pluginName);

@Injectable({
  providedIn: "root",
})
export class LiveActivityPluginService {
  async start(params: LiveActivityStartParams): Promise<string> {
    if (Capacitor.isPluginAvailable(_pluginName)) {
      return (await LiveAcivityPlugin.start(params))?.activityId;
    }
    throw Error();
  }
  async update(params: LiveActivityUpdateParams): Promise<void> {
    if (Capacitor.isPluginAvailable(_pluginName)) {
      return await LiveAcivityPlugin.update(params);
    }
  }
  async stop(params: LiveActivityStopParams): Promise<void> {
    if (Capacitor.isPluginAvailable(_pluginName)) {
      await LiveAcivityPlugin.stop(params);
    }
  }
}

Nothing extraordinary here. Now we can simplify message view

<!-- message.component.html -->
<ion-item
  *ngIf="message"
>
  <div slot="start dot"></div>
  <ion-label class="ion-text-wrap">
    <h2>
      {{ message.title }}
    </h2>
    <h3>Distance {{ message.distance }}m</h3>
    <h3>Time {{ message.expectedTimeInMinutes }}min</h3>

    <ion-button primary (click)="start(message)">
      Start
    </ion-button>
  </ion-label>
</ion-item>

and modify code behind to simulate some action

import { CommonModule } from "@angular/common";
import {
  ChangeDetectionStrategy,
  Component,
  inject,
  Input,
} from "@angular/core";
import { RouterLink } from "@angular/router";
import { IonicModule, Platform } from "@ionic/angular";
import { Message } from "../services/data.service";
import { LiveActivityPluginService } from "../services/live-activity-plugin.service";

@Component({
  selector: "app-message",
  templateUrl: "./message.component.html",
  styleUrls: ["./message.component.scss"],
  changeDetection: ChangeDetectionStrategy.OnPush,
  standalone: true,
  imports: [CommonModule, IonicModule, RouterLink],
})
export class MessageComponent {
  private platform = inject(Platform);
  @Input() message?: Message;

  constructor(private liveActivityPluginService: LiveActivityPluginService) {}
  isIos() {
    return this.platform.is("ios");
  }

  async start(item: Message) {
    const activityId = await this.liveActivityPluginService.start({
      distance: item.distance,
      expectedTimeInMinutes: item.expectedTimeInMinutes,
      title: item.title,
    });
    let distance = item.distance;
    let expectedTimeInMinutes = item.expectedTimeInMinutes;
    let interval = setInterval(async () => {
      distance -= 1000;
      expectedTimeInMinutes -= 1;
      if (distance > 0) {
        await this.liveActivityPluginService.update({
          activityId,
          distance,
          expectedTimeInMinutes,
        });
      } else {
        clearInterval(interval);
        await this.liveActivityPluginService.stop({
          activityId,
        });
      }
    }, 3000);
  }
}

The code is very trivial. We start the activity and set an interval to reduce the distance and time every time. We actually don’t have to reduce the time, but let’s simulate the prediction of finish time also changes over time. When the distance reaches 0, we clear interval and stop live activity. Now, you can try it for yourself.

Note that it’s not a production ready solution. Interval may be stopped when app goes to background. In real scenario you should use a different approach like background processing.

Summary

Apple’s latest feature is a game-changer for developers looking to build dynamic, modern apps. With unparalleled flexibility in defining custom user interfaces and seamlessly passing data between components, this new addition promises to elevate the user experience to new heights.

In particular, the Live Tracking feature stands out as a powerful tool for making time-sensitive operations a breeze. Unlike other extensions that require additional communication between the app and widget, Live Tracking operates seamlessly without any extra steps. This makes it easy to work with popular frameworks like Capacitor and ensures that your app runs smoothly at all times.

Overall, this new feature from Apple is a must-have for developers looking to create user-friendly, efficient apps that deliver a seamless experience to users. Whether you’re building a brand-new app from scratch or updating an existing one, this feature is sure to take your project to the next level.


Opublikowano

w

,

przez