Опыт использования 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.
Изображение 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)
}
При создании 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, при этом код остаётся чистым и легко читается, а пользователь получившегося приложения может управлять своими задачами, через нотификации, не открывая приложения.
Мир ИТ не стоит на месте постоянно появляются новые библиотеки и инструменты, успешный разработчик должен знать, как использовать эти библиотеки чтобы создавать потрясающие приложения с впечатляющими функциями и удобным пользовательским интерфейсом.
Если вы хотите взглянуть на код для этой статьи, вот его репозиторий.
Удачного кодинга!


