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
- Xcode 16+ with iOS 17 deployment target (iOS 16.2+ for Live Activities)
- Familiarity with SwiftData
- Familiarity with app lifecycle and background tasks
- Familiarity with WidgetKit and Live Activities concepts
Contents
- Getting Started
- Step 1: Defining the Shared Data Model
- Step 2: Building the Main App View
- Step 3: Setting Up the Widget Extension
- Step 4: Creating the Timeline Provider
- Step 5: Building the Widget Views for All Sizes
- Step 6: Adding Interactive Buttons with AppIntents
- Step 7: Sharing Data with App Groups
- Step 8: Starting the Live Activity
- Step 9: Updating and Ending the Live Activity
- Where to Go From Here?
Getting Started
Open Xcode and create a new project using the App template.
- Set the product name to PixarBudget.
- Ensure the interface is SwiftUI and the language is Swift.
- Set the deployment target to iOS 17.0.
- 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 — replaceyournamewith 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
UserDefaultsstore 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— WidgetKitNote:
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
ExpenseStoreis injected inPixarBudgetApp.
Step 3: Setting Up the Widget Extension
With the main app working, it’s time to add the widget extension target.
- In Xcode, go to File → New → Target.
- Choose Widget Extension under the iOS tab.
- Name it
PixarBudgetWidget. - Uncheck Include Live Activity for now — you’ll configure that manually. Uncheck Include Configuration App Intent as well.
- Click Finish and activate the scheme when prompted.
Now add the extension to your App Group:
- Select the
PixarBudgetWidgettarget in the project navigator. - Open Signing & Capabilities.
- Click + Capability → App Groups.
- 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— WidgetKitTip: Use
.after(_:)for widgets whose data changes on a known schedule (e.g., sports scores at game time). Use.atEndwhen all entries are pre-computed and no refresh is needed until the last entry expires. Use.neverfor 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— WidgetKitCheckpoint: Build and run the
PixarBudgetWidgetscheme (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 thatExpense.swifthasPixarBudgetWidgetchecked 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— AppIntentsWarning: 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
AppIntentby declaring@Parameterproperties. For example, add@Parameter(title: "Amount") var amount: Doubleand let users pick from preset options usingDynamicOptionsProvider.Checkpoint: Build and run the
PixarBudgetWidgetscheme 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 thatLogQuickExpenseIntentis added to the mainPixarBudgetapp 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— FoundationNote: You cannot use
@ModelSwiftData objects directly from a widget extension — SwiftData’sModelContainercan be accessed from an extension, but for simple data like daily expense totalsUserDefaults(via App Groups) is significantly faster to set up and read. For a production app you’d create a sharedModelContainerwith 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— ActivityKitApple Docs:
DynamicIsland— ActivityKitCheckpoint: 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
NSSupportsLiveActivitiesis set toYESinInfo.plistand that thePixarBudgetWidgetscheme 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(_:)— ActivityKitApple Docs:
Activity.end(_:dismissalPolicy:)— ActivityKitWarning: 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
.alertdismissal 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 theexpenseAddednotification fires in theadd(_:)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
UserDefaultssuite. - How to implement a
TimelineProviderwith a time-based reload policy and an immediate reload triggered from the host app viaWidgetCenter. - How to build widgets at three different sizes (
systemSmall,systemMedium,systemLarge) using@Environment(\.widgetFamily). - How to make widgets interactive with
Button(intent:)and a customAppIntent, including callingWidgetCenter.reloadTimelines(ofKind:)from within the intent. - How to define
ActivityAttributesandContentStateand 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: .tokeninActivity.request(...)and send the activity push token to your backend. - Introduce a second widget using
AppIntentConfiguration(instead ofStaticConfiguration) so users can pick which film’s budget to display from the widget settings. - Migrate the data store from
UserDefaultsto a sharedModelContainerusing 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
WidgetURLandLinkinside widget views to deep-link into a specific expense category when the user taps the widget.