Live Activities и Dynamic Island в SwiftUI: гайд по ActivityKit и ContentState (iOS 26)

Разберём Live Activities и Dynamic Island в SwiftUI: как устроен ActivityKit, что такое ActivityAttributes и ContentState, как обновлять активность через APNs, и что нового в iOS 26 от pushToStartToken до Liquid Glass в Lock Screen.

Live Activities SwiftUI: ActivityKit iOS 26

Обновлено: 6 июля 2026

Live Activities в SwiftUI — это временные интерактивные представления, которые отображают состояние текущего события на экране блокировки и в Dynamic Island с помощью фреймворка ActivityKit. В отличие от виджетов, Live Activity живёт ровно столько, сколько длится реальное событие (доставка, тренировка, поездка, таймер), и обновляется через Activity.update(using:) из приложения или через ActivityKit push‑уведомления, когда приложение не запущено. В iOS 26 добавились нюансы вокруг pushToStartToken, синхронизации с watchOS 11 и macOS 26, а также новых макросов #Preview для предпросмотра всех состояний Dynamic Island прямо в Xcode 16.

  • Live Activity: SwiftUI‑представление внутри Widget Extension, управляемое ActivityKit; работает на iOS 16.1+, а Dynamic Island доступен только на iPhone 14 Pro и новее.
  • ActivityAttributes хранит статические данные, а вложенный ContentState отвечает за динамические, которые обновляются через API или APNs.
  • Dynamic Island имеет четыре презентации: compact leading, compact trailing, minimal и expanded. Все обязательны в ActivityConfiguration.
  • Полезная нагрузка обновления не должна превышать 4 КБ; активность живёт до 8 часов, если не завершена вручную через Activity.end(...).
  • Для таймеров используйте Text(timerInterval:): обратный отсчёт считает система, батарея не расходуется.
  • В iOS 26 добавлены pushToStartTokenUpdates для стартовых push‑уведомлений и автоматическое отображение активностей на paired watchOS 11+ и macOS 26+.

Что такое Live Activity и как её отличать от виджета

Live Activity по сути своей временный, «событийный» интерфейс, который показывает пользователю прогресс происходящего в реальном времени, пока он не открыл приложение. Технически это SwiftUI‑представление, объявленное внутри Widget Extension через тип ActivityConfiguration. От обычного виджета его отличают три ключевые вещи: жизненный цикл (Live Activity существует, только пока идёт событие), способ обновления (моментальные апдейты через ActivityKit или APNs, а не тайм‑лайн) и место показа (экран блокировки, StandBy и Dynamic Island на совместимых устройствах).

В моей практике этот паттерн лучше всего ложится на сценарии, где у события есть чёткое начало и конец: ожидание курьера, спортивный матч, поездка, таймер приготовления кофе, отсчёт до вылета. Виджет здесь бесполезен, он обновляется по таймлайну и не умеет реагировать на пуш. А обычное уведомление не годится, потому что состояние меняется десятки раз. Live Activity закрывает именно эту нишу: она заменяет цепочку notification‑ов одним живым представлением.

Есть важный нюанс, о котором забывают в туториалах. Dynamic Island не отдельный API. Это способ, которым система рендерит вашу Live Activity, если устройство поддерживает вырез (iPhone 14 Pro и новее). На iPhone SE или на iPad та же самая активность просто покажется на экране блокировки, а «остров» будет пуст. Значит, никогда не полагайтесь на то, что пользователь увидит именно расширенный вид: представление на экране блокировки обязательно должно быть самодостаточным.

Настройка проекта и Widget Extension

Прежде чем писать код, добавьте в проект Widget Extension: File → New → Target → Widget Extension. Xcode 16 предложит галочку «Include Live Activity». Оставьте её включённой, тогда шаблон сразу создаст файл со структурой ActivityAttributes и заготовкой ActivityConfiguration. Одна и та же widget bundle может содержать и обычные виджеты, и Live Activities: они делят одну extension.

А теперь про самую частую ошибку новичков. В Info.plist основного приложения (не в plist расширения!) добавьте ключ NSSupportsLiveActivities со значением YES. Без этого ActivityAuthorizationInfo().areActivitiesEnabled всегда вернёт false, и вызов Activity.request(...) тихо упадёт. Если планируете отправлять больше одного обновления в секунду, добавьте ещё NSSupportsLiveActivitiesFrequentUpdates: YES, иначе система быстро загонит вас в throttling‑бюджет.

Ещё одна тонкость с iOS 17.2+: если хотите запускать активность удалённо (без открытия приложения), нужно объявить push‑тип .token и подготовить APNs‑ключ с активированным сервисом ActivityKit, как описано в официальной документации Apple по ActivityKit push notifications. Разберём это подробнее в разделе про пуши.

ActivityAttributes и ContentState: моделирование данных

Ядро ActivityKit, протокол ActivityAttributes. Данные модели чётко разделяются на две категории: неизменяемые за время жизни активности (номер заказа, имя ресторана) и динамические (ETA, статус курьера, координаты). Динамические поля выносятся во вложенный тип ContentState. Такое разделение критично: статические данные передаются один раз при запуске, а ContentState сериализуется в каждый апдейт и должен помещаться в лимит 4 КБ.

Модель для доставки еды выглядит так:

import ActivityKit
import Foundation

struct DeliveryAttributes: ActivityAttributes {
    // Статические данные, известны в момент старта, не меняются
    let orderId: String
    let restaurantName: String

    // Динамические данные, обновляются по ходу события
    struct ContentState: Codable, Hashable {
        var status: Status
        var estimatedArrival: Date
        var courierName: String?

        enum Status: String, Codable, Hashable {
            case preparing, onTheWay, arriving, delivered
        }
    }
}

Два правила, которые я вывел за годы работы с ActivityKit. Первое: держите ContentState плоским и без вложенных массивов. Их сериализация быстро упирается в 4 КБ, особенно если вы кодируете списки блюд или маршрут. Если нужно передать «богатое» состояние, отправляйте только идентификатор, а данные читайте из App Group. Второе: используйте Date и полагайтесь на Text(timerInterval:) / Text(_:style: .relative) для отсчёта времени. Обновлять поле ContentState раз в секунду ради часов? Это самый быстрый способ спалить процентов пять батареи за час (я эту ошибку в первом проде поймал, до сих пор помню отзывы в поддержке).

ActivityConfiguration: четыре презентации Dynamic Island

В отличие от обычного виджета, Live Activity требует объявить сразу пять SwiftUI‑представлений: одно для экрана блокировки и четыре для Dynamic Island. Все они собираются в одном ActivityConfiguration:

import ActivityKit
import SwiftUI
import WidgetKit

struct DeliveryLiveActivity: Widget {
    var body: some WidgetConfiguration {
        ActivityConfiguration(for: DeliveryAttributes.self) { context in
            // Экран блокировки и StandBy
            LockScreenView(state: context.state, attributes: context.attributes)
                .padding()
                .activityBackgroundTint(.black.opacity(0.5))
        } dynamicIsland: { context in
            DynamicIsland {
                DynamicIslandExpandedRegion(.leading) {
                    Label(context.attributes.restaurantName, systemImage: "fork.knife")
                }
                DynamicIslandExpandedRegion(.trailing) {
                    Text(context.state.estimatedArrival, style: .timer)
                        .monospacedDigit()
                        .frame(width: 60)
                }
                DynamicIslandExpandedRegion(.bottom) {
                    ProgressView(value: context.state.status.progress)
                        .tint(.orange)
                }
            } compactLeading: {
                Image(systemName: "bicycle")
            } compactTrailing: {
                Text(context.state.estimatedArrival, style: .timer)
                    .monospacedDigit()
                    .frame(width: 40)
            } minimal: {
                Image(systemName: "bicycle")
            }
            .keylineTint(.orange)
        }
    }
}

Разберём, кто когда показывается. Compact (leading + trailing): активна ровно одна Live Activity, и это два маленьких элемента слева и справа от выреза. Minimal: активностей несколько, и вторая ужимается до кружка на «отделившемся» островке. Expanded: пользователь долго нажимает на остров, и открывается развёрнутое представление с четырьмя регионами (leading, trailing, center, bottom). А Lock Screen View — это самостоятельное представление на экране блокировки и в StandBy.

Отдельно про дизайн. Если хотите выдержать язык iOS 26, посмотрите наше руководство по Liquid Glass в SwiftUI: модификатор .glassEffect() отлично работает и в Live Activity, придавая карточке нужную глубину без утяжеления рендера.

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

Запуск активности возможен только из foreground‑состояния приложения (либо через push‑to‑start, о нём ниже). Код минимальный:

import ActivityKit

@MainActor
enum DeliveryActivityManager {
    static func start(orderId: String, restaurant: String) throws -> Activity<DeliveryAttributes> {
        guard ActivityAuthorizationInfo().areActivitiesEnabled else {
            throw ActivityError.notAuthorized
        }
        let attributes = DeliveryAttributes(orderId: orderId, restaurantName: restaurant)
        let state = DeliveryAttributes.ContentState(
            status: .preparing,
            estimatedArrival: .now.addingTimeInterval(35 * 60),
            courierName: nil
        )
        return try Activity.request(
            attributes: attributes,
            content: .init(state: state, staleDate: .now.addingTimeInterval(60 * 60)),
            pushType: .token
        )
    }

    static func update(_ activity: Activity<DeliveryAttributes>, to state: DeliveryAttributes.ContentState) async {
        await activity.update(.init(state: state, staleDate: .now.addingTimeInterval(30 * 60)))
    }

    static func end(_ activity: Activity<DeliveryAttributes>, final: DeliveryAttributes.ContentState) async {
        await activity.end(.init(state: final, staleDate: nil), dismissalPolicy: .after(.now + 60))
    }

    enum ActivityError: Error { case notAuthorized }
}

Три параметра, которые люди часто игнорируют. staleDate: это момент, после которого система пометит контент как устаревший и слегка приглушит его; ставьте разумный, обычно на 15–30 минут вперёд. dismissalPolicy: как долго активность будет висеть после end(). Значения: .immediate уберёт сразу, .default оставит на 4 часа, а .after(Date) держит до указанного момента. Параметр pushType: .token просит систему выдать нам push‑token, без него удалённые апдейты невозможны.

Для наблюдения за изменениями состояния воспользуйтесь async‑последовательностями Activity.activityUpdates и activity.contentUpdates. Они прекрасно ложатся на структурированную конкурентность. Если тема async/await пока в тумане, начните с нашего разбора конкурентности в Swift 6.

Как обновлять Live Activity через push‑уведомления

В реальном приложении обновления почти всегда прилетают с сервера. Схема такая: клиент при старте активности получает pushToken, отправляет его в бэкенд, а бэкенд шлёт APNs‑пуш типа liveactivity. На клиенте достаточно подписаться на pushTokenUpdates:

Task {
    for await tokenData in activity.pushTokenUpdates {
        let hex = tokenData.map { String(format: "%02x", $0) }.joined()
        await BackendAPI.registerLiveActivity(id: activity.id, token: hex)
    }
}

На стороне сервера payload для обновления выглядит так:

{
  "aps": {
    "timestamp": 1751808000,
    "event": "update",
    "content-state": {
      "status": "onTheWay",
      "estimatedArrival": 751809800,
      "courierName": "Игорь"
    },
    "stale-date": 1751811600,
    "alert": {
      "title": "Курьер уже в пути",
      "body": "Прибудет примерно через 30 минут"
    }
  }
}

Ключевые правила APNs для Live Activities: заголовок apns-push-type: liveactivity, apns-topic в форме bundle-id.push-type.liveactivity, приоритет 10 для мгновенных апдейтов или 5 для «фоновых». Размер payload тоже до 4 КБ. Ключ content-state должен структурно совпадать с вашим ContentState, иначе декодер молча выбросит апдейт, и активность зависнет с прошлыми данными.

С iOS 17.2 появился «push‑to‑start»: сервер может запустить активность, даже если приложение не в foreground. Для этого подпишитесь на Activity<DeliveryAttributes>.pushToStartTokenUpdates, отправьте токен на бэкенд и шлите пуш с event: "start" и включёнными attributes. Судя по обсуждениям на Apple Developer Forums, в iOS 26.5 pushToStartToken иногда остаётся nil после переустановки приложения. Обходится вызовом токена в application(_:didFinishLaunchingWithOptions:) и заново при первом переходе в active‑состояние. Я эту багу поймал в проде на переустановке через TestFlight, так что имейте в виду.

Интерактивность через App Intents в iOS 17.2+

Начиная с iOS 17.2, кнопки и Toggle внутри Live Activity могут выполнять App Intents прямо из Dynamic Island и с экрана блокировки. Это превратило Live Activity из «просто индикатора» в мини‑приложение. Типичный случай: кнопка «Пауза» в таймере или «Отменить» в заказе.

import AppIntents

struct CancelOrderIntent: AppIntent {
    static var title: LocalizedStringResource = "Отменить заказ"
    @Parameter(title: "Order ID") var orderId: String

    func perform() async throws -> some IntentResult {
        try await OrderService.shared.cancel(id: orderId)
        return .result()
    }
}

// В ActivityConfiguration:
DynamicIslandExpandedRegion(.bottom) {
    Button(intent: CancelOrderIntent(orderId: context.attributes.orderId)) {
        Label("Отменить", systemImage: "xmark.circle")
    }
    .tint(.red)
}

Ограничения интерактивности: интент выполняется в фоновом процессе, у него нет доступа к UI родительского приложения, а полный обход состояния активности должен произойти в течение примерно 10 секунд. Если операция дольше, используйте фоновую задачу и обновите ContentState отдельно. Подробнее о App Intents и их интеграции с системой читайте в нашем разборе App Intents в Swift и iOS 26.

Ограничения, ошибки и практические советы

Перечислю ограничения ActivityKit, которые чаще всего валят проекты в первой итерации.

  • 4 КБ на полезную нагрузку. Это касается и локальных апдейтов, и push‑payload'ов. Изображения в base64 не поместятся, храните ассеты в App Group и передавайте только идентификаторы.
  • 8 часов жизни. После этого система сама завершает активность. Для «долгоживущих» сценариев (многодневная доставка) придётся запускать новую активность после завершения предыдущей.
  • Нет сети внутри extension. Live Activity бежит в отдельном sandbox без сети, GPS и таймеров. Всё обновление данных идёт либо из основного приложения через Activity.update, либо через APNs.
  • Нельзя запустить из фона без push‑to‑start. До iOS 17.2 старт активности возможен только из foreground; с 17.2+ через pushToStartToken.
  • Никаких Timer и onAppear { … } с побочными эффектами. Представление активности перерисовывается системой асинхронно и может быть отброшено. Отправляйте данные наружу через App Intent'ы, а не в модификаторах SwiftUI.

Ещё один недооценённый момент: превью в Xcode 16. Раньше приходилось запускать симулятор и вручную стартовать активность, чтобы увидеть Dynamic Island. Теперь есть макрос:

#Preview("Compact", as: .dynamicIsland(.compact), using: DeliveryAttributes(orderId: "1", restaurantName: "Пироги")) {
    DeliveryLiveActivity()
} contentStates: {
    DeliveryAttributes.ContentState(status: .onTheWay, estimatedArrival: .now.addingTimeInterval(600), courierName: "Игорь")
}

Доступные значения as: следующие: .dynamicIsland(.compact), .dynamicIsland(.expanded), .dynamicIsland(.minimal), .content (для Lock Screen). Один макрос покрывает все четыре презентации, и это радикально ускоряет итерации по вёрстке: без запуска симулятора можно проверить, что compact‑trailing умещается в 40 pt, а expanded не «съезжает» на длинных именах.

Что нового в Live Activities в iOS 26

С iOS 26 ActivityKit получил несколько важных апгрейдов, которые стоит учитывать при новых проектах. Во‑первых, автоматическая репликация на paired watchOS 11+ и macOS 26+. Ваша активность, запущенная на iPhone, без дополнительного кода отобразится на Apple Watch и на M‑Mac (если пользователь разрешил mirroring). Единственное требование: Lock Screen представление должно оставаться читабельным при размере до 176×176 pt, потому что часы обрежут его до этой формы.

Во‑вторых, улучшенный pushToStartTokenUpdates. Токен теперь выдаётся даже при полностью завершённом приложении, что раньше работало нестабильно. Это меняет паттерн: раньше сервер посылал пуш, только если знал, что клиент «жив»; теперь можно инициировать активность из бэкенда всегда, например автоматически при отгрузке заказа со склада.

В‑третьих, интеграция с Liquid Glass. Модификатор .glassEffect(), введённый в SwiftUI iOS 26, работает и в LockScreenView, и в expanded‑регионах Dynamic Island. При правильном подборе фона (тёмный полупрозрачный плюс материал) активность визуально «сливается» с системным UI. Для деталей стилизации я делал отдельный разбор Liquid Glass в SwiftUI, рекомендую пройти его перед проектированием представлений.

Наконец, прогнозируемый лимит апдейтов. Apple убрала неявное «магическое число» и теперь публикует бюджет через новое поле ActivityContent.updatesUsed. Читайте его перед каждым апдейтом и, если бюджет подходит к концу, сгруппируйте несколько изменений в один вызов. Для сложных сценариев сверьтесь с актуальным разделом официальной документации ActivityKit. Apple исправила ряд формулировок вокруг throttling именно в iOS 26.

Часто задаваемые вопросы

Чем Live Activity отличается от виджета?

Виджет: постоянный компонент домашнего экрана, обновляемый по расписанию тайм‑лайна. Live Activity: временное представление, существующее только пока идёт реальное событие, и обновляемое моментально через ActivityKit или APNs. Виджет живёт «всегда», Live Activity живёт «пока актуальна».

Как обновлять Live Activity через push‑уведомления?

Запросите активность с pushType: .token, подпишитесь на activity.pushTokenUpdates и отправьте токен на сервер. Сервер шлёт APNs‑пуш с заголовком apns-push-type: liveactivity и полем content-state, структурно совпадающим с вашим ActivityAttributes.ContentState. Полезная нагрузка до 4 КБ.

Как включить Live Activities в приложении?

Добавьте Widget Extension, объявите ActivityAttributes и ActivityConfiguration, а в Info.plist основного приложения выставьте NSSupportsLiveActivities в YES. Пользователь должен разрешить Live Activities в настройках приложения; проверяйте разрешение через ActivityAuthorizationInfo().areActivitiesEnabled.

Сколько времени может работать одна Live Activity?

До 8 часов с момента запуска, если её не завершить вручную. После этого система автоматически вызывает эквивалент end. Для «долгих» сценариев (многодневная доставка) необходимо стартовать новую активность после завершения предыдущей, а сохранение состояния при этом ложится на бэкенд.

Работает ли Dynamic Island без Live Activity?

Нет. Dynamic Island — это способ системного рендеринга Live Activities и стандартных индикаторов iOS (микрофон, зарядка, TrueDepth). Отдельного публичного API «просто нарисовать что‑то в острове» не существует; вы всегда работаете через ActivityKit и ActivityConfiguration.

Lukas Müller
Об авторе Lukas Müller

iOS developer and Swift author since the Objective-C days. Spends his evenings on side projects and his mornings on SwiftUI internals.