Lab 7.2 — Widget extension

Goal: Add a WidgetKit extension to an existing app. Render a Home Screen + Lock Screen + (optional) Watch complication widget showing a “Habits completed today” count. Include an interactive Button(intent:) that bumps the count, and an optional Live Activity for a “habit streak in progress” timer.

Time: 90–150 minutes.

Prereqs: Xcode 16+, iOS 18+. Real device recommended for Lock Screen + Live Activity testing.

Setup

  1. Open or create a SwiftUI app called HabitWidgetLab. Bundle ID: com.example.HabitWidgetLab.
  2. File → New → Target → Widget Extension.
    • Name: HabitWidget.
    • Uncheck “Include Configuration App Intent” (we’ll add a custom one manually).
    • Check “Include Live Activity”.
  3. Add both targets (app + widget) to a shared App Group:
    • Project → Signing & Capabilities → + Capability → App Groups.
    • Group ID: group.com.example.HabitWidgetLab.
    • Repeat for the widget target.
  4. Add App Groups capability to the Live Activity target if present (Xcode usually nests it inside the widget target).

Build

Shared model (in the main app target, also added to widget target)

Shared/HabitStore.swift:

import Foundation
import WidgetKit

struct HabitState: Codable {
    var completedToday: Int
    var totalToday: Int
    var lastUpdate: Date
}

enum HabitStore {
    static let groupID = "group.com.example.HabitWidgetLab"
    private static let key = "habitState"

    static var defaults: UserDefaults {
        UserDefaults(suiteName: groupID)!
    }

    static func read() -> HabitState {
        guard let data = defaults.data(forKey: key),
              let state = try? JSONDecoder().decode(HabitState.self, from: data)
        else { return HabitState(completedToday: 0, totalToday: 5, lastUpdate: .now) }
        return state
    }

    static func write(_ state: HabitState) {
        guard let data = try? JSONEncoder().encode(state) else { return }
        defaults.set(data, forKey: key)
        WidgetCenter.shared.reloadAllTimelines()
    }

    static func completeOne() {
        var state = read()
        if state.completedToday < state.totalToday {
            state.completedToday += 1
            state.lastUpdate = .now
        }
        write(state)
    }

    static func resetToday() {
        write(HabitState(completedToday: 0, totalToday: 5, lastUpdate: .now))
    }
}

Add this file to both the app target and the widget extension target (File Inspector → Target Membership).

App Intent (shared)

Shared/CompleteHabitIntent.swift:

import AppIntents
import WidgetKit

struct CompleteHabitIntent: AppIntent {
    static var title: LocalizedStringResource = "Complete a Habit"
    static var description = IntentDescription("Marks one habit as completed today.")

    @MainActor
    func perform() async throws -> some IntentResult {
        HabitStore.completeOne()
        return .result()
    }
}

struct ResetHabitsIntent: AppIntent {
    static var title: LocalizedStringResource = "Reset Today"
    @MainActor
    func perform() async throws -> some IntentResult {
        HabitStore.resetToday()
        return .result()
    }
}

Add to both targets.

Widget code

Replace the generated HabitWidget.swift:

import WidgetKit
import SwiftUI
import AppIntents

struct HabitEntry: TimelineEntry {
    let date: Date
    let state: HabitState
}

struct HabitProvider: TimelineProvider {
    func placeholder(in context: Context) -> HabitEntry {
        HabitEntry(date: .now, state: HabitState(completedToday: 2, totalToday: 5, lastUpdate: .now))
    }
    func getSnapshot(in context: Context, completion: @escaping (HabitEntry) -> Void) {
        completion(HabitEntry(date: .now, state: HabitStore.read()))
    }
    func getTimeline(in context: Context, completion: @escaping (Timeline<HabitEntry>) -> Void) {
        let entry = HabitEntry(date: .now, state: HabitStore.read())
        completion(Timeline(entries: [entry], policy: .never))
    }
}

struct HabitWidgetView: View {
    @Environment(\.widgetFamily) var family
    let entry: HabitEntry

    var body: some View {
        switch family {
        case .accessoryCircular:
            Gauge(value: progress) {
                Image(systemName: "checkmark.circle")
            } currentValueLabel: {
                Text("\(entry.state.completedToday)")
            }
            .gaugeStyle(.accessoryCircularCapacity)

        case .accessoryRectangular:
            VStack(alignment: .leading) {
                Text("Today's Habits")
                    .font(.caption)
                Text("\(entry.state.completedToday)/\(entry.state.totalToday)")
                    .font(.title2.bold())
            }

        case .accessoryInline:
            Text("Habits: \(entry.state.completedToday)/\(entry.state.totalToday)")

        default:
            VStack(spacing: 8) {
                Text("Today")
                    .font(.caption)
                    .foregroundStyle(.secondary)
                Text("\(entry.state.completedToday) / \(entry.state.totalToday)")
                    .font(.title.bold())
                HStack {
                    Button(intent: CompleteHabitIntent()) {
                        Image(systemName: "plus")
                    }
                    .buttonStyle(.borderedProminent)

                    Button(intent: ResetHabitsIntent()) {
                        Image(systemName: "arrow.counterclockwise")
                    }
                    .buttonStyle(.bordered)
                }
            }
            .padding()
        }
    }

    private var progress: Double {
        guard entry.state.totalToday > 0 else { return 0 }
        return Double(entry.state.completedToday) / Double(entry.state.totalToday)
    }
}

struct HabitWidget: Widget {
    let kind = "HabitWidget"

    var body: some WidgetConfiguration {
        StaticConfiguration(kind: kind, provider: HabitProvider()) { entry in
            HabitWidgetView(entry: entry)
                .containerBackground(.fill.tertiary, for: .widget)
        }
        .configurationDisplayName("Habit Tracker")
        .description("See and bump today's progress.")
        .supportedFamilies([
            .systemSmall, .systemMedium,
            .accessoryCircular, .accessoryRectangular, .accessoryInline,
        ])
    }
}

Live Activity (the “streak in progress” timer)

HabitStreakAttributes.swift (in widget target, also Live Activity target if separate):

import ActivityKit
import SwiftUI
import WidgetKit

struct HabitStreakAttributes: ActivityAttributes {
    public struct ContentState: Codable, Hashable {
        var startedAt: Date
        var goalMinutes: Int
    }
    var habitName: String
}

HabitStreakActivityWidget.swift:

import ActivityKit
import WidgetKit
import SwiftUI

struct HabitStreakActivityWidget: Widget {
    var body: some WidgetConfiguration {
        ActivityConfiguration(for: HabitStreakAttributes.self) { context in
            // Lock Screen / Notification Center presentation
            VStack(alignment: .leading) {
                Text(context.attributes.habitName).font(.headline)
                Text(timerInterval: context.state.startedAt...context.state.startedAt.addingTimeInterval(Double(context.state.goalMinutes * 60)))
                    .font(.title2.monospacedDigit())
            }
            .padding()
            .activityBackgroundTint(.indigo.opacity(0.2))
        } dynamicIsland: { context in
            DynamicIsland {
                DynamicIslandExpandedRegion(.leading) {
                    Image(systemName: "checkmark.circle.fill")
                }
                DynamicIslandExpandedRegion(.trailing) {
                    Text(timerInterval: context.state.startedAt...context.state.startedAt.addingTimeInterval(Double(context.state.goalMinutes * 60)))
                        .monospacedDigit()
                }
                DynamicIslandExpandedRegion(.bottom) {
                    Text(context.attributes.habitName)
                }
            } compactLeading: {
                Image(systemName: "checkmark.circle")
            } compactTrailing: {
                Text(timerInterval: context.state.startedAt...context.state.startedAt.addingTimeInterval(Double(context.state.goalMinutes * 60)))
                    .monospacedDigit()
                    .frame(maxWidth: 50)
            } minimal: {
                Image(systemName: "checkmark.circle")
            }
        }
    }
}

Widget bundle

HabitWidgetBundle.swift:

import WidgetKit
import SwiftUI

@main
struct HabitWidgetBundle: WidgetBundle {
    var body: some Widget {
        HabitWidget()
        HabitStreakActivityWidget()
    }
}

Start the Live Activity from the app

Add a button to your app’s ContentView:

import ActivityKit

func startStreak() async {
    let attrs = HabitStreakAttributes(habitName: "Morning meditation")
    let state = HabitStreakAttributes.ContentState(startedAt: .now, goalMinutes: 10)
    do {
        let activity = try Activity.request(
            attributes: attrs,
            content: ActivityContent(state: state, staleDate: nil),
            pushType: nil
        )
        print("Activity started: \(activity.id)")
    } catch {
        print("Failed: \(error)")
    }
}

Info.plist of the app target:

  • NSSupportsLiveActivities = YES.

Build & test

  1. Run the app, tap “Start streak” → Live Activity appears on Lock Screen / Dynamic Island.
  2. Long-press Home Screen → + → search “Habit Tracker” → add small or medium widget. Tap +/reset buttons; the count updates without launching the app.
  3. Long-press Lock Screen → Customize → add the accessory widget.

Stretch

  1. Configurable widget — convert to AppIntentConfiguration letting the user pick a target habit count.
  2. Watch complication — add accessoryCorner family; build with a watchOS target sharing the same widget code.
  3. CoreML hook — classify the most recent photo and surface its label on the widget (see Chapter 7.8).
  4. HealthKit step count — show today’s step total from HealthKit on the widget by reading from the same app group cache.
  5. Push-driven Live Activity — switch pushType: .token and POST updates from a server (see Chapter 7.1 for APNs).

Notes

  • Interactive widget Buttons require iOS 17+. On older OSes the buttons appear but tapping just opens the app.
  • App Group is the single most error-prone step. If your widget shows stale data, double-check both targets share the same group.* identifier and that you wrote to the same UserDefaults(suiteName:).
  • WidgetCenter.shared.reloadAllTimelines() after data writes is mandatory; widgets don’t poll.
  • Live Activities are budgeted by iOS — about 8 hours max active duration, fewer than 10 active across the system at once.

Next: Lab 7.3 — StoreKit 2 IAP