Live Activities w iOS 26: kompletny przewodnik po Dynamic Island i ActivityKit

Live Activities w iOS 26 dają aplikacji żywą kartę na ekranie blokady i w Dynamic Island. Przewodnik pokazuje ActivityKit, Push-to-Start, interaktywne App Intents i Liquid Glass z pełnym kodem Swift 6.2 dla Xcode 26.3.

Live Activities iOS 26: Dynamic Island

Zaktualizowano: 17 czerwca 2026

Live Activities w iOS 26 to mechanizm ActivityKit, który pozwala aplikacji pokazywać aktualizowane na żywo informacje na ekranie blokady, w Dynamic Island i na zegarku przez StandBy oraz Smart Stack. W tym przewodniku przeprowadzę cię przez pełny pipeline, od konfiguracji Widget Extension, przez ActivityAttributes i Push-to-Start, aż po nowości iOS 26: aktualizacje przez ActivityKit.PushType.token, interaktywne przyciski oparte o App Intents oraz integrację z Liquid Glass. Wszystkie przykłady działają z Xcode 26.3, Swift 6.2 i SDK iOS 26. Szczerze mówiąc, kilka z tych pułapek złapałem na własnej skórze, gdy wdrażałem śledzenie dostawy w produkcyjnej aplikacji food-delivery.

  • Live Activities wymagają Widget Extension z typem ActivityConfiguration, struktury implementującej ActivityAttributes oraz uprawnienia NSSupportsLiveActivities w Info.plist.
  • W iOS 26 maksymalny czas trwania aktywności to 12 godzin w trybie aktywnym i 8 godzin w stale-state, a frequentUpdates pozwala na ~120 push-update'ów dziennie zamiast dotychczasowych 50.
  • Push-to-Start (od iOS 17.2) umożliwia uruchamianie Live Activities zdalnie. W iOS 26 ten flow działa też dla widgetów Smart Stack na Apple Watch.
  • Interaktywne przyciski w Dynamic Island korzystają z App Intents z openAppWhenRun = false, dzięki czemu akcja wykonuje się bez wybudzania aplikacji.
  • Dynamic Island ma trzy stany: compactLeading/compactTrailing, minimal oraz expanded. Każdy trzeba zaprojektować, bo system wybiera prezentację na podstawie kontekstu.
  • Liquid Glass w iOS 26 wymaga użycia .glassBackgroundEffect() na komponentach Dynamic Island, by widok komponował się z systemowym tłem.

Czym są Live Activities w iOS 26?

Live Activities to lekkie widoki SwiftUI, które aplikacja publikuje przez framework ActivityKit, by pokazywać użytkownikowi stan trwającego zadania w czasie rzeczywistym. Pomyśl: wynik meczu, status dostawy jedzenia, krok treningu czy postęp odprawy lotniska. W odróżnieniu od zwykłych widgetów, Live Activity ma jasno określony cykl życia. Jest uruchamiana, aktualizowana wielokrotnie i kończona, a system gwarantuje jej miejsce na ekranie blokady oraz w Dynamic Island na urządzeniach iPhone 14 Pro i nowszych.

W iOS 26 Apple znacząco rozszerzyło ActivityKit. Maksymalny czas życia aktywności wzrósł z 8 do 12 godzin w trybie aktywnym, dodano ActivityContent<State> z wbudowanymi metadanymi staleDate i relevanceScore, a powiadomienia push uzyskały nowy typ frequentUpdates, który podwaja dzienny limit aktualizacji. Pojawił się też publiczny API do uruchamiania aktywności na watchOS 26 przez Smart Stack. Live Activity uruchomiona na iPhonie automatycznie pojawia się jako karta na zegarku, jeśli zadeklarujesz supportedFamilies = [.accessoryRectangular, .accessoryInline].

Z perspektywy użytkownika to po prostu „żywa karta", ale z perspektywy dewelopera Live Activity to Widget Extension działający w odrębnym procesie z limitem pamięci 30 MB. Cały stan, który chcesz pokazywać, musi przechodzić przez serializowalną strukturę ContentState. To zmienia sposób projektowania danych. Zamiast przekazywać model domenowy, projektujesz minimalny snapshot wystarczający do renderu. (Pierwszy raz mnie to zaskoczyło, gdy próbowałem wepchnąć całą encję zamówienia w state.)

Konfiguracja Widget Extension i ActivityAttributes

Pierwszy krok to dodanie do projektu nowego targetu Widget Extension z zaznaczoną opcją „Include Live Activity". Xcode wygeneruje szkielet z ActivityConfiguration, ale zwykle trzeba go od razu rozdzielić. Atrybuty trzymaj w pliku współdzielonym między aplikacją a rozszerzeniem, bo będziesz ich używać po obu stronach. Dodaj wspólny target membership do pliku z ActivityAttributes i pamiętaj o dodaniu klucza NSSupportsLiveActivities = YES do Info.plist aplikacji głównej.

Struktura ActivityAttributes opisuje dwa rodzaje danych. Attributes to niezmienne dane ustawiane przy starcie (numer zamówienia, ID meczu), a wewnętrzna struktura ContentState to to, co zmienia się podczas trwania aktywności. Oto kompletny przykład dla aplikacji śledzącej dostawę jedzenia:

import ActivityKit
import Foundation

struct DeliveryActivityAttributes: ActivityAttributes {
    // Dane niezmienne, ustawiane raz, przy starcie aktywności
    let orderId: String
    let restaurantName: String
    let estimatedDeliveryTime: Date

    // Dane mutowalne, aktualizowane podczas życia aktywności
    public struct ContentState: Codable, Hashable {
        var driverName: String
        var driverProgress: Double  // 0.0 ... 1.0
        var currentStage: DeliveryStage
        var minutesRemaining: Int
    }

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

Kluczowa uwaga: ContentState musi być w pełni Codable i Hashable, bo system serializuje go do APNs i deduplikuje aktualizacje. Trzymaj go małym (zalecam <4 KB), bo cała ramka push z payloadem nie może przekroczyć 4096 bajtów. Wszelkie obrazy podawaj jako nazwy z asset catalogu rozszerzenia. Nie próbuj przesyłać Data w state, ja kiedyś tak zrobiłem i payload odbił się od APNs z błędem PayloadTooLarge.

Uruchamianie, aktualizowanie i kończenie Live Activity

Aktywność uruchamia się z aplikacji głównej (lub przez Push-to-Start) wywołaniem Activity.request. W iOS 26 API jest w pełni async/await i wymaga obsługi błędów. Najczęstszym powodem niepowodzenia jest wyłączone uprawnienie w Ustawieniach albo limit ośmiu jednoczesnych aktywności tej samej aplikacji:

import ActivityKit

@MainActor
final class DeliveryLiveActivityController {

    private(set) var currentActivity: Activity<DeliveryActivityAttributes>?

    func start(orderId: String, restaurant: String, eta: Date) async throws {
        let attributes = DeliveryActivityAttributes(
            orderId: orderId,
            restaurantName: restaurant,
            estimatedDeliveryTime: eta
        )
        let initialState = DeliveryActivityAttributes.ContentState(
            driverName: "",
            driverProgress: 0,
            currentStage: .preparing,
            minutesRemaining: 35
        )
        let content = ActivityContent(
            state: initialState,
            staleDate: eta.addingTimeInterval(60 * 30),
            relevanceScore: 100
        )

        // pushType: .token, chcemy aktualizować z backendu przez APNs
        currentActivity = try Activity.request(
            attributes: attributes,
            content: content,
            pushType: .token
        )

        // Token push przekazujemy do naszego backendu
        if let activity = currentActivity {
            for await tokenData in activity.pushTokenUpdates {
                let token = tokenData.map { String(format: "%02x", $0) }.joined()
                await BackendAPI.registerLiveActivityToken(token, for: orderId)
            }
        }
    }

    func update(progress: Double, stage: DeliveryActivityAttributes.DeliveryStage) async {
        guard let activity = currentActivity else { return }
        let newState = DeliveryActivityAttributes.ContentState(
            driverName: activity.content.state.driverName,
            driverProgress: progress,
            currentStage: stage,
            minutesRemaining: max(0, activity.content.state.minutesRemaining - 1)
        )
        await activity.update(
            ActivityContent(state: newState, staleDate: nil, relevanceScore: 100)
        )
    }

    func end() async {
        guard let activity = currentActivity else { return }
        await activity.end(nil, dismissalPolicy: .after(.now + 60 * 5))
        currentActivity = nil
    }
}

dismissalPolicy kontroluje, kiedy karta znika z ekranu blokady. .immediate ukrywa od razu, .default przez 4 godziny pozostawia ją w historii, a .after(Date) daje precyzyjną kontrolę. Dla scenariuszy „już dostarczone, ale niech zostanie 5 minut z potwierdzeniem" sam zwykle używam .after.

Projektowanie Dynamic Island: trzy stany prezentacji

Dynamic Island ma trzy oddzielne tryby renderowania, które musisz zaprojektować osobno. System wybiera, który wyświetlić, na podstawie kontekstu (czy ekran jest aktywny, czy są inne aktywności, czy użytkownik dotyka wyspy). Pominięcie któregokolwiek skończy się rozmytym, generycznym fallbackiem albo nawet odrzuceniem w App Store Review:

  • compactLeading / compactTrailing: dwa małe widoki po lewej i prawej stronie kamery, ~50 pt szerokości każdy. Idealne dla ikony stanu i wskaźnika postępu.
  • minimal: jeden mikro-widok pojawiający się, gdy w tle są dwie aktywności i twoja jest zminimalizowana. ~28 pt.
  • expanded: pełna karta po długim naciśnięciu wyspy lub gdy aktywność jest najnowsza. Tu mieścisz wszystkie szczegóły.

Oto pełna konfiguracja widoku dla naszej aktywności dostawy z użyciem Liquid Glass dostępnego w iOS 26:

import WidgetKit
import SwiftUI
import ActivityKit

struct DeliveryLiveActivity: Widget {
    var body: some WidgetConfiguration {
        ActivityConfiguration(for: DeliveryActivityAttributes.self) { context in
            // Widok ekranu blokady i StandBy
            LockScreenView(context: context)
                .activityBackgroundTint(Color.black.opacity(0.35))
                .activitySystemActionForegroundColor(.white)
        } dynamicIsland: { context in
            DynamicIsland {
                DynamicIslandExpandedRegion(.leading) {
                    Label(context.attributes.restaurantName, systemImage: "bag.fill")
                        .font(.caption)
                }
                DynamicIslandExpandedRegion(.trailing) {
                    Text("\(context.state.minutesRemaining) min")
                        .font(.headline.monospacedDigit())
                }
                DynamicIslandExpandedRegion(.bottom) {
                    ProgressView(value: context.state.driverProgress)
                        .tint(.green)
                    Text(stageLabel(context.state.currentStage))
                        .font(.caption2)
                        .foregroundStyle(.secondary)
                }
            } compactLeading: {
                Image(systemName: "bag.fill")
                    .foregroundStyle(.green)
            } compactTrailing: {
                Text("\(context.state.minutesRemaining)m")
                    .monospacedDigit()
            } minimal: {
                Image(systemName: "bag.fill")
                    .foregroundStyle(.green)
            }
            .keylineTint(.green)
        }
    }

    private func stageLabel(_ stage: DeliveryActivityAttributes.DeliveryStage) -> String {
        switch stage {
        case .preparing: return "Przygotowywanie zamówienia"
        case .onTheWay:  return "Kurier w drodze"
        case .arriving:  return "Kurier dojeżdża"
        case .delivered: return "Dostarczone"
        }
    }
}

Zwróć uwagę na keylineTint. To akcent koloru wokół wyspy, który Apple zaleca, by wizualnie wiązać aktywność z brandem aplikacji. Zbyt jaskrawe kolory są filtrowane przez system, więc trzymaj się palety semantycznej.

Push-to-Start i powiadomienia APNs

Push-to-Start, wprowadzony w iOS 17.2 i ulepszony w iOS 26, pozwala backendowi uruchomić Live Activity bez konieczności wcześniejszego otwarcia aplikacji. Klient rejestruje globalny token po stronie ActivityKit (taki, który nie wygasa przy zamknięciu aplikacji), a backend wysyła specjalne powiadomienie APNs z apns-push-type: liveactivity i apns-priority: 10. System tworzy aktywność i przekazuje początkowy ContentState.

Rejestracja tokenu globalnego po stronie klienta wygląda tak:

import ActivityKit

@MainActor
func registerForPushToStart() async {
    for await data in Activity<DeliveryActivityAttributes>.pushToStartTokenUpdates {
        let token = data.map { String(format: "%02x", $0) }.joined()
        await BackendAPI.registerPushToStartToken(token)
    }
}

Po stronie backendu payload APNs musi zawierać kompletny attributes i content-state. Oto przykład w formie JSON, którą wysyłamy na adres api.push.apple.com/3/device/<token>:

{
  "aps": {
    "timestamp": 1718636400,
    "event": "start",
    "content-state": {
      "driverName": "Kurier",
      "driverProgress": 0,
      "currentStage": "preparing",
      "minutesRemaining": 35
    },
    "attributes-type": "DeliveryActivityAttributes",
    "attributes": {
      "orderId": "ORD-91823",
      "restaurantName": "Pizzeria Verde",
      "estimatedDeliveryTime": "2026-06-17T14:35:00Z"
    },
    "alert": {
      "title": "Pizzeria Verde",
      "body": "Twoje zamówienie zostało przyjęte"
    }
  }
}

Do aktualizacji już istniejącej aktywności używasz tego samego endpointu, ale z "event": "update" i tokenem konkretnej aktywności (nie globalnym push-to-start). Zakończenie to "event": "end" z opcjonalnym polem dismissal-date. Pełną specyfikację podaje oficjalna dokumentacja ActivityKit, a do generowania tokenów JWT dla APNs używaj kluczy z włączoną opcją „APNs" w Apple Developer Portal. Token p8 ma 30-dniową ważność i można go cachować.

Interaktywne przyciski z App Intents

Od iOS 17 Live Activities mogą zawierać przyciski wykonujące akcje przez App Intents, bez otwierania aplikacji. W iOS 26 ten flow zyskał wsparcie dla zwracania nowego ActivityContent bezpośrednio z perform(), dzięki czemu nie trzeba osobno wywoływać activity.update. To upraszcza interakcje typu „kontynuuj timer", „pauza", „następna runda":

import AppIntents
import ActivityKit

struct PauseDeliveryIntent: LiveActivityIntent {
    static var title: LocalizedStringResource = "Wstrzymaj śledzenie"
    static var description = IntentDescription("Wstrzymuje aktualizacje śledzenia dostawy")

    @Parameter(title: "Order ID")
    var orderId: String

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

    func perform() async throws -> some IntentResult {
        let activities = Activity<DeliveryActivityAttributes>.activities
        guard let activity = activities.first(where: { $0.attributes.orderId == orderId }) else {
            return .result()
        }
        var state = activity.content.state
        state.currentStage = .preparing  // zatrzymujemy progres
        await activity.update(
            ActivityContent(state: state, staleDate: nil, relevanceScore: 50)
        )
        return .result()
    }
}

W widoku Dynamic Island dodajesz przycisk standardowym SwiftUI Button(intent:):

DynamicIslandExpandedRegion(.bottom) {
    HStack {
        ProgressView(value: context.state.driverProgress)
        Button(intent: PauseDeliveryIntent(orderId: context.attributes.orderId)) {
            Image(systemName: "pause.fill")
        }
        .buttonStyle(.bordered)
        .tint(.green)
    }
}

Kluczowe: LiveActivityIntent ma domyślnie openAppWhenRun = false, więc akcja wykonuje się w procesie Widget Extension. Oznacza to, że nie masz dostępu do pełnego stanu aplikacji ani do bazy SwiftData, jeśli nie jest w App Group. Jeśli potrzebujesz odczytać model domenowy, opisałem ten flow szerzej w artykule o App Intents w iOS 26. Kontekst Live Activity jest tam identyczny.

Limity, zużycie baterii i best practices

ActivityKit ma kilka twardych limitów, których łamanie kończy się cichym rzucaniem aktywności przez system. Trzymaj je w głowie od pierwszego dnia projektowania:

  • 8 jednoczesnych aktywności per aplikacja. Dziewiąty Activity.request rzuca ActivityAuthorizationError.activityLimitExceeded.
  • 4 KB payload APNs łącznie z aps, content-state i attributes. Większe są odrzucane przez APNs z błędem PayloadTooLarge.
  • 12 godzin maksymalnego życia (iOS 26), po czym aktywność automatycznie wchodzi w stale-state i znika w ciągu 8 godzin lub przy dismissalPolicy.
  • ~120 aktualizacji push dziennie z frequentUpdates, ~50 bez. System throttluje wizualnie, a nie odrzuca. Twoje update'y dochodzą, ale przestają się pojawiać na zablokowanym ekranie.
  • 30 MB pamięci w procesie Widget Extension. Nie ładuj dużych obrazów, nie używaj Core ML.

Dla zużycia baterii dwie reguły mają największe znaczenie. Po pierwsze, ustaw realistyczny staleDate. Dzięki temu system wie, kiedy może uśpić twoją aktywność i nie próbuje renderować nieaktualnych danych. Po drugie, relevanceScore 100 zachowuj dla jednej, najbardziej aktualnej aktywności. Pozostałe degraduj do 50–80, bo to wpływa na to, którą wyspę użytkownik zobaczy najpierw. (W moim ostatnim projekcie zignorowanie tej reguły spowodowało, że aktywność dostawy „przegrywała" z aktywnością timera, co frustrowało użytkowników.)

Pod kątem designu trzymaj się wytycznych Apple HIG dla Live Activities. Typografia musi być czytelna z odległości metra, kontrast minimum WCAG AA (4.5:1), a animacje krótkie (poniżej 600 ms). W iOS 26 polecam też testowanie pod Liquid Glass, a szczegóły jak komponować widoki z systemowym tłem omówiłem w przewodniku po Liquid Glass w SwiftUI.

Jeśli budujesz UI prowadzące użytkownika do funkcji Live Activity (np. tutorial „włącz powiadomienia"), polecam zintegrować to z TipKit. Wzorce opisałem w przewodniku po TipKit w SwiftUI, który dobrze komponuje się z onboardingiem ActivityKit.

Często zadawane pytania

Czym różni się Live Activity od zwykłego widgetu?

Widget aktualizuje się przez timeline z ograniczeniem ~1 update'u na 15 minut, działa głównie na ekranie głównym i nie ma cyklu życia. Live Activity to aktywnie aktualizowana karta przez ActivityKit z dedykowanym miejscem na ekranie blokady i w Dynamic Island, gwarantowaną widocznością oraz jasnym cyklem start → update → end.

Jak długo Live Activity może być aktywna w iOS 26?

Maksymalnie 12 godzin w trybie aktywnym (z update'ami) plus do 8 godzin w stanie stale, zanim system ją zakończy. Możesz wcześniej zakończyć przez activity.end(_:dismissalPolicy:) z polityką .immediate, .default lub .after(Date).

Czy Live Activities działają na iPhone bez Dynamic Island?

Tak. Na iPhone 13 i starszych oraz wszystkich modelach SE aktywność pojawia się tylko na ekranie blokady i w widoku StandBy. Wystarczy zaprojektować widok lock-screen przez parametr for: w ActivityConfiguration. Xcode nie wymaga osobnego targetu.

Dlaczego moja Live Activity nie aktualizuje się po wysłaniu push?

Najczęstsze przyczyny: błędne attributes-type (musi być pełną nazwą struktury), payload powyżej 4096 bajtów, nieprawidłowy token (push-to-start vs activity update), wyczerpany dzienny limit aktualizacji lub apns-priority ustawiony na 5 zamiast 10. Sprawdź odpowiedź APNs, bo błędy są opisane w nagłówku apns-id oraz ciele JSON.

Czy mogę używać SwiftData lub Core Data w Live Activity?

Tylko jeśli kontener jest w App Group współdzielonym między aplikacją a Widget Extension. Pamiętaj o ograniczeniu pamięci do 30 MB. Odczytuj minimalne zapytania, nigdy nie ładuj pełnego modelu domenowego. Dla większości scenariuszy lepiej cache'ować snapshot w ContentState aktywności.

Editorial Team
O Autorze Editorial Team

Our team of expert writers and editors.