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 — это временные интерактивные представления, которые отображают состояние текущего события на экране блокировки и в 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:
Разберём, кто когда показывается. 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, о нём ниже). Код минимальный:
Три параметра, которые люди часто игнорируют. 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 из «просто индикатора» в мини‑приложение. Типичный случай: кнопка «Пауза» в таймере или «Отменить» в заказе.
Ограничения интерактивности: интент выполняется в фоновом процессе, у него нет доступа к 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. Теперь есть макрос:
Доступные значения 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.
Полное руководство по Swift Macros в Xcode 16: разница freestanding и attached, семь ролей, как создать пакет с swift-syntax, протестировать через assertMacroExpansion и не замедлить сборку. С примерами #stringify и @AutoCodable.
Liquid Glass в SwiftUI: разбираем .glassEffect(), GlassEffectContainer, миграцию интерфейса с Material, accessibility и производительность на устройствах iOS 26.
Полное руководство по Swift Charts в SwiftUI для iOS 26: типы графиков, кастомизация осей, интерактивный выбор, скроллируемые графики, 3D-визуализация Chart3D, анимации и оптимизация производительности с реальными примерами кода Swift 6.