Start a project

Article · iOS · WidgetKit · App Intents · 7 min read

Beyond the app icon: widgets, App Intents and Live Activities

Many of the moments when someone uses your app happen outside it: a glance at a widget, a Shortcut run from the Action button, a Siri request, a delivery tracked on the Lock Screen. Apple has spent several years building system surfaces that apps can feed, and they all rest on three frameworks: App Intents for actions, WidgetKit for glanceable views, and ActivityKit for Live Activities. This guide shows how they fit together, with working code for each, and how to decide which ones are worth building.

On this page 7 sections
  1. Start with the action, not the surface
  2. Writing an App Intent
  3. Sharing data between the app and its extensions
  4. Building a widget
  5. Live Activities
  6. Choosing what to build
  7. Key takeaways

Start with the action, not the surface

App Intents is the foundation. An intent is a single action your app can perform, described in a way the system understands: “log a glass of water”, “start a timer”, “show today’s orders”. Once an action is an intent, the same code can power a Shortcut, a Siri phrase, a Spotlight result, an Action button press, an interactive widget button and a Control Centre control.

So the first planning question isn’t “should we have a widget?” It’s “what are the two or three things people do most often, and could they do them without opening the app?”

Writing an App Intent

Here is an intent for a hydration tracker:

import AppIntents

struct LogWaterIntent: AppIntent {
    static let title: LocalizedStringResource = "Log Water"
    static let description = IntentDescription("Adds a drink to today's total.")

    @Parameter(title: "Amount (ml)", default: 250, inclusiveRange: (50, 1000))
    var millilitres: Int

    func perform() async throws -> some IntentResult & ProvidesDialog {
        let total = try await WaterStore.shared.log(millilitres: millilitres)
        return .result(dialog: "Logged \(millilitres) ml. That's \(total) ml today.")
    }
}

perform() runs without opening the app’s UI, so the intent must do its work through shared code, here a WaterStore, rather than through a view model that only exists when a screen is showing.

To make it available to Siri and Spotlight with no setup by the user, declare it as an App Shortcut:

struct WaterShortcuts: AppShortcutsProvider {
    static var appShortcuts: [AppShortcut] {
        AppShortcut(
            intent: LogWaterIntent(),
            phrases: [
                "Log water in \(.applicationName)",
                "Add a drink in \(.applicationName)"
            ],
            shortTitle: "Log Water",
            systemImageName: "drop.fill"
        )
    }
}

Every phrase must include the app’s name. App Shortcuts appear in the Shortcuts app and Spotlight as soon as the app is installed.

For actions on your app’s content, such as “open the Lisbon trip”, define an AppEntity with an EntityQuery, so the system can ask your app for matching items. Apple keeps extending what Siri can do with entities; the App Intents documentation and the current What’s new in iOS page are the places to check what the latest release adds.

Sharing data between the app and its extensions

Widgets and Live Activities run in a widget extension, a separate process from your app. To read the same data, both need to be in the same App Group (a capability you add to both targets in Xcode), and the shared store has to live in the group’s container:

import Foundation

enum SharedStorage {
    static let groupID = "group.uk.leej.hydrate"

    static var containerURL: URL {
        FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: groupID)!
    }

    static let defaults = UserDefaults(suiteName: groupID)!
}

For SwiftData, point the ModelConfiguration at the App Group with groupContainer: .identifier(SharedStorage.groupID). Keep the data the widget needs small and quick to read; widget extensions have tight memory limits.

If you read shared UserDefaults, remember to declare it in your privacy manifest with the App Group reason; Privacy manifests and App Store review explains how.

Building a widget

A widget is a SwiftUI view plus a timeline provider that tells the system what to show and when:

import WidgetKit
import SwiftUI

struct WaterEntry: TimelineEntry {
    let date: Date
    let totalMillilitres: Int
    let goalMillilitres: Int
}

struct WaterProvider: TimelineProvider {
    func placeholder(in context: Context) -> WaterEntry {
        WaterEntry(date: .now, totalMillilitres: 1250, goalMillilitres: 2000)
    }

    func getSnapshot(in context: Context, completion: @escaping (WaterEntry) -> Void) {
        completion(currentEntry())
    }

    func getTimeline(in context: Context, completion: @escaping (Timeline<WaterEntry>) -> Void) {
        let midnight = Calendar.current.startOfDay(for: .now.addingTimeInterval(86_400))
        completion(Timeline(entries: [currentEntry()], policy: .after(midnight)))
    }

    private func currentEntry() -> WaterEntry {
        WaterEntry(date: .now,
                   totalMillilitres: WaterStore.shared.todayTotal,
                   goalMillilitres: WaterStore.shared.dailyGoal)
    }
}

struct WaterWidgetView: View {
    let entry: WaterEntry

    var body: some View {
        VStack(alignment: .leading, spacing: 6) {
            Label("Water", systemImage: "drop.fill")
                .font(.caption.bold())
                .foregroundStyle(.blue)
            Text("\(entry.totalMillilitres) ml")
                .font(.title2.bold())
                .contentTransition(.numericText())
            Gauge(value: Double(entry.totalMillilitres), in: 0...Double(entry.goalMillilitres)) { EmptyView() }
                .gaugeStyle(.accessoryLinearCapacity)
            Button(intent: LogWaterIntent()) {
                Label("Add 250 ml", systemImage: "plus")
            }
            .font(.caption)
        }
    }
}

struct WaterWidget: Widget {
    var body: some WidgetConfiguration {
        StaticConfiguration(kind: "WaterWidget", provider: WaterProvider()) { entry in
            WaterWidgetView(entry: entry)
                .containerBackground(.fill.tertiary, for: .widget)
        }
        .configurationDisplayName("Water today")
        .description("Today's total against your goal, with a quick add button.")
        .supportedFamilies([.systemSmall])
    }
}

Three things to understand about widgets:

  1. They aren’t live views. The system renders your view for each timeline entry ahead of time and shows the snapshots. Code in the view runs at render time, not continuously. For a countdown, use Text(date, style: .timer), which the system keeps updated for you.
  2. Refreshes are budgeted. The system decides how often your timeline reloads. Ask for what you need, not more. When data changes inside the app, call WidgetCenter.shared.reloadTimelines(ofKind: "WaterWidget") so the widget catches up straight away.
  3. Buttons run intents. Button(intent:) and Toggle(isOn:intent:) run an App Intent in the background and then reload the widget. That’s how the “Add 250 ml” button above works without opening the app.

Apple’s WidgetKit documentation covers the families, including the small accessory widgets for the Lock Screen and Apple Watch. If your app has a watch companion, much of this code can be shared; the Apple Watch design guide covers complications and Smart Stack widgets on the wrist.

Live Activities

A Live Activity shows the progress of something happening right now, such as a delivery, a match score, a workout or a timer, on the Lock Screen and in the Dynamic Island. It lives in the same widget extension.

Define what’s fixed and what changes:

import ActivityKit

struct DeliveryAttributes: ActivityAttributes {
    struct ContentState: Codable, Hashable {
        var stage: String
        var expectedArrival: Date
    }

    var orderNumber: String
}

Describe how it looks:

import WidgetKit
import SwiftUI

struct DeliveryLiveActivity: Widget {
    var body: some WidgetConfiguration {
        ActivityConfiguration(for: DeliveryAttributes.self) { context in
            HStack {
                VStack(alignment: .leading) {
                    Text("Order \(context.attributes.orderNumber)").font(.headline)
                    Text(context.state.stage).foregroundStyle(.secondary)
                }
                Spacer()
                Text(context.state.expectedArrival, style: .relative)
                    .monospacedDigit()
            }
            .padding()
        } dynamicIsland: { context in
            DynamicIsland {
                DynamicIslandExpandedRegion(.leading) {
                    Label(context.state.stage, systemImage: "box.truck")
                }
                DynamicIslandExpandedRegion(.trailing) {
                    Text(context.state.expectedArrival, style: .relative)
                }
            } compactLeading: {
                Image(systemName: "box.truck")
            } compactTrailing: {
                Text(context.state.expectedArrival, style: .relative).monospacedDigit()
            } minimal: {
                Image(systemName: "box.truck")
            }
        }
    }
}

Then start, update and end it from the app (and add NSSupportsLiveActivities set to YES in the app’s Info.plist):

let attributes = DeliveryAttributes(orderNumber: order.number)
let initial = DeliveryAttributes.ContentState(stage: "Preparing", expectedArrival: order.eta)

let activity = try Activity.request(
    attributes: attributes,
    content: .init(state: initial, staleDate: nil),
    pushType: .token   // use nil if you'll only update from the app
)

// Later, when the status changes:
await activity.update(.init(state: .init(stage: "Out for delivery", expectedArrival: newETA), staleDate: nil))

// When it's done:
await activity.end(.init(state: .init(stage: "Delivered", expectedArrival: .now), staleDate: nil),
                   dismissalPolicy: .default)

Limits worth knowing before you promise anything to stakeholders, all from Apple’s Live Activities documentation:

That last point often decides the budget. A Live Activity that only updates while the app is open is simple; one driven by a live backend is a small project of its own.

Choosing what to build

A rough way to prioritise:

If people… Consider
repeat the same quick action often an App Intent, then an interactive widget button or a Control
check one number or status several times a day a Home Screen or Lock Screen widget
wait for something that changes over minutes or hours a Live Activity
ask “where is my…?” or “open my…” App Entities so Siri and Spotlight can find content

Most apps benefit from one or two of these done well, rather than all of them done thinly. A widget nobody adds to their Home Screen costs as much to maintain as one that everyone uses.

Key takeaways

If you’d like to work out which of these would make the biggest difference to your app, or need one built, tell me about your app.

Written by Lee Johnson. Published 30 October 2026.

All articles · Swift A–Z · Apps

Questions, or an app in mind?

Let's bring your ideas to life. Whether you're envisioning a game-changing Apple Watch app or a transformative iOS experience, I'm here to make it happen.

Start a project