Widgets, Live Activities and App Intents in MeteoHealth#
For years, "a good app" meant a good screen inside the app. Open it, look, tap, close it. Starting with iOS 16 through iOS 18, that definition stopped being enough: users increasingly touch your app's logic without ever opening the app — from the Home Screen, from the Dynamic Island, by voice through Siri, or with a single tap in Control Center.
While building MeteoHealth — an app that connects weather, sleep, heart rate, and how you actually feel — it became obvious that the most frequent user actions ("log a glass of water," "check my recovery forecast," "see how the workout is going") didn't deserve a full app launch. That's how interactive widgets, Dynamic Island Live Activities, Siri commands built on App Intents, and a full Apple Watch app with complications ended up in the product. This article walks through how it's built at the code and architecture level, and which decisions actually paid off.
Why this isn't just a checkbox feature#
Three years ago a widget was a static picture refreshed every 15–30 minutes by a timer. Starting with iOS 17, widgets gained buttons and toggles that run code right where they are, without opening the app — through the App Intents framework. iOS 18 added a third channel on top of that: Controls, living in Control Center, on the Lock Screen, and assignable to the Action Button.
For an app like MeteoHealth this isn't decoration — it's a way to remove the friction between "I thought about doing X" and "X is done." Logging water, glancing at a 24-hour risk forecast, watching heart rate mid-workout — all of it is faster when it doesn't require launching the app.
Interactive widgets: an AppIntent right on the Home Screen#
The core idea behind interactive widgets is that a button or toggle inside a widget doesn't open the app — it directly runs a struct conforming to the AppIntent protocol. The system decides whether to spin up your process in the background or reuse a shared data container; from the code's point of view, you simply describe what should happen.
import AppIntents
import WidgetKit
struct LogWaterIntent: AppIntent {
static var title: LocalizedStringResource = "Log a Glass of Water"
static var description = IntentDescription("Adds 250 ml to today's water intake")
@Parameter(title: "Amount (ml)", default: 250)
var amountML: Int
func perform() async throws -> some IntentResult {
try await HydrationStore.shared.addWater(milliliters: amountML)
WidgetCenter.shared.reloadTimelines(ofKind: "HydrationWidget")
return .result()
}
}Here's the same intent wired directly into the widget's SwiftUI layout — no delegates, no openURL, no intermediate screen:
struct HydrationWidgetView: View {
var entry: HydrationEntry
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Water today")
.font(.caption)
.foregroundStyle(.secondary)
Text("\(entry.totalML) ml")
.font(.title2.bold())
Button(intent: LogWaterIntent(amountML: 250)) {
Label("+250 ml", systemImage: "drop.fill")
}
.buttonStyle(.borderedProminent)
.tint(.cyan)
}
.padding()
}
}One thing that tripped me up in my first pass: if the app's and the widget's storage aren't kept in sync (MeteoHealth uses an App Group with Core Data rather than a separate database), the widget's displayed state lags behind the app after a button tap. Calling WidgetCenter.shared.reloadTimelines explicitly inside perform() isn't optional — without it, the widget's UI has no way to know the data changed until the next scheduled timeline refresh.
Live Activities and Dynamic Island: a workout that's always in view#
Live Activities solve a different problem — not "a quick action," but "continuously updating state for a time-bound event." In MeteoHealth that's workouts: while a run or a strength session is in progress, the Lock Screen and Dynamic Island keep showing live heart rate, duration, and calories, without unlocking the phone.
It starts with describing the state through ActivityAttributes:
import ActivityKit
struct WorkoutAttributes: ActivityAttributes {
struct ContentState: Codable, Hashable {
var heartRate: Int
var elapsedSeconds: Int
var caloriesBurned: Int
}
var workoutType: String
}Next comes starting the activity from the main app — typically the moment the user starts a workout on Apple Watch or in the app itself:
func startWorkoutActivity(type: String) {
let attributes = WorkoutAttributes(workoutType: type)
let initialState = WorkoutAttributes.ContentState(
heartRate: 0,
elapsedSeconds: 0,
caloriesBurned: 0
)
do {
let activity = try Activity<WorkoutAttributes>.request(
attributes: attributes,
content: .init(state: initialState, staleDate: nil),
pushType: .token
)
print("Started Live Activity: \(activity.id)")
} catch {
print("Failed to start Live Activity: \(error)")
}
}And finally, the actual Dynamic Island layout — separate compact, minimal, and expanded presentations:
struct WorkoutLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: WorkoutAttributes.self) { context in
WorkoutLockScreenView(context: context)
} dynamicIsland: { context in
DynamicIsland {
DynamicIslandExpandedRegion(.leading) {
Label("\(context.state.heartRate)", systemImage: "heart.fill")
}
DynamicIslandExpandedRegion(.trailing) {
Text(context.state.elapsedSeconds.formattedDuration)
}
DynamicIslandExpandedRegion(.bottom) {
Text("\(context.state.caloriesBurned) kcal")
}
} compactLeading: {
Image(systemName: "heart.fill")
} compactTrailing: {
Text("\(context.state.heartRate)")
} minimal: {
Image(systemName: "heart.fill")
}
}
}
}One practical detail: updating ContentState many times a second is a bad idea. Live Activities throttle update frequency, and calls that come in too fast just get dropped or coalesced by the system. In MeteoHealth, heart rate in the Dynamic Island refreshes every few seconds locally (while the app or the Watch companion is active) — that's enough to keep the "live data" feeling without hammering the system.
App Intents and Siri: "Hey Siri, log a glass of water"#
The same AppIntent protocol that powers widget buttons is also the foundation for voice commands, Shortcuts, and Spotlight suggestions. The difference is the AppShortcutsProvider wrapper, which tells the system which natural-language phrases should trigger the intent.
struct LogWaterGlassIntent: AppIntent {
static var title: LocalizedStringResource = "Log a Glass of Water"
static var openAppWhenRun = false
func perform() async throws -> some IntentResult & ProvidesDialog {
try await HydrationStore.shared.addWater(milliliters: 250)
return .result(dialog: "Logged a glass of water")
}
}
struct MeteoHealthShortcuts: AppShortcutsProvider {
static var appShortcuts: [AppShortcut] {
AppShortcut(
intent: LogWaterGlassIntent(),
phrases: [
"Log a glass of water in \(.applicationName)",
"Add water in \(.applicationName)"
],
shortTitle: "Log Water",
systemImageName: "drop.fill"
)
}
}The openAppWhenRun = false flag matters a lot here: without it, Siri opens the app first and only then runs the action — the exact extra step we're trying to remove. With false, the whole command executes on the fly: the user says the phrase, hears confirmation through ProvidesDialog, and the app never even appears on screen.
iOS 18 Controls: fast actions in Control Center#
A third channel arrived with iOS 18: Controls, living in Control Center, on the Lock Screen, and assignable to the Action Button. Technically it's another layer on top of WidgetKit, but with a different presentation template — ControlWidgetButton or a toggle — and much stricter expectations around instant response.
struct HydrationControl: ControlWidget {
var body: some ControlWidgetConfiguration {
StaticControlConfiguration(
kind: "com.meteohealth.hydration-control"
) {
ControlWidgetButton(action: LogWaterIntent(amountML: 250)) {
Label("Log Water", systemImage: "drop.fill")
}
}
.displayName("Log Water")
.description("Quickly add a glass of water from Control Center")
}
}Notice it reuses the same LogWaterIntent as the widget. That's not a coincidence — it's a deliberate choice. One AppIntent can be reused across all three surfaces (widget, Siri command, control) without duplicating business logic. That's how App Intents turn from "a Siri feature" into a single, unified action layer for the app.
The Watch app and complications: the same intent language on the wrist#
MeteoHealth ships a full Apple Watch app, not just a mirror of the phone screen — with complications for different watch faces showing the current wellbeing risk forecast or heart rate. watchOS complications use the same WidgetKit-plus-timeline-provider combination as iPhone widgets, so most of the timeline code is written once and works on both platforms with only minor layout differences for the watch face form factors.
Quick logging from the wrist — marking a symptom or water intake right after a workout — goes through the same AppIntent structs used on iPhone. The SwiftUI code is platform-specific, but the business logic and the intent definitions are shared. That noticeably shrank the surface for bugs: if HydrationStore.shared.addWater works correctly once, it works correctly everywhere it's called.
Widget, Live Activity, or Control: which one to pick#
These three mechanisms cover different scenarios, and confusing them is a common reason a feature never really lands with users.
| Criterion | Widget (WidgetKit) | Live Activity (ActivityKit) | Control (iOS 18 Controls) |
|---|---|---|---|
| When to use | A persistent summary that changes slowly | A time-bound active event (workout, timer, delivery) | A single fast action, one tap |
| Where it appears | Home Screen, Lock Screen, StandBy | Lock Screen, Dynamic Island | Control Center, Lock Screen, Action Button |
| Lifespan | Hours to days, refreshed on a schedule | Limited — the activity disappears after a long stretch without updates | Persistent, state fetched on demand |
| Data updates | Timeline provider + WidgetCenter.reloadTimelines | Push via ActivityKit or local ContentState updates | An AppIntent run on tap |
| Interactivity | Buttons and toggles via AppIntent (iOS 17+) | Mostly display, minimal interaction | Full — buttons and toggles are the whole point |
The rule of thumb I use: if the data needs to be seen, it's a widget; if you need to follow a process while it's happening, it's a Live Activity; if you need to do one thing as fast as possible, it's a control.
What I took away from this project#
The main lesson from MeteoHealth is that all three mechanisms only make sense when they sit on top of a shared, already-existing layer of business logic. I didn't write a separate "log water" implementation for the widget, another for Siri, and another for the control — they all use the same AppIntent, and all the storage logic lives in a shared App Group accessible to the app, the widget, and the Watch complication alike.
The second lesson is state synchronization. WidgetKit and ActivityKit don't find out about changes on their own: you have to explicitly call WidgetCenter.shared.reloadTimelines and update ContentState — and that's exactly where the "I tapped it, but nothing changed on screen" bugs come from most often.
And the third: the ecosystem around an app only works where the user's action is genuinely short — a glass of water, a glance at a forecast, starting a workout. The moment the logic inside perform() needs complex UI or multiple steps, that's the signal the feature should stay inside the app rather than move to a widget or a voice command.



