Live Activity и Dynamic Island

Как создать, обновлять и завершить Live Activity. Интерфейс Live Activity. Как работать с Dynamic Island.

Поможем в чате для iOS разработчиков.

Live Activity объединяют пуш-уведомления в один интерактивный баннер. Представим приложение для вызова такси — какие там могут быть пуши? «Водитель едет», «Водитель уже рядом» и «Водитель ждёт». С новым инструментом разработчики смогут объединить пуши в Live Activity и обновлять её.

 Про Dynamic Island в iOS.
Про Dynamic Island в iOS.
Live Activity доступны с iOS 16.1 и Xcode 14.1.

Live Activity не виджет — у неё нет таймлайнов и обновлений по времени. Основной способ обновления — как раз пуши. Способы обновления разберём в секции Как обновить и завершить Live Activity.

 Compact и Expanded Live Activity.
Compact и Expanded Live Activity.

Live Activity показываются на устройствах с Dynamic Island и без него. На заблокированном экране это будет похоже на обычное пуш-уведомление. А для устройств с Dynamic Island Live Activity показывается вокруг камер.

Добавляем Live Activity в проект

Live Activity используют фреймворк ActivityKit. Живут Live Activity в таргете виджета:

 Добавляем таргет WidgetKit в проект.
Добавляем таргет WidgetKit в проект.

Перейдите в таргет и оставьте код:

@main
struct LiveActivityWidget: Widget {

    let kind: String = "LiveActivityWidget"

    var body: some WidgetConfiguration {
        StaticConfiguration(kind: kind, provider: Provider()) { entry in
            widgetEntryView(entry: entry)
        }
        .configurationDisplayName("My Widget")
        .description("This is an example widget.")
    }
}
Если у вас уже есть виджеты, используйте WidgetBundle, чтобы определить несколько Widget.

В Info.plist добавьте атрибут Supports Live Activities:

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

StaticConfiguration используется для виджетов и компликейшнов. Скоро мы заменим его на другой, но сначала определим модель данных.

Определяем модель данных

Live Activity создаётся в самом приложении, а модель будет использоваться и в приложении, и в виджете. Поэтому хорошо бы сделать один класс и пошарить его между таргетами. Создайте новый файл для модели. Для этого наследуемся от ActivityAttributes:

import ActivityKit

struct ActivityAttribute: ActivityAttributes {
    
    public struct ContentState: Codable, Hashable {
        
        // Динамические данные
        
        var dynamicStringValue: String
        var dynamicIntValue: Int
        var dynamicBoolValue: Bool
    }
    
    // Статические данные
    
    var staticStringValue: String
    var staticIntValue: Int
    var staticBoolValue: Bool
}

В структуре ContentState определяем динамические данные — они будут меняться и обновлять UI. За пределами ContentState статические данные, они доступны только при создании Live Activity.

Пошарьте файл между двумя таргетами, для этого в инспекторе справа выберите главный таргет приложения и таргет виджета:

 Файл будет доступен в главном и виджет-таргетах.
Файл будет доступен в главном и виджет-таргетах.

UI

В объекте LiveActivityWidget поменяйте конфигурацию на ActivityConfiguration:

struct LiveActivityWidget: Widget {
    
    let kind: String = "LiveActivityWidget"
    
    var body: some WidgetConfiguration {
        ActivityConfiguration(for: ActivityAttribute.self) { context in
            // Здесь UI для активити на заблокированном экране
        } dynamicIsland: { context in
            // Здесь UI для Dynamic Island
        }
    }
}

У нас есть два замыкания, первое — для UI на заблокированном экране, второе — для динамического острова. Обратите внимание, указываем класс атрибутов ActivityAttribute.self — это модель данных, которую определили выше.

В Live Activity игнорируются модификаторы анимаций.

Lock Screen

Эта View показывается на заблокированном экране. Все инструменты для виджетов доступны в Live Activity. Укажите проперти context, чтобы передать модель данных:

struct LockScreenLiveActivityView: View {

    let context: ActivityViewContext<ActivityAttribute>
    
    var body: some View {
        VStack {
            Text("Dyanmic String: (context.state.dynamicStringValue))")
            Text("Static String: (context.staticStringValue))")
        }
        .activitySystemActionForegroundColor(.indigo)
        .activityBackgroundTint(.cyan)
    }
}
Максимальная высота Live Activity на Lock Screen — 160 точек.

В примере я распечатал и динамические, и статические проперти из ActivityAttribute. Давайте укажем вью в виджете:

struct LiveActivityWidget: Widget {
    
    let kind: String = "LiveActivityWidget"
    
    var body: some WidgetConfiguration {
        ActivityConfiguration(for: ActivityAttribute.self) { context in
            LockScreenLiveActivityView(context: context)
        } dynamicIsland: { context in
            // Здесь UI для Dynamic Island
        }
    }
}

Dynamic Island

У динамического острова есть 3 вида: компактный, минимальный и развёрнутый.

Углы динамического острова закруглили в 44 точки. Это соответствует закруглению камеры TrueDepth.

Compact & Minimal

Если запущена одна активность, то контент можно разместить слева и справа от динамического острова.

 Compact Live Activity в Dynamic Island.
Compact Live Activity в Dynamic Island.

Если запущено несколько Live Activity, система выберет две из них. Одна будет показываться слева, она прикреплена к острову, а другая справа — отделённая от острова в кружке.

 Minimal Live Activity в Dynamic Island.
Minimal Live Activity в Dynamic Island.

Так выглядит код для каждого варианта отображения:

DynamicIsland {
    // Здесь будет код для развёрнутого вида.
    // Его разберем в след. пункте.
} compactLeading: {
    Text("Leading")
} compactTrailing: {
    Text("Trailing")
} minimal: {
    Text("Min")
}

Expanded

Развёрнутая Live Activity показывается, когда человек нажимает и удерживает компатный или минимальный вид. Когда Live Activity обновляется, развёрнутый вид появляется автоматически на пару секунд.

 Expanded Live Activity в Dynamic Island.
Expanded Live Activity в Dynamic Island.

А вот код для развёрнутого вида. Каждое замыкание определяет область на Live Activity.

DynamicIslandExpandedRegion(.center) {}
DynamicIslandExpandedRegion(.leading) {}
DynamicIslandExpandedRegion(.trailing) {}
DynamicIslandExpandedRegion(.bottom) {}

Разметка областей:

 Области Dynamic Island.
Области Dynamic Island.
  1. center — контент под камерой.
  2. leading — пространство от левого угла до камеры. Если использовать вертикальный стек, будет доступно пространство ниже.
  3. trailing — аналогично leading, но для правого края.
  4. bottom — контент под всеми другими областями.

Если контент в левой и правой областях не помещается, можно объединить его с Bottom. Область будет адаптивная, на скриншоте сейчас максимальные размеры:

 Если не хватает места, области Dynamic Island можно объединить.
Если не хватает места, области Dynamic Island можно объединить.

Чтобы разрешить области использовать пространство ниже, укажите verticalPlacement:

DynamicIslandExpandedRegion(.leading) {
    Text("Leading Text with merge region")
        .dynamicIsland(verticalPlacement: .belowIfTooWide)
}
Максимальная высота Live Activity на Dynamic Island 160 точек.

Как добавить новую Live Activity

Live Activity можно создать только внутри приложения. Обновить и закончить Live Activity можно и внутри приложения, и по пуш-уведомлению.

Сначала проверьте доступность Live Activity — пользователь мог запретить их. Вторая причина недоступности — в системе достигнут лимит. Чтобы проверить, используем код:

guard ActivityAuthorizationInfo().areActivitiesEnabled else {
    print("Activities are not enabled")
    return
}

Можно отслеживать статус:

for await enabled in ActivityAuthorizationInfo().activityEnablementUpdates {
    // Здесь ваш код
}

Чтобы создать новую Live Activity, создайте атрибуты и после вызовите request:

let attributes = ActivityAttribute(...)
let contentState = ActivityAttribute.ContentState(...)
do {
    let activity = try Activity<ActivityAttribute>.request(
        attributes: attributes,
        contentState: contentState
    )
} catch {
    print("LiveActivityManager: Error in LiveActivityManager: (error.localizedDescription)")
}

Обратите внимание - здесь разделились статические и динамические проперти на два объекта.

Список активных Live Activity

Чтобы получить уже созданные Live Activity, укажите модель атрибутов:

for activity in Activity<ActivityAttribute>.activities {
    print("Activity details: (activity.contentState)")
}

Обновить и завершить Live Activity

Обновлять и завершать Live Activity можно только с динамическими параметрами — Content State.

Размер обновления Content State должен быть меньше 4KB.

Внутри приложения

Как обновить Live Activity из приложения:

// Новые данные
let contentState = ActivityAttribute.ContentState(...)

Task {    
    await activity?.update(using: contentState)   
}

Чтобы завершить Live Activity, вызовите:

await activity?.end(dismissalPolicy: .immediate)

Live Activity закроется сразу. А вот как сделать, чтобы Live Activity осталась ещё некоторое время на экране:

await activity?.end(using: attributes, dismissalPolicy: .default)

Live Activity обновится финальными данными и будет на экране ещё некоторое время. Система закроет активность через 4 часа или когда убедится, что пользователь увидел новые данные. Зависит от того, что наступит раньше.

У Live Activity нет таймлайна, как для виджетов. Для обновления или закрытия Live Activity — когда приложение в фоне — используйте Background Tasks.

Background Tasks не гарантируют своевременного выполнения.

Через push-уведомления

При создании Live Activity получаем pushToken. Он используется, чтобы обновлять Live Activity через пуш-уведомления.

Предварительно зарегистрируйте приложение для получения пушей.

Сформируем пуш для обновления Live Activity. Заголовки:

apns-topic: {Your App Bundle ID}.push-type.liveactivity
apns-push-type: liveactivity
authorization: bearer {Auth Token}

Тело:

"aps": {
    "timestamp": 1168364460,
    "event": "update", // or end
    "content-state": {
        "dynamicStringValue": "New String Value"
        "dynamicIntValue": 5
        "dynamicBoolValue": true
    },
    "alert": {
        "title": "Title of classic Push",
        "body": "Body or classic push",
    }
}

Словарь content-state должен совпадать с моделью атрибутов ActivityAttribute.ContentState. Мы можем обновлять только динамические проперти. Проперти не в Content State обновить не получится.

Отследить нажатие на Live Activity

По нажатию на Live Activity хорошо открывать релевантный экран, для этого реализуйте Deep Link. Установите модификатор widgetURL(_:). Можно задать разные ссылки для каждой области:

DynamicIslandExpandedRegion(.leading) {
    Text("Leading Text with merge region")
        .widgetURL(URL(string: "example://action"))
}

Развёрнутый вид Dynamic Island поддерживает Link.