Widgets та Live Activities у iOS 26: повний посібник з WidgetKit і ActivityKit

Покроковий посібник з WidgetKit і ActivityKit для iOS 26. Зберемо віджет курсу валюти, інтерактивний віджет задач з App Intents та Live Activity для доставки з Dynamic Island і APNs push-оновленнями.

iOS 26 Widgets і Live Activities Гайд

Оновлено: 14 липня 2026

Віджети (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.

Уявімо кнопку «Позначити виконаним» у віджеті списку задач:

import AppIntents
import WidgetKit

struct ToggleTaskIntent: AppIntent {
    static var title: LocalizedStringResource = "Позначити задачу"

    @Parameter(title: "ID задачі")
    var taskID: String

    init() {}
    init(taskID: String) { self.taskID = taskID }

    func perform() async throws -> some IntentResult {
        try await TaskStore.shared.toggle(id: taskID)
        // Просимо WidgetKit перерахувати таймлайн
        WidgetCenter.shared.reloadTimelines(ofKind: "TasksWidget")
        return .result()
    }
}

struct TasksWidgetView: View {
    let entry: TasksEntry
    var body: some View {
        VStack(alignment: .leading) {
            ForEach(entry.tasks) { task in
                HStack {
                    Button(intent: ToggleTaskIntent(taskID: task.id)) {
                        Image(systemName: task.done ? "checkmark.circle.fill" : "circle")
                    }
                    .buttonStyle(.plain)
                    Text(task.title)
                        .strikethrough(task.done)
                }
            }
        }
        .padding()
        .containerBackground(.fill.tertiary, for: .widget)
    }
}

Ключова деталь, яку легко проґавити: тіло 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
}

Створюємо активність, коли користувач розміщує замовлення:

@MainActor
func startDeliveryActivity(order: Order) throws {
    let attributes = DeliveryAttributes(
        orderNumber: order.number,
        restaurantName: order.restaurant
    )
    let initialState = DeliveryAttributes.ContentState(
        status: .preparing,
        etaMinutes: 35,
        courierName: "..."
    )
    let content = ActivityContent(
        state: initialState,
        staleDate: Date.now.addingTimeInterval(60 * 45)
    )
    _ = try Activity.request(
        attributes: attributes,
        content: content,
        pushType: .token  // ми хочемо отримати APNs push-токен
    )
}

Далі йде власне 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
        }
    }
}

Локальне оновлення виглядає так:

func updateStatus(activityID: String, to status: DeliveryStatus, eta: Int) async {
    guard let activity = Activity<DeliveryAttributes>.activities
        .first(where: { $0.id == activityID }) else { return }
    let newState = DeliveryAttributes.ContentState(
        status: status,
        etaMinutes: eta,
        courierName: activity.content.state.courierName
    )
    await activity.update(
        ActivityContent(state: newState,
                        staleDate: .now.addingTimeInterval(60 * 30))
    )
}

Dynamic Island: макети та найкращі практики

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, APNs, App Intents

Є три канали оновлення даних, і вибір залежить від сценарію. Для віджетів з передбачуваним ритмом (погода, курси валют, розклад) підходить TimelineProvider з policy: .after(_:). Для реактивних оновлень (нова задача, зміна балансу) варто взяти WidgetCenter.shared.reloadTimelines(ofKind:) з основного додатка або з App Intent.

Для Live Activities з непередбачуваними змінами (доставка, спорт) залишається APNs. Це справжній push, який приходить навіть коли додаток не запущений. Формат payload з iOS 16.2:

{
  "aps": {
    "timestamp": 1720000000,
    "event": "update",
    "content-state": {
      "status": "onTheWay",
      "etaMinutes": 22,
      "courierName": "Олег"
    },
    "alert": {
      "title": "Кур'єр у дорозі",
      "body": "Приблизно 22 хвилини"
    }
  }
}

На фінальному етапі надішліть "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.

Editorial Team
Про Автора Editorial Team

Our team of expert writers and editors.