Build a Spending Tracker with Interactive Widgets and Live Activities


Pixar’s production teams track hundreds of millions of dollars across thousands of line items — from lighting renders to catering for the voice cast. Imagine if the producer of Inside Out 2 could glance at their Lock Screen and see the running daily spend update in real time, or tap a home screen widget to log a new expense without ever opening the app.

In this tutorial, you’ll build PixarBudget — a production expense tracker for a fictional Pixar film that displays spending data on the home screen using WidgetKit, supports three widget sizes, and pushes a live running total to the Lock Screen using ActivityKit. Along the way, you’ll learn how to build timeline-based widgets with custom providers, share data between an app and its widgets using App Groups, add interactive widget buttons with AppIntents, and start, update, and end Live Activities.

Prerequisites

Contents

Getting Started

Open Xcode and create a new project using the App template.

  1. Set the product name to PixarBudget.
  2. Ensure the interface is SwiftUI and the language is Swift.
  3. Set the deployment target to iOS 17.0.
  4. Enable Swift 6 language mode in the project’s build settings under Swift Language Version.

Your project will serve as the host for both the main app and the widget extension. Before writing any code, add two capabilities to the main app target:

  • Open the Signing & Capabilities tab, click + Capability, and add App Groups. Create a new group with the identifier group.com.yourname.pixarbudget. You will use this exact identifier throughout the tutorial — replace yourname with your own Apple Developer identifier.
  • Add the Push Notifications capability (required for Live Activities push token delivery).

Note: App Groups are how the main app and its widget extension share a UserDefaults store and a common file container. Without this, the widget cannot read the expenses you enter in the app.

Step 1: Defining the Shared Data Model

The data model lives in a file both the app and the widget extension can access. The cleanest way to do this is to place shared types in a Swift package or a shared framework, but for a self-contained tutorial the simplest approach is to add the source files to both targets manually in the Target Membership panel.

Create a new Swift file at PixarBudget/Models/Expense.swift:

import Foundation

// Codable so we can encode/decode through UserDefaults (App Groups)
struct Expense: Identifiable, Codable, Sendable {
    let id: UUID
    var title: String
    var amount: Double
    var category: ExpenseCategory
    var date: Date

    init(
        id: UUID = UUID(),
        title: String,
        amount: Double,
        category: ExpenseCategory,
        date: Date = .now
    ) {
        self.id = id
        self.title = title
        self.amount = amount
        self.category = category
        self.date = date
    }
}

enum ExpenseCategory: String, Codable, CaseIterable, Sendable {
    case lighting = "Lighting"
    case animation = "Animation"
    case sound = "Sound"
    case catering = "Catering"
    case travel = "Travel"
    case postProduction = "Post-Production"

    var emoji: String {
        switch self {
        case .lighting: return "💡"
        case .animation: return "🎬"
        case .sound: return "🎵"
        case .catering: return "🍕"
        case .travel: return "✈️"
        case .postProduction: return "🎞️"
        }
    }
}

Next, create PixarBudget/Models/ExpenseStore.swift. This class is the single source of truth for the app — it reads and writes to the shared UserDefaults App Group container so the widget can read today’s totals without launching the full app:

import Foundation
import WidgetKit

// MARK: - App Group key constants

enum SharedDefaults {
    static let suiteName = "group.com.yourname.pixarbudget"
    static let todayExpensesKey = "todayExpenses"
    static let totalBudgetKey = "totalBudget"
    static let dailyLimitKey = "dailyLimit"
}

// MARK: - ExpenseStore

@MainActor
final class ExpenseStore: ObservableObject {
    @Published private(set) var todayExpenses: [Expense] = []

    private let defaults: UserDefaults

    init() {
        self.defaults = UserDefaults(suiteName: SharedDefaults.suiteName)
            ?? UserDefaults.standard
        loadFromDefaults()
    }

    var totalToday: Double {
        todayExpenses.reduce(0) { $0 + $1.amount }
    }

    var dailyLimit: Double {
        defaults.double(forKey: SharedDefaults.dailyLimitKey).nonZero ?? 50_000
    }

    // MARK: - Mutations

    func add(_ expense: Expense) {
        todayExpenses.append(expense)
        persist()
    }

    func remove(at offsets: IndexSet) {
        todayExpenses.remove(atOffsets: offsets)
        persist()
    }

    // MARK: - Private helpers

    private func persist() {
        guard let data = try? JSONEncoder().encode(todayExpenses) else { return }
        defaults.set(data, forKey: SharedDefaults.todayExpensesKey)
        // Tell WidgetKit to reload all timelines immediately
        WidgetCenter.shared.reloadAllTimelines()
    }

    private func loadFromDefaults() {
        guard
            let data = defaults.data(forKey: SharedDefaults.todayExpensesKey),
            let saved = try? JSONDecoder().decode([Expense].self, from: data)
        else { return }
        todayExpenses = saved
    }
}

private extension Double {
    var nonZero: Double? { self == 0 ? nil : self }
}

Apple Docs: WidgetCenter — WidgetKit

Note: WidgetCenter.shared.reloadAllTimelines() is the correct way to tell WidgetKit that new data is available. The system schedules a fresh timeline fetch on its own cadence — you cannot force an immediate on-screen update from inside the app.

Step 2: Building the Main App View

With the model in place, wire up the SwiftUI interface. Open PixarBudget/PixarBudgetApp.swift and inject the store as an environment object:

import SwiftUI

@main
struct PixarBudgetApp: App {
    @StateObject private var store = ExpenseStore()

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(store)
        }
    }
}

Create PixarBudget/Views/ContentView.swift:

import SwiftUI

struct ContentView: View {
    @EnvironmentObject private var store: ExpenseStore
    @State private var showingAddExpense = false

    var body: some View {
        NavigationStack {
            List {
                Section {
                    BudgetSummaryRow(
                        spent: store.totalToday,
                        limit: store.dailyLimit
                    )
                }
                Section("Today's Expenses") {
                    ForEach(store.todayExpenses) { expense in
                        ExpenseRow(expense: expense)
                    }
                    .onDelete(perform: store.remove)
                }
            }
            .navigationTitle("PixarBudget")
            .toolbar {
                ToolbarItem(placement: .primaryAction) {
                    Button("Add", systemImage: "plus") {
                        showingAddExpense = true
                    }
                }
            }
            .sheet(isPresented: $showingAddExpense) {
                AddExpenseView()
                    .environmentObject(store)
            }
        }
    }
}

Create PixarBudget/Views/BudgetSummaryRow.swift to display a progress bar and current totals:

import SwiftUI

struct BudgetSummaryRow: View {
    let spent: Double
    let limit: Double

    private var progress: Double { min(spent / limit, 1.0) }
    private var isOverBudget: Bool { spent > limit }

    var body: some View {
        VStack(alignment: .leading, spacing: 8) {
            HStack {
                Text("Daily Spend")
                    .font(.headline)
                Spacer()
                Text(spent, format: .currency(code: "USD"))
                    .foregroundStyle(isOverBudget ? .red : .primary)
                    .fontWeight(.semibold)
            }
            ProgressView(value: progress)
                .tint(isOverBudget ? .red : .blue)
            HStack {
                Text("Limit: \(limit, format: .currency(code: "USD"))")
                    .font(.caption)
                    .foregroundStyle(.secondary)
                Spacer()
                if isOverBudget {
                    Text("Over budget!")
                        .font(.caption)
                        .foregroundStyle(.red)
                }
            }
        }
        .padding(.vertical, 4)
    }
}

Create PixarBudget/Views/ExpenseRow.swift:

import SwiftUI

struct ExpenseRow: View {
    let expense: Expense

    var body: some View {
        HStack {
            Text(expense.category.emoji)
                .font(.title2)
            VStack(alignment: .leading) {
                Text(expense.title)
                    .font(.subheadline)
                Text(expense.category.rawValue)
                    .font(.caption)
                    .foregroundStyle(.secondary)
            }
            Spacer()
            Text(expense.amount, format: .currency(code: "USD"))
                .fontWeight(.medium)
        }
    }
}

Create PixarBudget/Views/AddExpenseView.swift:

import SwiftUI

struct AddExpenseView: View {
    @EnvironmentObject private var store: ExpenseStore
    @Environment(\.dismiss) private var dismiss

    @State private var title = ""
    @State private var amountText = ""
    @State private var category: ExpenseCategory = .animation

    private var amount: Double { Double(amountText) ?? 0 }
    private var isValid: Bool { !title.isEmpty && amount > 0 }

    var body: some View {
        NavigationStack {
            Form {
                TextField("e.g. Render farm time", text: $title)
                TextField("Amount (USD)", text: $amountText)
                    .keyboardType(.decimalPad)
                Picker("Category", selection: $category) {
                    ForEach(ExpenseCategory.allCases, id: \.self) { cat in
                        Text("\(cat.emoji) \(cat.rawValue)").tag(cat)
                    }
                }
            }
            .navigationTitle("New Expense")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .cancellationAction) {
                    Button("Cancel") { dismiss() }
                }
                ToolbarItem(placement: .confirmationAction) {
                    Button("Add") {
                        store.add(Expense(
                            title: title,
                            amount: amount,
                            category: category
                        ))
                        dismiss()
                    }
                    .disabled(!isValid)
                }
            }
        }
    }
}

Checkpoint: Build and run on a simulator or device. You should see the PixarBudget navigation list with a budget summary at the top. Tap the + button and add a test expense — for example “Render farm time” under Animation for $12,000. The expense should appear in the list and the progress bar should advance. If the app crashes on launch, double-check that ExpenseStore is injected in PixarBudgetApp.

Step 3: Setting Up the Widget Extension

With the main app working, it’s time to add the widget extension target.

  1. In Xcode, go to File → New → Target.
  2. Choose Widget Extension under the iOS tab.
  3. Name it PixarBudgetWidget.
  4. Uncheck Include Live Activity for now — you’ll configure that manually. Uncheck Include Configuration App Intent as well.
  5. Click Finish and activate the scheme when prompted.

Now add the extension to your App Group:

  1. Select the PixarBudgetWidget target in the project navigator.
  2. Open Signing & Capabilities.
  3. Click + Capability → App Groups.
  4. Select the same group you created earlier: group.com.yourname.pixarbudget.

Add the shared model files to the widget target. Select Expense.swift and ExpenseStore.swift in the project navigator. In the File Inspector on the right, check PixarBudgetWidget under Target Membership. The widget extension needs to read the same Expense and SharedDefaults types.

Apple Docs: WidgetKit — Apple Developer Documentation

Step 4: Creating the Timeline Provider

The heart of every widget is its TimelineProvider. The provider supplies placeholder data, a snapshot (used in the widget gallery), and the full timeline of entries the widget should display over time.

Create PixarBudgetWidget/Provider/BudgetTimelineProvider.swift. Make sure its Target Membership is set to PixarBudgetWidget only:

import WidgetKit
import Foundation

// MARK: - Timeline Entry

struct BudgetEntry: TimelineEntry {
    let date: Date
    let expenses: [Expense]
    let dailyLimit: Double

    var totalSpent: Double {
        expenses.reduce(0) { $0 + $1.amount }
    }

    var topCategory: ExpenseCategory? {
        let grouped = Dictionary(grouping: expenses, by: \.category)
        return grouped
            .max(by: { $0.value.count < $1.value.count })?
            .key
    }

    // Static placeholder — shown while the real data loads
    static let placeholder = BudgetEntry(
        date: .now,
        expenses: [
            Expense(title: "Render farm", amount: 12_500, category: .animation),
            Expense(title: "Voice talent", amount: 8_000, category: .sound),
        ],
        dailyLimit: 50_000
    )
}

// MARK: - Timeline Provider

struct BudgetTimelineProvider: TimelineProvider {
    typealias Entry = BudgetEntry

    // Called immediately when the widget appears for the first time
    func placeholder(in context: Context) -> BudgetEntry {
        .placeholder
    }

    // Called for the widget gallery preview
    func getSnapshot(
        in context: Context,
        completion: @escaping (BudgetEntry) -> Void
    ) {
        completion(context.isPreview ? .placeholder : makeEntry())
    }

    // Called to supply the full timeline of entries
    func getTimeline(
        in context: Context,
        completion: @escaping (Timeline<BudgetEntry>) -> Void
    ) {
        let entry = makeEntry()

        // Reload at the start of the next hour so the "today" filter stays fresh
        let nextHour = Calendar.current.nextDate(
            after: .now,
            matching: DateComponents(minute: 0),
            matchingPolicy: .nextTime
        ) ?? Date(timeIntervalSinceNow: 3600)

        let timeline = Timeline(
            entries: [entry],
            policy: .after(nextHour)
        )
        completion(timeline)
    }

    // MARK: - Private helpers

    private func makeEntry() -> BudgetEntry {
        let defaults = UserDefaults(suiteName: SharedDefaults.suiteName)
            ?? UserDefaults.standard

        let expenses: [Expense]
        if
            let data = defaults.data(forKey: SharedDefaults.todayExpensesKey),
            let decoded = try? JSONDecoder().decode([Expense].self, from: data)
        {
            // Filter to today's expenses only in case old data is present
            let startOfDay = Calendar.current.startOfDay(for: .now)
            expenses = decoded.filter { $0.date >= startOfDay }
        } else {
            expenses = []
        }

        let limit = defaults.double(forKey: SharedDefaults.dailyLimitKey)
        return BudgetEntry(
            date: .now,
            expenses: expenses,
            dailyLimit: limit == 0 ? 50_000 : limit
        )
    }
}

The .after(nextHour) reload policy tells WidgetKit to request a fresh timeline at the next hour boundary. The app calls WidgetCenter.shared.reloadAllTimelines() when an expense is added, so in practice the widget updates immediately after each new entry — the hourly reload is just a safety net.

Apple Docs: TimelineProvider — WidgetKit

Tip: Use .after(_:) for widgets whose data changes on a known schedule (e.g., sports scores at game time). Use .atEnd when all entries are pre-computed and no refresh is needed until the last entry expires. Use .never for truly static content.

Step 5: Building the Widget Views for All Sizes

WidgetKit supports four widget families: systemSmall, systemMedium, systemLarge, and systemExtraLarge. Each family provides a different amount of horizontal and vertical space. Read the WidgetFamily documentation for exact point dimensions.

Create PixarBudgetWidget/Views/BudgetWidgetView.swift:

import WidgetKit
import SwiftUI

struct BudgetWidgetView: View {
    @Environment(\.widgetFamily) private var family
    let entry: BudgetEntry

    var body: some View {
        switch family {
        case .systemSmall:
            SmallBudgetView(entry: entry)
        case .systemMedium:
            MediumBudgetView(entry: entry)
        case .systemLarge:
            LargeBudgetView(entry: entry)
        default:
            SmallBudgetView(entry: entry)
        }
    }
}

Create the small view — it shows the total spent and a compact gauge:

// MARK: - Small

struct SmallBudgetView: View {
    let entry: BudgetEntry

    private var progress: Double {
        entry.dailyLimit > 0
            ? min(entry.totalSpent / entry.dailyLimit, 1.0)
            : 0
    }
    private var isOver: Bool { entry.totalSpent > entry.dailyLimit }

    var body: some View {
        VStack(alignment: .leading, spacing: 6) {
            Text("🎬 Daily")
                .font(.caption2)
                .foregroundStyle(.secondary)
            Text(entry.totalSpent, format: .currency(code: "USD"))
                .font(.title3.bold())
                .minimumScaleFactor(0.7)
                .foregroundStyle(isOver ? .red : .primary)
            Gauge(value: progress) {
                EmptyView()
            }
            .gaugeStyle(.linearCapacity)
            .tint(isOver ? .red : .blue)
            Text("of \(entry.dailyLimit, format: .currency(code: "USD"))")
                .font(.caption2)
                .foregroundStyle(.secondary)
        }
        .padding()
        .containerBackground(.fill.tertiary, for: .widget)
    }
}

Create the medium view — adds a short expense list on the right:

// MARK: - Medium

struct MediumBudgetView: View {
    let entry: BudgetEntry

    private var recentThree: [Expense] {
        Array(entry.expenses.suffix(3).reversed())
    }
    private var progress: Double {
        entry.dailyLimit > 0
            ? min(entry.totalSpent / entry.dailyLimit, 1.0)
            : 0
    }

    var body: some View {
        HStack(alignment: .top, spacing: 16) {
            // Left column — total + gauge
            VStack(alignment: .leading, spacing: 6) {
                Text("🎬 PixarBudget")
                    .font(.caption.bold())
                    .foregroundStyle(.secondary)
                Text(entry.totalSpent, format: .currency(code: "USD"))
                    .font(.title2.bold())
                    .minimumScaleFactor(0.6)
                Gauge(value: progress) {
                    EmptyView()
                }
                .gaugeStyle(.linearCapacity)
                .tint(entry.totalSpent > entry.dailyLimit ? .red : .blue)
                Text("\(entry.expenses.count) expenses today")
                    .font(.caption2)
                    .foregroundStyle(.secondary)
                Button(intent: LogQuickExpenseIntent()) {
                    Label("+ $5K Render", systemImage: "plus.circle.fill")
                        .font(.caption.bold())
                }
                .buttonStyle(.borderedProminent)
                .tint(.blue)
            }
            .frame(maxWidth: .infinity, alignment: .leading)

            Divider()

            // Right column — recent expenses
            VStack(alignment: .leading, spacing: 4) {
                ForEach(recentThree) { expense in
                    HStack {
                        Text(expense.category.emoji)
                        Text(expense.title)
                            .font(.caption)
                            .lineLimit(1)
                        Spacer()
                        Text(expense.amount, format: .currency(code: "USD"))
                            .font(.caption.monospacedDigit())
                    }
                }
                if entry.expenses.isEmpty {
                    Text("No expenses yet")
                        .font(.caption)
                        .foregroundStyle(.secondary)
                }
            }
            .frame(maxWidth: .infinity, alignment: .leading)
        }
        .padding()
        .containerBackground(.fill.tertiary, for: .widget)
    }
}

Create the large view — shows a full breakdown by category:

// MARK: - Large

struct LargeBudgetView: View {
    let entry: BudgetEntry

    private var byCategory: [(ExpenseCategory, Double)] {
        var totals = [ExpenseCategory: Double]()
        for expense in entry.expenses {
            totals[expense.category, default: 0] += expense.amount
        }
        return totals.sorted { $0.value > $1.value }
    }

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            // Header
            HStack {
                VStack(alignment: .leading) {
                    Text("🎬 PixarBudget")
                        .font(.caption.bold())
                        .foregroundStyle(.secondary)
                    Text(entry.totalSpent, format: .currency(code: "USD"))
                        .font(.title.bold())
                }
                Spacer()
                VStack(alignment: .trailing) {
                    Text("Limit")
                        .font(.caption2)
                        .foregroundStyle(.secondary)
                    Text(entry.dailyLimit, format: .currency(code: "USD"))
                        .font(.subheadline)
                }
            }

            ProgressView(
                value: min(entry.totalSpent / max(entry.dailyLimit, 1), 1.0)
            )
            .tint(entry.totalSpent > entry.dailyLimit ? .red : .blue)

            Divider()

            // Category breakdown
            Text("By Category")
                .font(.caption.bold())
                .foregroundStyle(.secondary)

            ForEach(byCategory, id: \.0) { category, total in
                HStack {
                    Text(category.emoji)
                    Text(category.rawValue)
                        .font(.subheadline)
                    Spacer()
                    Text(total, format: .currency(code: "USD"))
                        .font(.subheadline.monospacedDigit())
                }
            }

            if byCategory.isEmpty {
                Text("No expenses logged today")
                    .font(.subheadline)
                    .foregroundStyle(.secondary)
            }
        }
        .padding()
        .containerBackground(.fill.tertiary, for: .widget)
    }
}

Now register all three sizes in the widget entry point. Open the auto-generated PixarBudgetWidgetBundle.swift and replace its contents:

import WidgetKit
import SwiftUI

@main
struct PixarBudgetWidgetBundle: WidgetBundle {
    var body: some Widget {
        BudgetWidget()
    }
}

struct BudgetWidget: Widget {
    let kind = "BudgetWidget"

    var body: some WidgetConfiguration {
        StaticConfiguration(
            kind: kind,
            provider: BudgetTimelineProvider()
        ) { entry in
            BudgetWidgetView(entry: entry)
        }
        .configurationDisplayName("PixarBudget")
        .description("Track today's production spending at a glance.")
        .supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
    }
}

Apple Docs: StaticConfiguration — WidgetKit

Checkpoint: Build and run the PixarBudgetWidget scheme (set it in the toolbar). Xcode will install the extension and open the widget simulator. Add the widget to the home screen in all three sizes. You should see the budget gauge and expense list populated from the placeholder data. If the build fails with a missing symbol error, verify that Expense.swift has PixarBudgetWidget checked in Target Membership.

Step 6: Adding Interactive Buttons with AppIntents

Since iOS 17, widgets can contain interactive controls — buttons and toggles — backed by AppIntents. This allows users to log a quick expense directly from the home screen without opening the app.

Create PixarBudgetWidget/Intents/LogQuickExpenseIntent.swift. Add it to both the PixarBudget app target and PixarBudgetWidget — AppIntents must be present in the host app to function correctly:

import AppIntents
import Foundation
import WidgetKit

struct LogQuickExpenseIntent: AppIntent {
    static let title: LocalizedStringResource = "Log Quick Expense"
    static let description = IntentDescription(
        "Log a preset expense to PixarBudget."
    )

    // The category and amount are baked in — this intent is a shortcut for
    // the most common Pixar production expense: a render farm block.
    func perform() async throws -> some IntentResult {
        let expense = Expense(
            title: "Quick Render Block",
            amount: 5_000,
            category: .animation
        )

        let defaults = UserDefaults(suiteName: SharedDefaults.suiteName)
            ?? UserDefaults.standard

        var current: [Expense] = []
        if
            let data = defaults.data(forKey: SharedDefaults.todayExpensesKey),
            let decoded = try? JSONDecoder().decode([Expense].self, from: data)
        {
            current = decoded
        }
        current.append(expense)

        if let encoded = try? JSONEncoder().encode(current) {
            defaults.set(encoded, forKey: SharedDefaults.todayExpensesKey)
        }

        // Notify WidgetKit that the timeline is stale and should be refreshed
        WidgetCenter.shared.reloadTimelines(ofKind: "BudgetWidget")

        return .result()
    }
}

Apple Docs: AppIntent — AppIntents

Warning: Interactive widgets require iOS 17+. If your deployment target is iOS 16, wrap the button in an #if canImport(...) check or use @available(iOS 17, *). Because this tutorial targets iOS 17, the button is always available.

Tip: You can pass parameters to an AppIntent by declaring @Parameter properties. For example, add @Parameter(title: "Amount") var amount: Double and let users pick from preset options using DynamicOptionsProvider.

Checkpoint: Build and run the PixarBudgetWidget scheme again. The medium widget should now show a blue ”+ $5K Render” button. Tap it — the expense should appear when you switch to the main app. If the button appears but tapping does nothing, verify that LogQuickExpenseIntent is added to the main PixarBudget app target, not just the extension.

Step 7: Sharing Data with App Groups

You already configured the App Group capability in Getting Started and read from UserDefaults with the suite name in the intent. This step adds one important call: reloadTimelines(ofKind:), which you already added at the end of LogQuickExpenseIntent.perform().

The ofKind: parameter matches the kind string you used in BudgetWidget. Using reloadTimelines(ofKind:) is more targeted than reloadAllTimelines() — it avoids disturbing other widgets your app might provide.

Apple Docs: UserDefaults — Foundation

Note: You cannot use @Model SwiftData objects directly from a widget extension — SwiftData’s ModelContainer can be accessed from an extension, but for simple data like daily expense totals UserDefaults (via App Groups) is significantly faster to set up and read. For a production app you’d create a shared ModelContainer with a common App Group URL.

Step 8: Starting the Live Activity

ActivityKit powers Lock Screen Live Activities and the Dynamic Island. A Live Activity is declared as an ActivityAttributes struct that separates static data (things that don’t change for the lifetime of the activity) from dynamic content state (data that updates).

Note: Live Activities require iOS 16.2+. All Live Activity code in this tutorial is gated with @available(iOS 16.2, *).

Create PixarBudget/LiveActivity/BudgetActivityAttributes.swift. Add it to both targets:

import ActivityKit
import Foundation

@available(iOS 16.2, *)
struct BudgetActivityAttributes: ActivityAttributes {
    // Static: set when the activity starts, never changes
    struct ContentState: Codable, Hashable {
        var totalSpent: Double
        var dailyLimit: Double
        var lastExpenseTitle: String
    }

    // The film title stays constant for the life of the activity
    var filmTitle: String
}

Now add a button to ContentView to start the Live Activity. Open PixarBudget/Views/ContentView.swift and add a state property and a toolbar item:

// At the top of ContentView, add this property:
@State private var activityID: String? = nil

// Add to the .toolbar modifier after the existing ToolbarItem:
ToolbarItem(placement: .navigationBarLeading) {
    if #available(iOS 16.2, *) {
        Button(activityID == nil ? "Start Live" : "Stop Live") {
            if activityID == nil {
                startLiveActivity()
            } else {
                stopLiveActivity()
            }
        }
        .tint(activityID == nil ? .green : .red)
    }
}

Add the startLiveActivity() function to ContentView. Because this calls into ActivityKit, keep it scoped with @available:

@available(iOS 16.2, *)
private func startLiveActivity() {
    let attributes = BudgetActivityAttributes(filmTitle: "Inside Out 3")
    let initialState = BudgetActivityAttributes.ContentState(
        totalSpent: store.totalToday,
        dailyLimit: store.dailyLimit,
        lastExpenseTitle: store.todayExpenses.last?.title ?? "No expenses yet"
    )

    let content = ActivityContent(
        state: initialState,
        staleDate: Calendar.current.date(
            byAdding: .hour, value: 8, to: .now
        )
    )

    do {
        let activity = try Activity.request(
            attributes: attributes,
            content: content,
            pushType: nil  // Use nil unless you set up push-to-update
        )
        activityID = activity.id
    } catch {
        print("Failed to start Live Activity: \(error)")
    }
}

Apple Docs: ActivityKit — ActivityKit

Before the activity can appear on screen, you must declare it in Info.plist. Select the PixarBudget target, go to the Info tab, and add a new key:

  • Key: NSSupportsLiveActivities
  • Type: Boolean
  • Value: YES

You also need to create the Lock Screen and Dynamic Island views. Create PixarBudget/LiveActivity/BudgetLiveActivityView.swift:

import ActivityKit
import SwiftUI
import WidgetKit

@available(iOS 16.2, *)
struct BudgetLiveActivityView: Widget {
    var body: some WidgetConfiguration {
        ActivityConfiguration(
            for: BudgetActivityAttributes.self
        ) { context in
            // Lock Screen / StandBy expanded view
            HStack {
                VStack(alignment: .leading) {
                    Text("🎬 \(context.attributes.filmTitle)")
                        .font(.caption.bold())
                    Text(context.state.totalSpent,
                         format: .currency(code: "USD"))
                        .font(.title3.bold())
                }
                Spacer()
                VStack(alignment: .trailing) {
                    Text("Limit")
                        .font(.caption2)
                        .foregroundStyle(.secondary)
                    Text(context.state.dailyLimit,
                         format: .currency(code: "USD"))
                        .font(.caption)
                }
            }
            .padding()
            .activityBackgroundTint(Color.black.opacity(0.6))
        } dynamicIsland: { context in
            DynamicIsland {
                // Expanded regions
                DynamicIslandExpandedRegion(.leading) {
                    Label(
                        context.state.lastExpenseTitle,
                        systemImage: "creditcard"
                    )
                    .font(.caption)
                    .lineLimit(1)
                }
                DynamicIslandExpandedRegion(.trailing) {
                    Text(context.state.totalSpent,
                         format: .currency(code: "USD"))
                        .font(.caption.bold())
                }
                DynamicIslandExpandedRegion(.bottom) {
                    ProgressView(
                        value: min(
                            context.state.totalSpent /
                                max(context.state.dailyLimit, 1),
                            1.0
                        )
                    )
                    .tint(.blue)
                    .padding(.horizontal)
                }
            } compactLeading: {
                Text("🎬")
            } compactTrailing: {
                Text(context.state.totalSpent,
                     format: .currency(code: "USD"))
                    .font(.caption2.monospacedDigit())
                    .minimumScaleFactor(0.5)
            } minimal: {
                Text("🎬")
            }
        }
    }
}

Register this view in the widget bundle. Open PixarBudgetWidgetBundle.swift and update the bundle:

@main
struct PixarBudgetWidgetBundle: WidgetBundle {
    var body: some Widget {
        BudgetWidget()
        if #available(iOS 16.2, *) {
            BudgetLiveActivityView()
        }
    }
}

Apple Docs: ActivityConfiguration — ActivityKit

Apple Docs: DynamicIsland — ActivityKit

Checkpoint: Build and run on a physical device running iOS 16.2+ (Live Activities do not work in the Simulator). Tap Start Live in the navigation bar. You should see the PixarBudget Live Activity appear on the Lock Screen. On an iPhone with Dynamic Island, lock the device and confirm the compact view shows the film emoji and current total. If the activity does not appear, verify NSSupportsLiveActivities is set to YES in Info.plist and that the PixarBudgetWidget scheme is installed on the device.

Step 9: Updating and Ending the Live Activity

A Live Activity is only useful if it stays current. Every time the user logs a new expense, the activity’s ContentState should update to reflect the new total.

Add an updateLiveActivity() helper to ContentView:

@available(iOS 16.2, *)
private func updateLiveActivity() {
    guard let id = activityID else { return }

    // Find the running activity by ID
    guard let activity = Activity<BudgetActivityAttributes>
        .activities.first(where: { $0.id == id })
    else { return }

    let newState = BudgetActivityAttributes.ContentState(
        totalSpent: store.totalToday,
        dailyLimit: store.dailyLimit,
        lastExpenseTitle: store.todayExpenses.last?.title ?? "No expenses yet"
    )

    Task {
        await activity.update(
            ActivityContent(
                state: newState,
                staleDate: Calendar.current.date(
                    byAdding: .hour, value: 8, to: .now
                )
            )
        )
    }
}

Call this whenever expenses change. Modify ExpenseStore.add(_:) by posting a notification after persist():

// At the bottom of ExpenseStore.add(_:), after persist():
NotificationCenter.default.post(
    name: .expenseAdded,
    object: nil
)

Add the notification name extension in ExpenseStore.swift:

extension Notification.Name {
    static let expenseAdded = Notification.Name("com.pixarbudget.expenseAdded")
}

In ContentView, observe this notification and call updateLiveActivity():

// Add to ContentView.body, as a modifier on NavigationStack:
.onReceive(NotificationCenter.default.publisher(for: .expenseAdded)) { _ in
    if #available(iOS 16.2, *) {
        updateLiveActivity()
    }
}

Add the stopLiveActivity() function:

@available(iOS 16.2, *)
private func stopLiveActivity() {
    guard let id = activityID else { return }
    guard let activity = Activity<BudgetActivityAttributes>
        .activities.first(where: { $0.id == id })
    else {
        activityID = nil
        return
    }

    let finalState = BudgetActivityAttributes.ContentState(
        totalSpent: store.totalToday,
        dailyLimit: store.dailyLimit,
        lastExpenseTitle: "Day wrapped — great work!"
    )

    Task {
        await activity.end(
            ActivityContent(state: finalState, staleDate: nil),
            dismissalPolicy: .after(
                Date(timeIntervalSinceNow: 5)  // Show for 5 more seconds
            )
        )
        await MainActor.run { activityID = nil }
    }
}

Apple Docs: Activity.update(_:) — ActivityKit

Apple Docs: Activity.end(_:dismissalPolicy:) — ActivityKit

Warning: ActivityKit is strict about update frequency. iOS throttles activities to avoid draining the battery — you should not update more than roughly once per minute in normal circumstances. For high-frequency updates (like a stopwatch), use the .alert dismissal policy and push updates via APNs push-to-update tokens.

Checkpoint: Run the app on a physical device. Start the Live Activity, then add a new expense. Lock the device — the Lock Screen activity should refresh to show the updated total and the most recent expense title. Tap Stop Live; the activity should display “Day wrapped — great work!” for five seconds and then dismiss. If updates are not reflected, ensure updateLiveActivity() is being called by checking that the expenseAdded notification fires in the add(_:) method.

Where to Go From Here?

Congratulations! You’ve built PixarBudget — a production expense tracker for a fictional Pixar film that brings spending data to the home screen and Lock Screen using WidgetKit and ActivityKit.

Here’s what you learned:

  • How to architect shared data between an app and a widget extension using App Groups and a common UserDefaults suite.
  • How to implement a TimelineProvider with a time-based reload policy and an immediate reload triggered from the host app via WidgetCenter.
  • How to build widgets at three different sizes (systemSmall, systemMedium, systemLarge) using @Environment(\.widgetFamily).
  • How to make widgets interactive with Button(intent:) and a custom AppIntent, including calling WidgetCenter.reloadTimelines(ofKind:) from within the intent.
  • How to define ActivityAttributes and ContentState and use ActivityKit to start, update, and end a Live Activity on the Lock Screen and Dynamic Island.
  • How to use @available(iOS 16.2, *) to safely gate Live Activity code for apps that support older deployment targets.

Ideas for extending this project:

  • Add push-to-update support so the Live Activity refreshes from a server without the user opening the app. Set pushType: .token in Activity.request(...) and send the activity push token to your backend.
  • Introduce a second widget using AppIntentConfiguration (instead of StaticConfiguration) so users can pick which film’s budget to display from the widget settings.
  • Migrate the data store from UserDefaults to a shared ModelContainer using SwiftData, using the App Group container URL as the store location.
  • Add a Lock Screen widget (.accessoryCircular, .accessoryRectangular) showing a compact gauge for the iPhone Lock Screen.
  • Use WidgetURL and Link inside widget views to deep-link into a specific expense category when the user taps the widget.