Live Activities ja Dynamic Island iOS 26:ssa: Käytännön opas ActivityKit-kehitykseen

Opi rakentamaan Live Activities -toimintoja ja Dynamic Island -käyttöliittymiä iOS 26:lle ActivityKitin ja SwiftUI:n avulla. Sisältää APNs-integraation, koodiesimerkit ja parhaat käytännöt vuodelle 2026.

Live Activities iOS 26: ActivityKit-opas

Live Activities ja Dynamic Island ovat muodostuneet aika lailla keskeiseksi osaksi modernia iOS-käyttökokemusta — eikä syyttä. iOS 26:n myötä ActivityKit-kehys on saanut entistä laajemman alustatuen: toiminnot näkyvät nyt iPhonen lukitusnäytöllä, Dynamic Islandissa, paritetussa Apple Watchissa, CarPlayssa ja jopa macOS 26:ssa (kyllä, valikkopalkissa). Tässä oppaassa käymme alusta loppuun läpi sen, miten rakennat tuotantotason Live Activityn SwiftUI:lla, ActivityKitillä ja APNs-pohjaisilla push-päivityksillä. Selkokielisesti ja ilman turhaa pumpulia.

Mitä Live Activity oikeastaan on — ja milloin sitä kannattaa käyttää?

Live Activity on iOS-käyttöliittymäelementti, joka pysyy kiinnitettynä lukitusnäytölle ja Dynamic Islandiin niin kauan kuin meneillään oleva tapahtuma jatkuu. Esimerkkejä? Taksimatka, ruokalähetys, urheilupeli, ajastin, kuntoiluharjoitus — sellaisia juttuja, jotka loppuvat jossain vaiheessa. Päivitykset tulevat joko suoraan sovelluksesta tai palvelimelta APNs:n kautta.

Toisin kuin widget — joka on pysyvä ja päivittyy järjestelmän aikataulun mukaan — Live Activity on olemassa vain tapahtuman ajan. Yksittäinen aktiviteetti voi olla aktiivinen enintään 8 tuntia, ja se pysyy lukitusnäytöllä vielä 4 tuntia "vanhentuneessa" tilassa. Yhteensä siis enintään 12 tuntia, jonka jälkeen järjestelmä hylkää sen. Ei pidempään, vaikka kuinka yrittäisit.

Tyypilliset käyttötapaukset 2026

  • Toimitus- ja kuljetussovellukset: reaaliaikainen seuranta saapumiseen asti.
  • Urheilu ja eSports: elävä tulospalvelu ottelun aikana.
  • Ajastimet ja kuntoilu: käynnissä olevan harjoituksen tai pomodoron näyttäminen.
  • Taloustyökalut: osakekurssin tai kryptohinnan reaaliaikainen seuranta (omasta kokemuksesta — älä tee tästä jatkuvaa, akku kärsii).
  • Matkustaminen: portin vaihto ja lentoyhteyden tila.

Mikä on uutta iOS 26:ssa?

iOS 26 ei tuonut radikaaleja rikkovia muutoksia ActivityKitin ydinrajapintaan, mutta alustan kattavuus laajeni — ja kunnolla:

  • CarPlay-tuki: Live Activities näkyy autossa kuljettajalle.
  • macOS Tahoe (26): iPhonen aktiviteetit peilautuvat Macin valikkopalkkiin.
  • Liquid Glass -ulkoasu: uusi materiaalivaikutus on käytettävissä myös Live Activity -näkymissä GlassEffect-modifioijan kautta.
  • watchOS 26 Smart Stack: Live Activities tulevat näkyviin älypinoon kontekstin perusteella.
  • Push-to-Start (iOS 17.2+): serveri voi käynnistää Live Activityn kokonaan ilman, että sovellus on auki.
  • visionOS 26: yhteensopiva iPhone-/iPad-sovelluksen Live Activities tulee saataville myös visionOS:lle.

Rehellisesti sanottuna CarPlay-tuki on se, mitä monet kehittäjät ovat odottaneet pisimpään. Ruokalähetys-sovelluksilla se on lähes pakollinen ominaisuus tänä päivänä.

Projektin alustus Xcode 26:ssa

Live Activity tarvitsee Widget Extension -kohteen. Lisää se valitsemalla File → New → Target → Widget Extension. Älä rastita "Include Configuration Intent" -valintaa, ellet erikseen tarvitse sitä — siitä tulee vain ylimääräistä sotkua.

Vaadittavat asetukset

  1. Avaa sovelluksen Info.plist ja lisää avain NSSupportsLiveActivities arvolla YES.
  2. Halutessasi lisää NSSupportsLiveActivitiesFrequentUpdates päästäksesi tiheämpiin paikallisiin päivityksiin.
  3. Lisää Push Notifications -ominaisuus pääsovellustargettiin (Signing & Capabilities -välilehti), jos käytät palvelinpäivityksiä.
  4. Vähimmäisversio: iOS 16.1 lukitusnäytölle, iOS 16.2 Dynamic Islandille, iOS 17.2 Push-to-Start-toiminnolle.

ActivityAttributes: staattisen ja dynaamisen datan erottelu

ActivityAttributes-protokolla määrittää datamallin Live Activitylle. Keskeinen periaate on yksinkertainen: erottele muuttumaton metadata (asetetaan vain käynnistyksen yhteydessä) ja elävä sisältötila (ContentState), joka muuttuu tapahtuman edetessä. Sekoittaminen näiden välillä on yksi yleisimmistä alkuvaiheen virheistä.

import ActivityKit
import Foundation

struct DeliveryAttributes: ActivityAttributes {
    // Dynaaminen sisältö — päivittyy ajan mittaan
    public struct ContentState: Codable, Hashable {
        var status: DeliveryStatus
        var etaMinutes: Int
        var driverName: String?
        var progress: Double // 0.0...1.0
    }

    // Staattinen metadata — asetetaan kerran
    var orderId: String
    var restaurantName: String
    var deliveryAddress: String
}

enum DeliveryStatus: String, Codable, Hashable {
    case preparing
    case onTheWay
    case arrived
}

Tärkeä raja, johon kannattaa varautua: jokaisen ContentState-päivityksen koodattu JSON-paketti saa olla enintään 4 kt. Pidä siis vain todella tarvittava tieto dynaamisessa tilassa. Ei mitään ylimääräistä metaa.

Live Activity -näkymän rakentaminen SwiftUI:lla

Käyttöliittymä määritellään ActivityConfiguration-rakenteella. Se sisältää kaksi sulkeumaa: lukitusnäytön ulkoasu ja Dynamic Island -ulkoasu eri kokoisina (laajennettu, kompakti, minimaalinen).

import ActivityKit
import SwiftUI
import WidgetKit

struct DeliveryLiveActivity: Widget {
    var body: some WidgetConfiguration {
        ActivityConfiguration(for: DeliveryAttributes.self) { context in
            // Lukitusnäytön ulkoasu
            LockScreenDeliveryView(
                state: context.state,
                attributes: context.attributes
            )
            .activityBackgroundTint(.black.opacity(0.6))
            .activitySystemActionForegroundColor(.white)

        } dynamicIsland: { context in
            DynamicIsland {
                // Laajennettu tila (pitkä painallus tai automaattinen)
                DynamicIslandExpandedRegion(.leading) {
                    Image(systemName: iconName(for: context.state.status))
                        .font(.title2)
                        .foregroundStyle(.tint)
                }
                DynamicIslandExpandedRegion(.trailing) {
                    Text("\(context.state.etaMinutes) min")
                        .font(.headline.monospacedDigit())
                }
                DynamicIslandExpandedRegion(.bottom) {
                    ProgressView(value: context.state.progress)
                        .tint(.green)
                    Text(context.attributes.restaurantName)
                        .font(.caption)
                }
            } compactLeading: {
                Image(systemName: "bag.fill")
            } compactTrailing: {
                Text("\(context.state.etaMinutes)m")
                    .monospacedDigit()
            } minimal: {
                Image(systemName: "bag.fill")
            }
            .keylineTint(.green)
            .widgetURL(URL(string: "myapp://order/\(context.attributes.orderId)"))
        }
    }

    private func iconName(for status: DeliveryStatus) -> String {
        switch status {
        case .preparing: return "frying.pan"
        case .onTheWay:  return "bicycle"
        case .arrived:   return "house.fill"
        }
    }
}

Lukitusnäytön näkymä Liquid Glassilla

struct LockScreenDeliveryView: View {
    let state: DeliveryAttributes.ContentState
    let attributes: DeliveryAttributes

    var body: some View {
        VStack(alignment: .leading, spacing: 8) {
            HStack {
                Text(attributes.restaurantName)
                    .font(.headline)
                Spacer()
                Text("\(state.etaMinutes) min")
                    .monospacedDigit()
                    .foregroundStyle(.green)
            }
            ProgressView(value: state.progress)
                .tint(.green)
            if let driver = state.driverName {
                Text("Kuljettaja: \(driver)")
                    .font(.caption)
                    .foregroundStyle(.secondary)
            }
        }
        .padding()
        .glassEffect(.regular, in: .rect(cornerRadius: 16)) // iOS 26
    }
}

Live Activityn käynnistäminen sovelluksesta

Käynnistä aktiviteetti silloin, kun varsinainen tapahtuma alkaa — esimerkiksi heti kun käyttäjä on maksanut tilauksen. Tarkista ensin, onko käyttäjä antanut luvan. Jos et tarkista, törmäät ennen pitkää virheeseen.

import ActivityKit

@MainActor
final class DeliveryActivityManager {
    private(set) var current: Activity<DeliveryAttributes>?

    func start(orderId: String, restaurant: String, address: String) async throws {
        guard ActivityAuthorizationInfo().areActivitiesEnabled else {
            throw ActivityError.notAuthorized
        }

        let attributes = DeliveryAttributes(
            orderId: orderId,
            restaurantName: restaurant,
            deliveryAddress: address
        )
        let initialState = DeliveryAttributes.ContentState(
            status: .preparing,
            etaMinutes: 35,
            driverName: nil,
            progress: 0.05
        )

        let activity = try Activity.request(
            attributes: attributes,
            content: .init(state: initialState, staleDate: nil),
            pushType: .token // pyydetään APNs-token palvelinpäivityksiä varten
        )
        self.current = activity

        // Tilaa push-tokenin päivitykset taustasäikeenä
        Task { await observePushTokens(for: activity) }
    }

    private func observePushTokens(for activity: Activity<DeliveryAttributes>) async {
        for await tokenData in activity.pushTokenUpdates {
            let token = tokenData.map { String(format: "%02x", $0) }.joined()
            await sendTokenToServer(token, orderId: activity.attributes.orderId)
        }
    }

    private func sendTokenToServer(_ token: String, orderId: String) async {
        // POST sovelluksesi backendille
    }

    enum ActivityError: Error { case notAuthorized }
}

Päivittäminen ja päättäminen sovelluksesta

extension DeliveryActivityManager {
    func update(status: DeliveryStatus, etaMinutes: Int, progress: Double, driver: String?) async {
        guard let activity = current else { return }
        let newState = DeliveryAttributes.ContentState(
            status: status,
            etaMinutes: etaMinutes,
            driverName: driver,
            progress: progress
        )
        await activity.update(.init(
            state: newState,
            staleDate: Date().addingTimeInterval(60 * 10) // vanhenee 10 min kuluttua
        ))
    }

    func end() async {
        guard let activity = current else { return }
        let finalState = DeliveryAttributes.ContentState(
            status: .arrived,
            etaMinutes: 0,
            driverName: activity.content.state.driverName,
            progress: 1.0
        )
        await activity.end(
            .init(state: finalState, staleDate: nil),
            dismissalPolicy: .after(Date().addingTimeInterval(60 * 5))
        )
        current = nil
    }
}

APNs-päivitykset palvelimelta

Kun Live Activity käynnistetään pushType: .token -parametrillä, ActivityKit pyytää APNs:lta yksilöllisen push-tokenin. Tämä token eroaa tavallisesta laitetokenista — se on uniikki kullekin Live Activitylle. Älä siis sekoita näitä keskenään palvelinkoodissasi.

APNs-paketin rakenne

{
  "aps": {
    "timestamp": 1746883200,
    "event": "update",
    "content-state": {
      "status": "onTheWay",
      "etaMinutes": 12,
      "driverName": "Mikko",
      "progress": 0.62
    },
    "stale-date": 1746884400,
    "alert": {
      "title": "Tilaus matkalla",
      "body": "Mikko saapuu noin 12 minuutissa"
    }
  }
}

Muutama keskeinen yksityiskohta, jotka kannattaa pitää mielessä:

  • Käytä HTTP/2-päätettä apns-push-type: liveactivity.
  • apns-topic-otsakkeen pitää olla com.yritys.sovellus.push-type.liveactivity.
  • Vain .p8-token-pohjainen autentikointi — Apple ei tue p12-sertifikaatteja Live Activityille (tämä on yllättänyt monta vanhempaa projektia).
  • JWT-token on virkistettävä vähintään kerran tunnissa, mutta ei useammin kuin kerran 20 minuutissa.
  • Aseta event-arvoksi update päivityksille tai end päättämiselle.
  • Aseta timestamp sekunteina vuoden 1970 alusta — järjestelmä käyttää sitä järjestyksen takaamiseen.

Push-to-Start (iOS 17.2+)

Voit käynnistää Live Activityn ilman, että sovellus on auki. Aika hieno ominaisuus, kun sitä oppii käyttämään. Tilaa ensin push-to-start-tokeneita sovelluksen elinkaaren alussa:

Task {
    for await tokenData in Activity<DeliveryAttributes>.pushToStartTokenUpdates {
        let token = tokenData.map { String(format: "%02x", $0) }.joined()
        await sendStartTokenToServer(token)
    }
}

Palvelin lähettää APNs-paketin, jonka event-arvo on start ja attributes-type sisältää ActivityAttributes-tyypin nimen. Siinä se, periaatteessa.

Interaktiivisuus: napit ja App Intents

iOS 17:stä alkaen Live Activity ja Dynamic Island tukevat interaktiivisia kontrolleja App Intents -kehyksen kautta. iOS 26:ssa tämä yhdistyy entistä saumattomammin laajaan Apple Intelligence -ekosysteemiin — mikä avaa ihan uusia mahdollisuuksia esim. ehdotuksille.

import AppIntents

struct CallDriverIntent: AppIntent {
    static var title: LocalizedStringResource = "Soita kuljettajalle"
    @Parameter(title: "Tilaus")
    var orderId: String

    func perform() async throws -> some IntentResult {
        // Avaa puhelinsovellus tai sisäinen VoIP
        return .result()
    }
}

// Live Activity -näkymässä:
Button(intent: CallDriverIntent(orderId: context.attributes.orderId)) {
    Image(systemName: "phone.fill")
}
.tint(.green)

Esikatselu Xcode Canvasilla

#Preview-makrolla voit esikatsella Live Activitya kaikissa eri tiloissa ilman, että sinun tarvitsee käynnistää sovellusta uudelleen. Tämä säästää uskomattoman paljon aikaa.

#Preview("Compact", as: .dynamicIsland(.compact),
         using: DeliveryAttributes(orderId: "A123", restaurantName: "Pizzeria",
                                   deliveryAddress: "Mannerheimintie 1")) {
    DeliveryLiveActivity()
} contentStates: {
    DeliveryAttributes.ContentState(status: .preparing, etaMinutes: 30,
                                    driverName: nil, progress: 0.1)
    DeliveryAttributes.ContentState(status: .onTheWay, etaMinutes: 12,
                                    driverName: "Mikko", progress: 0.6)
    DeliveryAttributes.ContentState(status: .arrived, etaMinutes: 0,
                                    driverName: "Mikko", progress: 1.0)
}

Parhaat käytännöt 2026

  • Pidä ContentState pienenä: alle 4 kt rajan reilusti — ihanteellisesti alle 1 kt.
  • Käytä monospaced numeroita ETA-/aikalukemiin, jotta UI ei hyppäile (pieni juttu, mutta huomataan heti kun se puuttuu).
  • Aseta aina staleDate: näin järjestelmä osaa näyttää käyttäjälle, että data on vanhentunut.
  • Käytä .activitySystemActionForegroundColor taataksesi luettavuuden taustasta riippumatta.
  • Älä päivitä liian usein: tarpeeton liikenne syö akkua ja saa järjestelmän rajoittamaan päivityksiä.
  • Vältä animaatioita Dynamic Island -näkymissä — ne on suunniteltu staattisiksi tilakuviksi.
  • Testaa CarPlayssa ja Apple Watchilla: ulkoasusi voi näyttää aivan erilaiselta paritetulla laitteella.
  • Käytä Liquid Glass -modifioijia (.glassEffect) iOS 26:ssa yhtenäisen ilmeen takaamiseksi.
  • Tarjoa fallback-päivitys paikallisesti jos APNs-paketti epäonnistuu ja sovellus pääsee taustaan.

Yleisiä virheitä ja niiden korjaaminen

  • "Activities are not enabled" — käyttäjä on poistanut Live Activities -toiminnon Asetuksista. Tarkista ActivityAuthorizationInfo().areActivitiesEnabled ennen yritystä.
  • Push-päivitys ei toteudu — varmista, että apns-push-type on liveactivity ja apns-topic päättyy .push-type.liveactivity. Tämä on klassinen kompastuskivi.
  • JSON ei dekoodaudu — käytä oletusarvoista JSONEncoder-strategiaa palvelimella, älä mukautettuja avainnimiä.
  • UI ei päivity — varmista, että muokkaat ContentState-rakennetta etkä staattisia ActivityAttributes-kenttiä.
  • 4 kt ylitys — pakkaa tila tiiviimmin, käytä lyhyitä avaimia tai siirrä harvoin muuttuva data staattiseen attributes-osaan.

Usein kysytyt kysymykset

Mikä on ero Live Activityn ja widgetin välillä?

Widget on pysyvä komponentti, joka päivittyy järjestelmän ajastusten mukaan ja näyttää tietoa pidempään. Live Activity puolestaan elää vain meneillään olevan tapahtuman ajan (max. 12 tuntia) ja näkyy lukitusnäytöllä sekä Dynamic Islandissa. Live Activity tukee push-päivityksiä, joiden ansiosta tieto pysyy tuoreena ilman sovelluksen avaamista.

Voinko käyttää samaa widget-extensionia molempiin?

Kyllä voi. Sama Widget Extension -targetti voi sisältää sekä tavallisia Widget-määrityksiä että yhden tai useamman ActivityConfiguration-määrityksen. Molemmat jakavat saman SwiftUI-renderöintiympäristön ja samat rajoitukset (ei animaatioita, rajoitettu API-pinta).

Tarvitsenko palvelimen lähettääkseni Live Activity -päivityksiä?

Et välttämättä. Voit päivittää aktiviteettia paikallisesti niin kauan kuin sovellus on auki tai sillä on taustaruntime. Mutta jos haluat luotettavia päivityksiä silloinkin, kun sovellus on suljettu, tarvitset APNs-pohjaisen palvelimen ja pushType: .token -käynnistyksen. Käytännössä useimmat tuotantosovellukset tekevät niin.

Toimivatko Live Activities iPadilla tai macOS:ssä?

iOS 26:ssa ja macOS 26:ssa iPhonella käynnistetyt aktiviteetit peilautuvat oletuksena paritetuille Apple Watch- ja Mac-laitteille. iPad ei vielä tarjoa omaa lukitusnäytön Live Activitya (ehkä joskus tulevaisuudessa), mutta visionOS 26 ja CarPlay tukevat niitä natiivisti.

Kuinka kauan Live Activity voi olla aktiivinen?

Yksittäinen aktiviteetti voi olla aktiivinen enintään 8 tuntia ja näkyä lukitusnäytöllä vielä 4 tuntia "vanhentuneena", yhteensä siis enintään 12 tuntia. Sen jälkeen järjestelmä hylkää sen automaattisesti, ja sinun on käynnistettävä uusi aktiviteetti tarvittaessa.

Tarvitseeko Live Activity erillisen App Store -tarkistuksen?

Ei. Live Activities on tavallinen iOS-ominaisuus, joka kulkee normaalin App Store -arvioinnin mukana. Varmista kuitenkin, että käyttötapauksesi vastaa Applen ohjeita: aktiviteetin pitää liittyä todelliseen, käyttäjän aloittamaan tapahtumaan — ei mainoksiin tai jatkuviin notifikaatioihin. Tästä on tullut hylkäyksiä.

Yhteenveto

Live Activities ja Dynamic Island ovat siirtyneet "kivasta lisäominaisuudesta" pakolliseksi osaksi laadukasta iOS-sovellusta vuonna 2026. iOS 26:n laajentunut alustakatto, Liquid Glass -ulkoasu ja Push-to-Start-toiminto tekevät niistä entistä tehokkaampia.

Aloita yksinkertaisella ActivityAttributes-mallilla, rakenna SwiftUI-näkymät kaikille Dynamic Island -tiloille, integroi APNs-token-pohjainen palvelin, ja muista pitää ContentState tiiviinä. Näiden perusteiden hallinnan jälkeen voit syventyä App Intents -interaktiivisuuteen ja tehdä Live Activitysta sovelluksesi tärkeimmän etusivun. Kannattaa kokeilla — käyttäjät huomaavat eron heti.

Tietoa Kirjoittajasta Daniel Okafor

Daniel is a former Spotify iOS engineer (2019-2024) who worked on the Now Playing surface and the in-app podcast player. He shipped the SwiftUI rewrite of the lyrics view to over 600 million users and contributed several fixes upstream to swift-collections. His writing tends toward the unglamorous corners of iOS work: build-time regressions in Xcode 16, why SwiftData still isn't ready for production sync scenarios, and how to instrument a real app with os_signpost without drowning in noise. He spent two years before Spotify at a fintech startup in Berlin building a banking app on top of Solaris API. Daniel now freelances out of Lisbon and maintains a small open-source library for type-safe deep links in SwiftUI. He has 9 years of native iOS experience.