Widgets та Live Activities у iOS 26: повний посібник з WidgetKit і ActivityKit
Покроковий посібник з WidgetKit і ActivityKit для iOS 26. Зберемо віджет курсу валюти, інтерактивний віджет задач з App Intents та Live Activity для доставки з Dynamic Island і APNs push-оновленнями.
Віджети (Widgets) — це компактні представлення додатка на домашньому екрані, екрані блокування та StandBy, які будуються за допомогою WidgetKit і SwiftUI. Live Activities, натомість, це живі, оновлювані у реальному часі поверхні, побудовані на ActivityKit, що з'являються на екрані блокування та в Dynamic Island. В iOS 26 обидві технології отримали новий візуальний стиль Liquid Glass, повну підтримку інтерактивних елементів через App Intents та кращу інтеграцію зі StandBy режимом. Чесно кажучи, коли я вперше зібрав повноцінну Live Activity для доставки, найбільше здивування було саме те, наскільки прозорим став life-cycle. У цьому посібнику ми зберемо реальний віджет і Live Activity з нуля, використовуючи Swift 6.2 та Xcode 26.
WidgetKit будує статичні або оновлювані за таймлайном віджети; ActivityKit керує Live Activities з життєвим циклом до 12 годин активного стану та 4 годин у заціпенілому вигляді.
З iOS 17 віджети можуть бути інтерактивними завдяки App Intents. Кнопки Button і перемикачі Toggle всередині віджета викликають ваш AppIntent без відкриття додатка.
Dynamic Island має три презентації: compact (leading + trailing), minimal і expanded (full). Кожну описують окремим SwiftUI-в'ю у DynamicIsland-конструкторі.
Оновлення Live Activity відбувається трьома шляхами: локально через Activity.update(...), віддалено через APNs push, або з App Intents всередині віджета.
iOS 26 додає повний фон Liquid Glass до Live Activities та підтримку широких StandBy-макетів (systemLarge landscape) для віджетів на бічному екрані iPhone.
Widgets vs Live Activities: у чому різниця?
Найшвидший спосіб зрозуміти два підходи, це подумати про частоту оновлення та тривалість життя. Віджет, по суті, це знімок, який iOS відображає з набору попередньо згенерованих часових точок. Ваш додаток формує «таймлайн» (масив TimelineEntry), а система вирішує, коли рендерити наступну точку. Ви не малюєте піксель у реальному часі. Замість цього ви оголошуєте, як має виглядати екран у майбутньому.
Live Activity, навпаки, живе, поки задача триває: доставка, поїздка, спортивна гра, тренування. Ваш додаток створює ActivityAttributes і виштовхує оновлення (contentState), як тільки з'являється нова інформація. Life-time обмежений: максимум 12 годин активного стану, після чого iOS може «заморозити» активність (stale state) ще на 4 години.
Ще одна відмінність, і на неї часто натикаються новачки, це де вони живуть. Віджети живуть на домашньому екрані, Lock Screen, StandBy та Apple Watch (через окремі WidgetKit-таргети). Live Activities мешкають на Lock Screen плюс у Dynamic Island на iPhone 14 Pro і новіших. У iOS 26 обидва API отримали оформлення Liquid Glass, тож візуально грань між ними стає тоншою, але семантично це різні інструменти.
Як створити віджет у SwiftUI з WidgetKit
Кожен віджет складається з чотирьох частин: Widget (конфігурація), TimelineProvider (постачає точки часу), TimelineEntry (модель даних однієї точки) та SwiftUI-в'ю рендеру. У Xcode 26 створіть новий Target → Widget Extension, і шаблон дасть скелет.
Розглянемо приклад: віджет, що показує курс валюти. У Swift 6.2 з підходом MainActor-first (див. наш посібник з approachable concurrency у Swift 6.2) провайдер зазвичай тримається головного актора для доступу до кешованих даних.
import WidgetKit
import SwiftUI
struct RateEntry: TimelineEntry {
let date: Date
let symbol: String
let value: Decimal
}
struct RateProvider: TimelineProvider {
func placeholder(in context: Context) -> RateEntry {
RateEntry(date: .now, symbol: "USD/UAH", value: 41.20)
}
func getSnapshot(in context: Context,
completion: @escaping (RateEntry) -> Void) {
completion(placeholder(in: context))
}
func getTimeline(in context: Context,
completion: @escaping (Timeline<RateEntry>) -> Void) {
Task {
let rate = try? await RatesService.shared.latest("USD/UAH")
let entry = RateEntry(date: .now,
symbol: "USD/UAH",
value: rate ?? 0)
// Наступне оновлення через 30 хвилин
let next = Date.now.addingTimeInterval(30 * 60)
completion(Timeline(entries: [entry], policy: .after(next)))
}
}
}
struct RateWidget: Widget {
let kind = "RateWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: RateProvider()) { entry in
RateWidgetView(entry: entry)
.containerBackground(.fill.tertiary, for: .widget)
}
.configurationDisplayName("Курс валюти")
.description("Актуальний курс з оновленням кожні 30 хв.")
.supportedFamilies([.systemSmall, .systemMedium, .accessoryCircular])
}
}
Кілька важливих деталей. По-перше, containerBackground(.fill.tertiary, for: .widget) — це обов'язковий модифікатор з iOS 17+; без нього віджет не з'являтиметься у StandBy та на Lock Screen. По-друге, supportedFamilies визначає, де iOS дозволить розмістити ваш віджет. Accessory-родини нам потрібні для Lock Screen і Apple Watch. По-третє, policy: .after(_:), це підказка системі, тож iOS може оновити пізніше, якщо ви перебрали бюджет фонового виконання.
Інтерактивні віджети з App Intents у iOS 26
Починаючи з iOS 17, віджети можуть містити Button та Toggle, які запускають AppIntent без відкриття додатка. iOS 26 покращила модель: тепер інтенти можна оголошувати з @Parameter, що приймають динамічні опції з віджета, а результат може миттєво перезаписати timeline.
Уявімо кнопку «Позначити виконаним» у віджеті списку задач:
Ключова деталь, яку легко проґавити: тіло perform() виконується у процесі вашого додатка, а не віджета. iOS активує головний бінарник у фоні, викликає інтент і завершує процес. Тому, якщо у вас вже є детальніший огляд App Intents для Siri та Apple Intelligence, той самий тип можна повторно використати всередині віджета. Головне тримати його швидким (менше секунди) і без важких синхронних мережевих викликів.
Live Activities та ActivityKit: покроковий приклад
Live Activity складається з двох частин: незмінних атрибутів (ActivityAttributes), які задаються при старті, і мінливого стану (ContentState), який ви оновлюєте протягом життя активності. Розглянемо доставку піци, класичний сценарій, з яким я стикався у нещодавньому клієнтському проєкті.
import ActivityKit
struct DeliveryAttributes: ActivityAttributes {
public struct ContentState: Codable, Hashable {
var status: DeliveryStatus
var etaMinutes: Int
var courierName: String
}
var orderNumber: String
var restaurantName: String
}
enum DeliveryStatus: String, Codable {
case preparing, onTheWay, arriving, delivered
}
Створюємо активність, коли користувач розміщує замовлення:
Далі йде власне UI, який Apple очікує побачити у Widget Extension поруч зі звичайним віджетом. Тут ми оголошуємо і Lock Screen presentation, і три презентації Dynamic Island:
struct DeliveryLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: DeliveryAttributes.self) { context in
// Lock Screen / Notification Center
DeliveryLockScreenView(context: context)
.activityBackgroundTint(Color.orange.opacity(0.2))
.activitySystemActionForegroundColor(Color.orange)
} dynamicIsland: { context in
DynamicIsland {
DynamicIslandExpandedRegion(.leading) {
Label(context.attributes.restaurantName,
systemImage: "fork.knife")
}
DynamicIslandExpandedRegion(.trailing) {
Text("\(context.state.etaMinutes) хв")
.monospacedDigit()
}
DynamicIslandExpandedRegion(.bottom) {
ProgressView(value: progress(for: context.state.status))
}
} compactLeading: {
Image(systemName: "bag")
} compactTrailing: {
Text("\(context.state.etaMinutes)m")
.monospacedDigit()
} minimal: {
Image(systemName: "bag.fill")
}
}
}
private func progress(for status: DeliveryStatus) -> Double {
switch status {
case .preparing: 0.2
case .onTheWay: 0.55
case .arriving: 0.85
case .delivered: 1.0
}
}
}
Dynamic Island, як я з'ясував не з першої спроби, це не одна поверхня, а набір із трьох презентацій, які iOS показує залежно від контексту: compact (коли ваша активність єдина у фокусі), minimal (коли активних декілька) та expanded (коли користувач торкається острова). Ваш обов'язок, оголосити всі три через DynamicIsland-конструктор. Пропуск однієї з них це помилка компіляції, тож Xcode вас швидко зупинить.
Для compact-стану Apple рекомендує використовувати мінімум інформації: іконку плюс короткий числовий стан (наприклад, ETA або поточний рахунок). Максимальна ширина кожного слоту (leading, trailing) приблизно 100 pt, тож слова довші за 6-7 символів обріжуться. Використовуйте .monospacedDigit() для числових значень, щоб уникнути стрибків макета.
Expanded, це прямокутник під островом висотою до 160 pt. Тут можна класти ProgressView, кнопки з App Intents, картинки. Apple у HIG щодо Live Activities радить розмістити основну дію в trailing-регіоні і завжди тримати ліву колонку зарезервованою під ідентичність активності (лого, назва).
Minimal, це один-єдиний символ. Все, що ви туди покладете, буде обрізано до кола ~24 pt. Ідеально сюди пасує SF Symbol.
StandBy і Lock Screen: нові макети iOS 26
StandBy режим з'явився в iOS 17, а в iOS 26 він отримав широкі ландшафтні макети для iPhone Pro Max, що працюють як міні-борд. Ваш віджет автоматично отримує роль StandBy, якщо ви оголошуєте .accessoryCircular, .accessoryRectangular або новий .systemLarge у landscape (доступний з iOS 26 SDK при білді проти targetEnvironment(.standBy)).
Головна відмінність StandBy від звичайного домашнього екрану, це постійно тьмяне (dimmed) підсвічування у нічному режимі. Використовуйте widgetRenderingMode environment-змінну і в режимі .vibrant малюйте нейтральними відтінками, які добре виглядають при 5% яскравості:
struct StandBySafeView: View {
@Environment(\.widgetRenderingMode) private var mode
let value: String
var body: some View {
Text(value)
.font(.system(.title, design: .rounded, weight: .semibold))
.foregroundStyle(mode == .vibrant ? .primary : .accent)
}
}
На Lock Screen ваш віджет має ті самі accessory-родини. Різниця у тому, що система може малювати його поверх фото користувача, тож будь-який фон із високим контрастом «обріжеться». Використовуйте AccessoryWidgetBackground() як контейнер для тексту, коли хочете забезпечити читабельність.
Є три канали оновлення даних, і вибір залежить від сценарію. Для віджетів з передбачуваним ритмом (погода, курси валют, розклад) підходить TimelineProvider з policy: .after(_:). Для реактивних оновлень (нова задача, зміна балансу) варто взяти WidgetCenter.shared.reloadTimelines(ofKind:) з основного додатка або з App Intent.
Для Live Activities з непередбачуваними змінами (доставка, спорт) залишається APNs. Це справжній push, який приходить навіть коли додаток не запущений. Формат payload з iOS 16.2:
На фінальному етапі надішліть "event": "end", щоб коректно закрити активність. iOS автоматично приховає її з Lock Screen після dismissalDate.
Часті помилки та як їх усунути
Віджет не з'являється у StandBy. Найчастіша причина, це відсутній containerBackground(_:for:). Без нього iOS 17+ вважає віджет несумісним із новими середовищами і ховає його. Друга причина, ви не додали .accessoryCircular у supportedFamilies.
Live Activity не оновлюється через push. Перевірте три речі: apns-topic, environment (development vs production сертифікати) та pushType при створенні активності (має бути .token, а не .channel, якщо ви не використовуєте broadcast).
Інтерактивний Button у віджеті не спрацьовує. Це майже завжди означає, що ваш AppIntent живе всередині widget-таргета, а не в основному додатку. Створіть його у shared-фреймворку і додайте до обох цілей.
Dynamic Island обрізає текст. У compact-режимі слоти вузькі, тож використовуйте .minimumScaleFactor(0.7) або короткі формати ("22m" замість "22 хвилини").
Live Activity зникає раніше, ніж очікуєте. Ви пропустили staleDate в ActivityContent. Якщо він у минулому, iOS негайно вважає активність застарілою і показує «замерзлий» стан.
Часті питання
У чому різниця між віджетом і Live Activity?
Віджет це знімок, який iOS рендерить із таймлайну і оновлює за розкладом (хвилини або години). Live Activity це живе представлення поточної задачі (доставки, гри, поїздки), яке живе на Lock Screen і в Dynamic Island і оновлюється кожні секунди або хвилини протягом кількох годин.
Як зробити віджет інтерактивним у iOS 26?
Додайте Button(intent:) або Toggle(isOn:intent:) у SwiftUI-в'ю віджета, де intent це ваш AppIntent-тип. iOS запустить процес додатка у фоні, виконає perform() і після цього ви викликаєте WidgetCenter.shared.reloadTimelines(ofKind:), щоб перемалювати віджет.
Скільки часу може жити Live Activity?
До 12 годин у активному стані та ще до 4 годин у застарілому (stale) стані, після чого iOS автоматично закриває її. Ви можете завершити активність раніше через activity.end(_:dismissalPolicy:).
Чи потрібен окремий APNs сертифікат для Live Activities?
Ні, використовується той самий APNs, але з іншим topic (<bundle-id>.push-type.liveactivity) і з push-токеном, який ви отримуєте від конкретної активності через activity.pushToken, а не токеном пристрою.
Чи підтримує Apple Watch WidgetKit?
Так, з watchOS 9+ WidgetKit будує ускладнення (complications) на циферблатах. Використовуйте .accessoryCircular, .accessoryRectangular, .accessoryInline. Ці ж родини одночасно працюють і на Lock Screen iPhone.
Повний посібник з App Intents у Swift для iOS 26: створення намірів, AppEntity, EntityQuery, App Shortcuts та інтеграція з Apple Intelligence через AssistantSchemas.
Swift 6.2 представив InlineArray та Span — два типи для високопродуктивного коду. InlineArray зберігає елементи на стеку без heap-алокацій, а Span надає безпечний доступ до суміжної пам'яті. Синтаксис, практичні приклади та сценарії використання.
Покроковий посібник із наслідування моделей у SwiftData для iOS 26. Створення підкласів, предикати з перевіркою типів, міграція схеми через VersionedSchema та оптимізація запитів через FetchDescriptor із робочими прикладами коду.