Skip to content
C.W.K.
Stream
Lesson 04 of 06 · published

Widgets and Live Activities: A Number on the Home Screen, a Turn on the Lock Screen

~17 min · beyond-the-app, widgetkit, activitykit, live-activities, push

Level 0Bundle Opener
0 XP0/81 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"A widget that re-read on a clock would burn battery to display the same number all afternoon."

A Widget Is a Reminder, Not a Window

The travel journal's widget shows one thing: how many captures are still only on this phone. The owner sees that count in the app, but the moment it matters is when he is standing somewhere with signal and not thinking about the app at all, and a number on the Home Screen is the reminder to open it on the hotel wifi. A widget is a separate extension built with WidgetKit. Its TimelineProvider hands the system entries to display, and it reads what it shows from the App Group container, where the app writes a small summary.

Two decisions make it right. The timeline has one entry and the policy .never: the count changes when the app changes it, so the app calls WidgetCenter to reload that widget's timelines after the outbox changes, instead of the widget waking on a schedule to show the same number. And the widget shows a count, a state and a time, never a capture's text or a place. The Home Screen and Lock Screen are the most screenshotable surfaces a person owns, and a travel log is private until its owner decides otherwise.

A Live Activity Follows One Turn

Native Pippa shows a Live Activity while a message is on its way. It starts when the message is queued, changes phase as the send progresses, ends with the first words of the reply and lingers briefly, and a tap opens that exact conversation through the widget's deep link. The attributes and the changing content state are plain Codable types shared by the app and its widget extension.

The platform draws a firm line: an app can start a Live Activity only while it is in the foreground. A message sent from the watch reaches the phone in the background, so the local start is refused exactly when it would be most useful. The answer is push. Since iOS 17.2 the app can observe a push-to-start token and hand it to its relay, which sends a Live Activity push to start one; each activity started with pushType: .token also has its own token, so the relay can end it when the reply lands on a phone that slept through the turn.

Two Things the Bench Taught

Measured on a development build on the owner's iPhone: the ordinary alert token was accepted only by the sandbox push host, while the same build's Live Activity tokens were accepted only by production, and each host answered the other token with BadDeviceToken. The relay now tries the alert token's host, retries the other once on BadDeviceToken, and remembers the answer per device. And at launch the push-to-start token arrived before the alert token, so an alert registration that replaced the device's whole record wiped the start token a second after it arrived. Registrations merge.

Code

A widget that shows a count from the App Group and reloads only when the app says so·swift
import SwiftUI
import WidgetKit

/// What the widget may show: a count, a state, a time. Never a note's text or a place:
/// the Home Screen is the most screenshotable surface there is.
struct CaptureSummary: Codable, Sendable {
    var waiting: Int
    var lastCapturedAt: Date?

    static func read(from container: URL?) -> CaptureSummary {
        guard let url = container?.appending(path: "summary.json"),
              let data = try? Data(contentsOf: url),
              let summary = try? JSONDecoder().decode(CaptureSummary.self, from: data)
        else { return CaptureSummary(waiting: 0, lastCapturedAt: nil) }
        return summary
    }
}

struct WaitingEntry: TimelineEntry {
    let date: Date
    let summary: CaptureSummary
}

struct WaitingProvider: TimelineProvider {
    private var container: URL? {
        FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.com.example.spark")
    }
    func placeholder(in context: Context) -> WaitingEntry {
        WaitingEntry(date: .now, summary: CaptureSummary(waiting: 3, lastCapturedAt: nil))
    }
    func getSnapshot(in context: Context, completion: @escaping (WaitingEntry) -> Void) {
        completion(WaitingEntry(date: .now, summary: .read(from: container)))
    }
    /// One entry and no schedule: the number changes when the APP changes it, and the app says so.
    func getTimeline(in context: Context, completion: @escaping (Timeline<WaitingEntry>) -> Void) {
        completion(Timeline(entries: [WaitingEntry(date: .now, summary: .read(from: container))], policy: .never))
    }
}

struct WaitingWidget: Widget {
    var body: some WidgetConfiguration {
        StaticConfiguration(kind: "com.example.spark.widget.waiting", provider: WaitingProvider()) { entry in
            VStack(alignment: .leading) {
                Text("\(entry.summary.waiting)").font(.largeTitle.weight(.semibold))
                Text("waiting to sync").font(.caption).foregroundStyle(.secondary)
            }
            .containerBackground(.fill.tertiary, for: .widget)
        }
        .configurationDisplayName("Waiting to Sync")
        .supportedFamilies([.systemSmall, .accessoryRectangular])
    }
}

// In the app, after the outbox changes:
@MainActor
func summaryDidChange() {
    WidgetCenter.shared.reloadTimelines(ofKind: "com.example.spark.widget.waiting")
}
A Live Activity started locally with a push token, and the push-to-start token handed up·swift
import ActivityKit
import Foundation

struct TurnAttributes: ActivityAttributes {
    struct ContentState: Codable, Hashable, Sendable {
        var phase: String        // "sending", "thinking", "answered"
    }
    var conversationID: String   // for the widgetURL deep link back to this conversation
}

@MainActor
final class TurnActivities {
    /// Local start needs the app in the foreground. Asking for a push token lets the relay end it later.
    func start(conversationID: String) throws -> Activity<TurnAttributes> {
        try Activity.request(
            attributes: TurnAttributes(conversationID: conversationID),
            content: .init(state: .init(phase: "sending"), staleDate: nil),
            pushType: .token)
    }

    /// iOS 17.2+: a push-to-start token lets the relay START an activity when the app cannot,
    /// for example after a message handled in the background. Hand it up; merge, never replace.
    @available(iOS 17.2, *)
    func observePushToStartTokens(send: @escaping @Sendable (String) async -> Void) -> Task<Void, Never> {
        Task {
            for await token in Activity<TurnAttributes>.pushToStartTokenUpdates {
                await send(token.map { String(format: "%02x", $0) }.joined())
            }
        }
    }
}

External links

Exercise

Add WaitingWidget to a SparkMobile widget extension that shares the App Group, have the app write summary.json whenever its outbox changes, and call reloadTimelines(ofKind:) after each write. On a simulator, capture three notes offline and watch the widget change without opening it. Then add TurnAttributes and start an activity from a button while the app is in front, and record what Activity.request does when you call it from a background task instead.
Hint
The widget extension needs the App Group entitlement too, or its container is nil and it shows zero forever. Live Activities also need NSSupportsLiveActivities set to true in the app's Info.plist.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.