Widgets, Live Activities y App Intents en MeteoHealth#
Durante años, "una buena app" significaba una buena pantalla dentro de la app. Abrirla, mirar, tocar, cerrarla. Desde iOS 16 hasta iOS 18, esa definición dejó de ser suficiente: cada vez más, el usuario toca la lógica de tu app sin llegar a abrirla — desde la pantalla de inicio, desde la Dynamic Island, por voz a través de Siri, o con un solo toque en el Centro de Control.
Mientras construía MeteoHealth — una app que conecta el clima, el sueño, el pulso y cómo te sientes realmente — quedó claro que las acciones más frecuentes del usuario ("anotar un vaso de agua", "ver mi pronóstico de recuperación", "comprobar cómo va el entrenamiento") no merecían un lanzamiento completo de la app. Así fue como en el producto terminaron apareciendo widgets interactivos, Live Activities en la Dynamic Island, comandos de Siri construidos sobre App Intents, y una app completa de Apple Watch con complicaciones. Este artículo recorre cómo está construido a nivel de código y arquitectura, y qué decisiones realmente valieron la pena.
Por qué esto no es solo una casilla marcada#
Hace tres años, un widget era una imagen estática que se actualizaba cada 15–30 minutos mediante un temporizador. Desde iOS 17, los widgets ganaron botones e interruptores que ejecutan código justo donde están, sin abrir la app — a través del framework App Intents. iOS 18 añadió un tercer canal encima de eso: los Controles (Controls), que viven en el Centro de Control, en la pantalla de bloqueo y pueden asignarse al botón de Acción.
Para una app como MeteoHealth esto no es decoración, sino una forma de eliminar la fricción entre "pensé en hacer X" y "X está hecho". Anotar agua, echar un vistazo a un pronóstico de riesgo a 24 horas, ver el pulso durante un entrenamiento: todo es más rápido si no requiere abrir la app.
Widgets interactivos: un AppIntent directo en la pantalla de inicio#
La idea central de los widgets interactivos es que un botón o un interruptor dentro de un widget no abre la app: ejecuta directamente una estructura que conforma al protocolo AppIntent. El sistema decide si arrancar tu proceso en segundo plano o reutilizar un contenedor de datos compartido; desde el punto de vista del código, simplemente describes lo que debe ocurrir.
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()
}
}Así se conecta ese mismo intent directamente en el layout de SwiftUI del widget — sin delegados, sin openURL, sin pantalla intermedia:
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()
}
}Un detalle práctico con el que tropecé en mi primera versión: si el almacenamiento de la app y el del widget no están sincronizados (MeteoHealth usa un App Group con Core Data, no una base de datos separada), el estado mostrado en el widget queda por detrás de la app después de tocar el botón. Llamar explícitamente a WidgetCenter.shared.reloadTimelines dentro de perform() no es opcional: sin eso, la interfaz del widget no tiene forma de saber que los datos cambiaron hasta la próxima actualización programada del timeline.
Live Activities y Dynamic Island: un entrenamiento siempre a la vista#
Las Live Activities resuelven un problema distinto — no "una acción rápida", sino "un estado que se actualiza continuamente para un evento con tiempo limitado". En MeteoHealth eso son los entrenamientos: mientras dura una carrera o una sesión de fuerza, la pantalla de bloqueo y la Dynamic Island siguen mostrando el pulso en vivo, la duración y las calorías, sin necesidad de desbloquear el teléfono.
Todo empieza describiendo el estado mediante ActivityAttributes:
import ActivityKit
struct WorkoutAttributes: ActivityAttributes {
struct ContentState: Codable, Hashable {
var heartRate: Int
var elapsedSeconds: Int
var caloriesBurned: Int
}
var workoutType: String
}Luego viene el arranque de la actividad desde la app principal — normalmente en el momento en que el usuario inicia un entrenamiento en el Apple Watch o en la propia app:
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)")
}
}Y, finalmente, el layout real de la Dynamic Island — con presentaciones separadas compacta, mínima y expandida:
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")
}
}
}
}Un detalle práctico: actualizar ContentState docenas de veces por segundo es mala idea. Las Live Activities limitan la frecuencia de actualización, y las llamadas demasiado frecuentes simplemente se descartan o se agrupan por el sistema. En MeteoHealth, el pulso en la Dynamic Island se actualiza cada pocos segundos de forma local (mientras la app o el compañero de Watch están activos) — suficiente para mantener la sensación de "datos en vivo" sin saturar el sistema.
App Intents y Siri: "Oye Siri, anota un vaso de agua"#
El mismo protocolo AppIntent que impulsa los botones de los widgets también es la base de los comandos de voz, los Atajos (Shortcuts) y las sugerencias de Spotlight. La diferencia está en el envoltorio AppShortcutsProvider, que le indica al sistema qué frases en lenguaje natural deben activar el 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"
)
}
}El flag openAppWhenRun = false es clave aquí: sin él, Siri abre primero la app y solo después ejecuta la acción — exactamente el paso extra del que intentamos deshacernos. Con false, todo el comando se ejecuta al vuelo: el usuario dice la frase, escucha la confirmación a través de ProvidesDialog, y la app ni siquiera llega a aparecer en pantalla.
Controles de iOS 18: acciones rápidas en el Centro de Control#
Un tercer canal llegó con iOS 18: los Controles, que viven en el Centro de Control, en la pantalla de bloqueo y pueden asignarse al botón de Acción. Técnicamente es otra capa sobre WidgetKit, pero con una plantilla de presentación distinta — ControlWidgetButton o un interruptor — y expectativas mucho más estrictas de respuesta instantánea.
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")
}
}Fíjate que reutiliza el mismo LogWaterIntent que el widget. Eso no es casualidad, es una decisión deliberada: un solo AppIntent puede reutilizarse en las tres superficies (widget, comando de Siri, control) sin duplicar la lógica de negocio. Así es como los App Intents dejan de ser "una función de Siri" para convertirse en una única capa de acciones de la app.
La app de Watch y las complicaciones: el mismo lenguaje de intents en la muñeca#
MeteoHealth incluye una app completa para Apple Watch, no solo un espejo de la pantalla del teléfono — con complicaciones para distintas esferas que muestran el pronóstico de riesgo para el bienestar actual o el pulso. Las complicaciones en watchOS usan la misma combinación de WidgetKit y proveedor de timeline que los widgets de iPhone, así que la mayor parte del código del timeline se escribe una sola vez y funciona en ambas plataformas con solo pequeñas diferencias de layout según el formato de la esfera.
El registro rápido de datos desde la muñeca — anotar un síntoma o el agua justo después de un entrenamiento — pasa por los mismos AppIntent que se usan en el iPhone. El código de SwiftUI es específico de cada plataforma, pero la lógica de negocio y las definiciones de los intents son compartidas. Esto redujo notablemente la superficie de errores: si HydrationStore.shared.addWater funciona correctamente una vez, funciona correctamente en todos los lugares donde se llama.
Qué elegir: widget, Live Activity o control#
Estos tres mecanismos cubren escenarios distintos, y confundirlos es una razón habitual por la que una función nunca termina de calar entre los usuarios.
| Criterio | Widget (WidgetKit) | Live Activity (ActivityKit) | Control (iOS 18 Controls) |
|---|---|---|---|
| Cuándo usarlo | Un resumen persistente que cambia lentamente | Un evento activo con tiempo limitado (entrenamiento, temporizador, entrega) | Una sola acción rápida, un toque |
| Dónde aparece | Pantalla de inicio, pantalla de bloqueo, StandBy | Pantalla de bloqueo, Dynamic Island | Centro de Control, pantalla de bloqueo, botón de Acción |
| Tiempo de vida | Horas o días, se refresca según un calendario | Limitado — la actividad desaparece tras un tramo largo sin actualizaciones | Persistente, el estado se obtiene bajo demanda |
| Actualización de datos | Proveedor de timeline + WidgetCenter.reloadTimelines | Push vía ActivityKit o actualizaciones locales de ContentState | Un AppIntent ejecutado al tocar |
| Interactividad | Botones e interruptores vía AppIntent (iOS 17+) | Sobre todo visualización, interacción mínima | Total — botones e interruptores son el objetivo principal |
La regla práctica que uso: si los datos necesitan verse, es un widget; si necesitas seguir un proceso mientras ocurre, es una Live Activity; si necesitas hacer una sola cosa lo más rápido posible, es un control.
Lo que me llevo de este proyecto#
La principal lección de MeteoHealth es que estos tres mecanismos solo tienen sentido cuando se apoyan sobre una capa de lógica de negocio compartida y ya existente. No escribí una implementación separada de "anotar agua" para el widget, otra para Siri y otra para el control — todos usan el mismo AppIntent, y toda la lógica de almacenamiento vive en un App Group compartido, accesible tanto para la app como para el widget y la complicación del Watch.
La segunda lección es la sincronización del estado. WidgetKit y ActivityKit no se enteran de los cambios por sí solos: hay que llamar explícitamente a WidgetCenter.shared.reloadTimelines y actualizar ContentState — y ahí es exactamente donde nacen la mayoría de los errores del tipo "toqué el botón y en pantalla no cambió nada".
Y la tercera: el ecosistema alrededor de una app solo funciona donde la acción del usuario es realmente corta — un vaso de agua, un vistazo a un pronóstico, iniciar un entrenamiento. En el momento en que la lógica dentro de perform() empieza a necesitar una interfaz compleja o varios pasos, esa es la señal de que la función debe quedarse dentro de la app, en lugar de trasladarse a un widget o a un comando de voz.



