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.

Apple отвечает на 10 вопросов про Live Activity
Поправить или дополнить статью через Pull Request