Опыт использования Live Activity в IOS

В последние годы мобильные приложения всё чаще стремятся быть полезными пользователю ещё до того, как он их откроет. Экран блокировки, уведомления и Dynamic Island превращаются из пассивных элементов интерфейса в полноценную точку взаимодействия. С появлением Live Activities в iOS у разработчиков появился мощный инструмент для отображения состояния приложения в реальном времени, но на практике быстро выясняется, что примеров их осмысленного и удобного использования не так уж много. В этой статье мы разберёмся, как с помощью ActivityKit, App Intents и UserNotifications можно построить живой и интерактивный пользовательский опыт — на примере простого, но показательного To Do-приложения.

В последних версиях iOS Apple представила несколько новых библиотек, заметно расширяющих возможности взаимодействия приложения с пользователем. Среди них — WidgetKit, SwiftData, ActivityKit и UserNotifications. Каждая из них хорошо документирована по отдельности, однако на практике быстро выясняется, что примеров их совместного использования не так много.

В процессе работы над одним из проектов нам стало интересно разобраться, насколько удобно можно управлять состоянием приложения, не открывая его напрямую. В результате мы решили провести небольшой эксперимент и проверить это на простом примере.

Выбор тестового приложения

В качестве тестового проекта было выбрано минималистичное приложение To Do. Такой формат оказался удобным, поскольку задачи имеют понятный жизненный цикл, а изменение их состояния легко отследить визуально.

Основная идея заключалась в том, чтобы пользователь мог:

  • запускать выполнение задачи;
  • отслеживать её состояние;
  • завершать задачу

без необходимости каждый раз переходить в само приложение.

Главный экран и экран блокировки

На главном экране обновление статуса задач реализуется достаточно очевидно — с помощью WidgetKit. Гораздо интереснее оказалось взаимодействие с экраном блокировки.

Для этой части мы использовали ActivityKit. Live Activity, появившиеся в iOS 16, позволяют отображать текущий контекст прямо на экране блокировки и реагировать на действия пользователя. Несмотря на то что сама концепция уже хорошо известна, практических примеров использования App Intents внутри Live Activity оказалось заметно меньше, чем ожидалось.

Работа с Live Activity

При запуске задачи в приложении создаётся соответствующая Live Activity. Она отображается на экране блокировки и показывает актуальное состояние задачи. В нашем случае этого оказалось достаточно, чтобы пользователь понимал, что задача находится в процессе выполнения.

Ключевой момент заключался в том, чтобы дать возможность завершить задачу прямо с экрана блокировки. Такой сценарий оказался вполне рабочим и заметно снижает количество лишних действий со стороны пользователя.

Управление состоянием

Для упрощения работы с Live Activity был выделен отдельный класс — ActivityManager. Он отвечает за создание, обновление и завершение Live Activity, а также за синхронизацию их состояния с моделью задач.

Подобное разделение логики позволило избежать дублирования кода и упростило дальнейшие эксперименты с ActivityKit. По ощущениям, такой подход хорошо вписывается в общую архитектуру SwiftUI-приложений.

Пример управления статусом задачи через уведомления на заблокированном экране

Изображение 1 — Пример управления статусом задачи через уведомления на заблокированном экране

LiveActivityWidget

Создаём новый Target через File -> New -> Target -> Widget Extension и называем его LiveActivityWidget.


struct LiveActivityWidget: Widget {
    var body: some WidgetConfiguration {
        ActivityConfiguration(for: TodoAttributes.self) { context in
            // Lock screen/banner UI goes here
            LiveActivityView(context: context)
        } dynamicIsland: { context in
            DynamicIsland {
                DynamicIslandExpandedRegion(.center) {
                    LiveActivityView(context: context)
                }
            } compactLeading: {
                Text(context.attributes.target.name)
            } compactTrailing: {
                Text(context.state.status.text)
            } minimal: {
                Text("")
            }
        }
    }
    
    @ViewBuilder
    func LiveActivityView(context: ActivityViewContext) -> some View {
        ZStack{
            VStack(alignment: .leading) {
                HStack(alignment: .center){
                    Image("appLogo")
                        .resizable()
                        .frame(width:38,height: 38)
                        .clipShape(.circle)

                    VStack(alignment: .leading){
                        HStack{
                            Text(context.attributes.target.name)
                                .font(.system(size: 18,weight: .bold))
                            Spacer()
                        }
                        Text(DateHelper.Formatter.longDate.string(from: context.attributes.target.startTime))
                            .font(.system(size: 16,weight: .medium))
                            .lineLimit(2)
                            .multilineTextAlignment(.leading)
                    }
                }

                ButtonView(context: context)
            }
            .padding(16)
            .activityBackgroundTint(Color.brandSecondary.opacity(0.5))
        }
    }
    
    @ViewBuilder
    func ButtonView(context: ActivityViewContext) -> some View {
        Group {
            switch context.state.status {
            case .pending:
                Text("Pending")
            case .inProgress:
                LiveActivityActionButtonsView(recordID: context.attributes.target.id)
            case .completed:
                RoundedRectangle(cornerRadius: 20, style: .circular)
                    .frame(width: .infinity,height: 40)
                    .foregroundStyle(Color.brandSecondary.opacity(0.5))
                    .overlay(content: {
                        Text("Completed!")
                            .font(Font.system(size: 16,weight: .bold))
                    })
            }
        }
    }
}

struct LiveActivityActionButtonsView: View {
    let recordID:String
    var body: some View {
        HStack{
            Button(intent: LiveActivityButtonIntent(id: recordID), label: {
                Text("Complete")
            })
            .buttonStyle(.plain)
            .frame(maxWidth: .infinity,minHeight:40,maxHeight:40)
            .background(Color.brandPrimary.gradient)
            .clipShape(Capsule())

        }
    }
}

Это поможет создать LiveActivityView. В этом примере мы создали компонент нотификации, который показывает название задачи, дату выполнения и кнопку для её завершения, пример работы с которым мы рассмотрим ниже.

Пример уведомления на заблокированном экране телефона

Изображение 2 — Пример уведомления на заблокированном экране телефона

TodoAttributes

Также мы создаём структуру TodoAttributes, которая будет соответствовать протоколу ActivityAttributes. Это очень важный шаг, так как протокол описывает контент, который отображается в Live Activity на экране блокировки. Внутри протокола есть структура ContentState, которая представляет динамический контент Live Activity (параметры, которые можно обновлять).


struct TodoAttributes: ActivityAttributes {
    
    enum Status: Codable, Hashable {
        case pending
        case inProgress
        case completed
        
        var text: String {
            switch self {
            case .pending: return "Pending"
            case .inProgress: return "In Progress"
            case .completed: return "Completed"
            }
        }
    }
    
    // Dynamic data
    public struct ContentState: Codable, Hashable {
        var status: Status
        var lastUpdated: Date
    }
    
    // Static data
    let target: Target
}

struct Target: Hashable, Codable, Identifiable {
    var id: String
    var name: String
    var startTime: Date
    var duration: Int?
}

extension Target {
    static var testTarget = Target(id: "130", name: "Review project", startTime: .now, duration: 60)
}

extension TodoAttributes {
    static var preview: TodoAttributes {
        TodoAttributes(target: Target.testTarget)
    }
}

Примечание: убедитесь, что новый Target для Live Activity добавлен в Target Membership файла.

Затем, в Info.plist необходимо установить значение поля “Supports Live Activities” – YES.

Info.plist

Изображение 3 – Info.plist

ActivityManager

Затем создаём ActivityManager, который поддерживает организацию логики Live Activity в одном месте.


class ActivityManager: ObservableObject {
    struct ActivityStatus: Sendable, Hashable, Identifiable {
        var id: String
        var activityState: ActivityState
        var target: Target
        var contentState: TodoAttributes.ContentState
        
        init(id: String, activityState: ActivityState, target: Target, contentState: TodoAttributes.ContentState) {
            self.id = id
            self.activityState = activityState
            self.target = target
            self.contentState = contentState
        }
        
        init(activity: Activity) {
            self.id = activity.id
            self.activityState = activity.activityState
            self.target = activity.attributes.target
            self.contentState = activity.content.state
        }
    }
    
    // current Activity
    @Published var activityStatus: ActivityStatus? = nil
        
    @Published var statusList: [ActivityStatus] = []
    
    private var activityList: [Activity] = [] {
        didSet {
            DispatchQueue.main.async {
                self.statusList = self.activityList.map({ActivityStatus(activity: $0)})
            }
        }
    }
    
    func getLiveActivity(for recordID: String) -> Activity?{
        Activity.activities.first(where: {$0.attributes.target.id == recordID})
    }
    
    /// Used for App Intent purposes
    func loadActivity(_ activityId: String?) {
        guard let activityId else {
            return
        }
        
        guard let activity = self.activityList.first(where: {$0.id == activityId}) else {
            print("No Activity Found for current Target!")
            return
        }
        if activity.activityState == .ended || activity.activityState == .dismissed {
            print("Activity ended! Please start a new one!")
            return
        }
        
    }
    
    func startActivity(for target: Target){
        let activityData: TodoAttributes = .init(target: target)
        let contentState: TodoAttributes.ContentState = .init(status: .inProgress, lastUpdated: .now)
                do {
                    let activity = try Activity.request(attributes: activityData, content: .init(state: contentState, staleDate: Date(timeIntervalSinceNow: 10)))
                    print("Activity Added: \(activity)")
                } catch {
                    print(error.localizedDescription)
                }
    }
    
    func updateActivity(id: String, status: TodoAttributes.Status) async {
        guard let currentActivity = getLiveActivity(for: id) else {
            print("Start a New activity or load an existing one before update.")
            return
        }

        let contentState = TodoAttributes.ContentState(status: status, lastUpdated: .now)

        await currentActivity.update(
            ActivityContent(
                state: contentState,
                staleDate: nil
            )
        )
        if status == .completed {
            await self.endActivity(id: id, dismissalPolicy: .default)
        }
    }
    
    func endActivity(id: String, dismissalPolicy: ActivityUIDismissalPolicy) async {
        guard let currentActivity = getLiveActivity(for: id) else {
            print("Start a New activity or load an existing one before ending it.")
            return
        }

        await currentActivity.end(nil, dismissalPolicy: dismissalPolicy)
    }
}

// Helper functions for Intent actions
extension ActivityManager {
    
    func finishTask(id: String) async {
        await self.updateActivity(id: id, status: .completed)
       
    }
}

Внутри ActivityManager мы обрабатываем всю логику, необходимую для обновления задачи в Live Activity: получение Activity через TodoAttribute, запуск активности и её обновление после завершения.

После создания этих двух классов можно добавить ActivityManager в основную структуру приложения.


 @main
struct TodoApp: App {
    @StateObject var activityManager: ActivityManager
    
    init() {
        let activityManager = ActivityManager()
        _activityManager = StateObject(wrappedValue: activityManager)
    }
    
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(activityManager)
        }
    }
}

После базовой настройки Live Activity возвращаемся в ContentView и делаем небольшие изменения:

  • Добавляем @EnvironmentObject private var manager: ActivityManager
  • Создаём метод для запуска задачи и подключения её к Live Activity следующим образом:

private func start(task: Todo) {
   guard let index = todos.firstIndex(where: {$0.id == task.id}) else { return }
   todos[index].status = .inProgress
   let target = Target(id: task.id, name: task.title)
   manager.startActivity(for: target)
}

LiveActivityButtonIntent

При создании LiveActivityWidget мы пропустили создание структуры LiveActivityButtonIntent, которую создаём сейчас.


import AppIntents
import SwiftData
import ActivityKit

/// Button Intent which will update the todo status
struct LiveActivityButtonIntent: LiveActivityIntent {
    static var title: LocalizedStringResource = .init(stringLiteral: "Toggles Todo State")
    static var openAppWhenRun: Bool = false
    static var isDiscoverable: Bool = false
    
    @Parameter(title: "Todo ID") var id: String
    
    init() {
    }
    init(id: String) {
        self.id = id
    }
    
    func perform() async throws -> some IntentResult{
        /// Updating todo status
        let context = try ModelContext(.init(for: Todo.self))
        /// Retrieving Respective Todo
        let descriptor = FetchDescriptor(predicate: #Predicate {$0.id == id})
        if let todo = try context.fetch(descriptor).first {
            todo.isCompleted = true
            todo.lastUpdated = .now
            /// Saving Context
            try context.save()
            
            print("refreshing \(String(describing: id))")
            let manager = ActivityManager()
            manager.loadActivity(id)
            await manager.finishTask(id: id)
        }
        
        return .result()
    }
}

LiveActivityIntents позволяют добавить функционал в LiveActivityView, влияющий на логику приложения. В этом случае мы будем использовать LiveActivityButtonIntent чтобы перевести текущий статус задачи в Выполнено. Нажатие кнопки также обновляет задачу в приложении.

Создание интерактивных уведомлений с помощью UserNotifications

Ещё одна полезная библиотека — UserNotifications. Она позволяет показывать доступные опции при долгом нажатии на уведомление. Было вариант использовать расширение AppDelegate для реализации userNotificationCenter(_:didReceive:withCompletionHandler:), однако это слишком напоминает UIKit. Для создания интерактивного уведомления в SwiftUI мы решили использовать следующее решение: создать класс NotificationDelegate, который будет обрабатывать все действия, необходимые для создания уведомлений.


class NotificationDelegate: NSObject, ObservableObject, UNUserNotificationCenterDelegate {

    private let notificationCenter = UNUserNotificationCenter.current()
    
    @Published var activityManager: ActivityManager
    
    init(activityManager: ActivityManager) {
        self.activityManager = activityManager
    }
    
    func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
        completionHandler([.badge, .banner, .sound])
    }
    
    func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
        let userInfo = response.notification.request.content.userInfo
        guard let taskId = userInfo["id"] as? String ,
              let taskName = userInfo["task"] as? String,
              let starTime = userInfo["startTime"] as? Date,
              let duration = userInfo["duration"] as? Int
        else {
            completionHandler()
            return
        }
        switch response.actionIdentifier {
        case Constants.startActionIdentifier:
            removeAllNotifications()
            let target = Target(id: taskId, name: taskName, startTime: starTime, duration: duration)
            start(task: target)
            break
        case Constants.rescheduleActionIdentifier:
            removeAllNotifications()
            rescheduleNotification()
            break
            
        case Constants.dismissActionIdentifier:
            removeAllNotifications()
            break
            
        case UNNotificationDefaultActionIdentifier,
        UNNotificationDismissActionIdentifier:
            break
            
        default:
            break
        }
        completionHandler()
    }

}

extension NotificationDelegate {
    
    /// Create notification for task
    func createNotification(for task: Todo) {
        
        // Remove any existing notifications for this task if there are any
        removePendingNotification(identifier: task.id)
        
        let content = UNMutableNotificationContent()
        content.title = task.task.capitalized
        content.subtitle =  "It's time to complete your task!"
        content.userInfo = [
            "id": task.id,
            "task": task.task,
            "startTime": task.startDate,
            "duration": task.durationInSeconds ?? 0
        ]
        content.categoryIdentifier = Constants.timerActionableIdentifier
        
        let components = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute], from: task.startDate)
        let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: false)

        registerNotificationRequest(identifier: task.id, content: content, trigger: trigger)
    }
    
    ///  Show the notification in 5 minutes
    private func rescheduleNotification() {
        let content = UNMutableNotificationContent()
        content.title = "task.task.capitalized"
        content.subtitle =  "It's time to complete your task!"
        content.categoryIdentifier = Constants.timerActionableIdentifier
        
        // reminder notification in 5 minutes
        let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 5 * 60, repeats: false)
        registerNotificationRequest(identifier: "task.id", content: content, trigger: trigger)
    }
    
    /// Registers the notification with its corresponding actions
    private func registerNotificationRequest(identifier: String, content: UNMutableNotificationContent, trigger: UNNotificationTrigger) {
        let request = UNNotificationRequest(identifier: identifier, content: content, trigger: trigger)
        
        let startAction = UNNotificationAction(identifier: Constants.startActionIdentifier,
                                               title: "Start task",
                                               options: .foreground)
        let rescheduleAction = UNNotificationAction(identifier: Constants.rescheduleActionIdentifier,
                                                    title: "Remind me in 5 minutes",
                                                    options: .foreground)
        let dismissAction = UNNotificationAction(identifier: Constants.dismissActionIdentifier,
                                                 title: "Dismiss",
                                                 options: .destructive)
        
        let category = UNNotificationCategory(identifier: Constants.timerActionableIdentifier, actions: [startAction, rescheduleAction, dismissAction], intentIdentifiers: [])
        
        UNUserNotificationCenter.current().setNotificationCategories([category])
        UNUserNotificationCenter.current().add(request, withCompletionHandler: nil)
    }
    
    private func removeAllNotifications() {
        notificationCenter.removeAllDeliveredNotifications()
        notificationCenter.removeAllPendingNotificationRequests()
    }
    
    func removePendingNotification(identifier: String) {
        notificationCenter.removePendingNotificationRequests(withIdentifiers: [identifier])
    }
    
    /// Creates the Live Activity View for the corresponding task
    private func start(task: Target) {
        activityManager.startActivity(for: task)
    }
}

extension NotificationDelegate {
    private enum Constants {
        static let timerActionableIdentifier = "TIMER_ACTION"
        static let startActionIdentifier = "START_ACTION"
        static let rescheduleActionIdentifier = "RESCHEDULE_ACTION"
        static let dismissActionIdentifier = "DISMISS_ACTION"
    }
}

В коде выше есть три основные функции:

Первые две — это userNotificationCenter(_:willPresent:withCompletionHandler:) и userNotificationCenter(_:didReceive:withCompletionHandler:), которые нужны для того, чтобы NotificationDelegate соответствовал UNUserNotificationCenterDelegate. Первый метод отвечает за отображение уведомления, а второй — за обработку действий пользователя и выполнение бизнес-логики прямо из уведомления. В данном например, у нас реализованы действия для запуска, переноса задачи или просто закрытия уведомления.

Третий основной метод этого класса – createNotification(for:), которому мы передаём информацию о недавно созданной задаче, которая будет отображаться в уведомлении, и устанавливаем таймер, когда должно появиться уведомление.

Завершив работу над NotificationDelegate, мы можем перейти к его использованию в приложении и начать создавать интерактивные уведомления для задач. Один из примеров его использования – создание нового элемента Todo. После сохранения задачи в контексте модели мы можем просто создать уведомление для этой задачи с помощью delegate.createNotification(for todo: Todo).

Пример доступных действий через уведомление

Изображение 4 – Пример доступных действий через уведомление

Заключение

Создав всего два класса (NotificationDelegate и ActivityManager) мы смогли просто и эффективно связать логику работы уведомлений и Live Activities, при этом код остаётся чистым и легко читается, а пользователь получившегося приложения может управлять своими задачами, через нотификации, не открывая приложения.

Мир ИТ не стоит на месте постоянно появляются новые библиотеки и инструменты, успешный разработчик должен знать, как использовать эти библиотеки чтобы создавать потрясающие приложения с впечатляющими функциями и удобным пользовательским интерфейсом.

Если вы хотите взглянуть на код для этой статьи, вот его репозиторий.

Удачного кодинга!

Читайте также

Наверх