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
- Open or create a SwiftUI app called
HabitWidgetLab. Bundle ID:com.example.HabitWidgetLab. - File → New → Target → Widget Extension.
- Name:
HabitWidget. - Uncheck “Include Configuration App Intent” (we’ll add a custom one manually).
- Check “Include Live Activity”.
- Name:
- 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.
- 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
- Run the app, tap “Start streak” → Live Activity appears on Lock Screen / Dynamic Island.
- Long-press Home Screen → + → search “Habit Tracker” → add small or medium widget. Tap
+/resetbuttons; the count updates without launching the app. - Long-press Lock Screen → Customize → add the accessory widget.
Stretch
- Configurable widget — convert to
AppIntentConfigurationletting the user pick a target habit count. - Watch complication — add
accessoryCornerfamily; build with a watchOS target sharing the same widget code. - CoreML hook — classify the most recent photo and surface its label on the widget (see Chapter 7.8).
- HealthKit step count — show today’s step total from HealthKit on the widget by reading from the same app group cache.
- Push-driven Live Activity — switch
pushType: .tokenand 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 sameUserDefaults(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