Las Live Activities son una máquina de estados, no una insignia
Género: shipped-code. La publicación documenta la Live Activity que construí en Return, el temporizador de meditación SwiftUI que usa mi esposa, mi mamá y unos cuantos miles de extraños.1 Los patrones son los que sobrevivieron a producción. El pie de honestidad brutal dice lo que aún no sé.
La Live Activity en Return parece un número de cuenta regresiva en la pantalla de bloqueo y en la Dynamic Island.2 No es un número. Es una máquina de cinco estados de ciclo de vida con tres rutas externas de descarte y una ruta de inicio reentrante que tiene que defenderse de sí misma.
Lancé una v1 que trataba la Live Activity como una insignia. El “tiempo restante actual” era datos; el resto era decoración. Esa versión tenía tres bugs que detecté en TestFlight y uno que detecté en producción:
- Tocar iniciar mientras el inicio ya estaba en curso creaba una segunda actividad que dejaba huérfana a la primera.
- La cuenta regresiva se renderizaba correctamente en la Dynamic Island, pero la vista de la pantalla de bloqueo verificaba
endTime <= Date()para temporizadores en pausa y mostraba0:00hasta que el usuario reanudaba. - La Live Activity permanecía visible mucho después de que el usuario reiniciara el temporizador porque la política de descarte era
.default, que Apple mantiene visible durante un tiempo de hasta cuatro horas. - (Producción.) En configuraciones regionales de idiomas de derecha a izquierda (árabe, hebreo), los dígitos se renderizaban al revés en la región compact-trailing de la Dynamic Island. Dígitos latinos, layout RTL. La corrección fue una sola línea.
Cada uno de esos era un bug de máquina de estados. El número de la cuenta regresiva estaba bien. El número no es el producto. El producto es el estado.
La máquina de estados a continuación es la que sobrevivió a esos bugs.
TL;DR
- El
LiveActivityManagerque se lanza expone 5 métodos de transición (startActivity,updateActivity,showCycleComplete,showFinalCompletion,endActivity) más 1 lectura (hasActiveActivity). Las 224 líneas de producción protegen contra un peligro específico dentro destartActivity: llamadas concurrentes de inicio más comprobaciones de cancelación a través de cada límiteawaiten ese método.3 - El
ContentStatelleva 6 campos:endTime,currentCycle,totalCycles,isPaused,isCompleted,remainingSeconds. Los primeros cinco son las etiquetas de la máquina de estados. El sexto (remainingSeconds) es un fallback de visualización estática que eltimerIntervalen vivo de ActivityKit no puede atender. - La decisión de la política de descarte es la verdadera decisión de producto.
.immediatepara reinicio del usuario,.after(Date().addingTimeInterval(3))para finalización, nunca el predeterminado del sistema. - La región compact-trailing de la Dynamic Island necesita
.environment(\.layoutDirection, .leftToRight)en el texto del temporizador para mantener los dígitos latinos en LTR bajo configuraciones regionales del sistema en RTL.
La máquina de estados
La Live Activity lanzada tiene un estado idle, tres estados en vivo que el usuario puede observar, un estado terminal y una puerta reentrante que el desarrollador debe observar:
┌──────────────────────────────────────────────────────────────────┐
│ Lifecycle states │
├──────────────────────────────────────────────────────────────────┤
│ IDLE currentActivity == nil; no Live Activity present │
│ RUNNING isPaused=false, endTime > Date() │
│ PAUSED isPaused=true, remainingSeconds=N │
│ CYCLE_END isPaused=false, endTime <= Date(), isCompleted=false│
│ COMPLETE isCompleted=true (terminal; transitions to IDLE) │
└──────────────────────────────────────────────────────────────────┘
│
↓
┌──────────────────────────────────────────────────────────────────┐
│ Dismissal policies (Apple) │
├──────────────────────────────────────────────────────────────────┤
│ .immediate user reset │
│ .after(now + 3s) completion display window │
│ .default system decides; can stay up to 4 hours │
└──────────────────────────────────────────────────────────────────┘
Reentrancy gate inside startActivity():
isStartingActivity flag + cancellable startActivityTask
prevents two concurrent startActivity() calls from creating
two Live Activities for one timer. Cancellation checks across
each await keep the in-flight task safe to abort.
La ruta de renderizado verifica primero isPaused; ese ordenamiento es lo que evita que un temporizador en pausa se renderice como CYCLE_END cuando el tiempo de reloj ha cruzado endTime.7
Los nombres de los estados no son etiquetas en el número. Los nombres de los estados son el contrato entre LiveActivityManager (el lado de la app, donde viven mis vistas SwiftUI) y ReturnLiveActivity (la extensión de widget, donde el proceso de Apple renderiza la superficie).
El contrato es TimerActivityAttributes.ContentState, los 6 campos:3
public struct ContentState: Codable, Hashable {
var endTime: Date
var currentCycle: Int
var totalCycles: Int?
var isPaused: Bool
var isCompleted: Bool = false
var remainingSeconds: Int = 0
}
Cada transición de estado muta esta estructura y le pide a ActivityKit que la entregue a través de los límites del proceso a la extensión del widget. El widget entonces se vuelve a renderizar. No hay memoria compartida. No hay callback. Hay una estructura Codable que cruza un límite de proceso en cada transición.
Ese hecho descarta cualquier cosa que pudiera querer hacer con closures, view models, observable objects o computed properties. El estado tiene que poder expresarse como datos serializables. Si no puede codificarse, no puede transitar.
El inicio reentrante
Las Live Activities tienen un límite estricto en actividades concurrentes y un límite blando en lo que sucede si llamas a Activity.request dos veces en curso. El límite estricto está bien documentado.4 El límite blando es “la segunda llamada puede tener éxito y crear una huérfana”. La huérfana es la Live Activity que ya no está asociada con currentActivity en tu manager. Sobrevive. No tiene una ruta de regreso a tu código. Eventualmente se descarta por su propio temporizador de obsolescencia. El usuario ve un temporizador duplicado hasta entonces.
La huérfana fue el bug de la v1 que Return lanzó. La corrección es la puerta reentrante más una Task cancelable en LiveActivityManager.swift:3
private var isStartingActivity = false
private var startActivityTask: Task<Void, Never>?
func startActivity(...) {
#if os(iOS)
guard ActivityAuthorizationInfo().areActivitiesEnabled else { return }
guard !isStartingActivity else { return }
isStartingActivity = true
startActivityTask?.cancel()
startActivityTask = Task {
defer {
isStartingActivity = false
startActivityTask = nil
}
guard !Task.isCancelled else { return }
await endActivity() // explicit cleanup of any prior state
guard !Task.isCancelled else { return }
// ... build attributes + contentState ...
do {
let activity = try Activity.request(...)
guard !Task.isCancelled else { return }
currentActivity = activity
} catch {
// log; flag clears via defer
}
}
#endif
}
Tres cosas sobre ese patrón que la documentación no menciona:
El flag isStartingActivity es la protección activa; startActivityTask?.cancel() es limpieza defensiva. El flag corta cualquier segunda llamada a startActivity mientras la primera está en curso, así que en realidad no compites por la ruta pública. La danza de cancelar-y-reemplazar todavía importa porque la Task en curso es asíncrona y puede sobrevivir a un caller de corta duración; la cancelación evita que una Task obsoleta continúe después de que el caller haya seguido adelante.
Las verificaciones guard !Task.isCancelled a través de cada límite await. La cancelación es cooperativa en Swift. Incluso si se llama a cancel, la Task sigue ejecutándose hasta que verifica explícitamente. Cada await es una oportunidad para verificar. Sin las verificaciones post-await, una Task cancelada sigue construyendo el estado de la actividad, llama a Activity.request y crea silenciosamente una huérfana en caso de éxito.
El defer limpia el flag antes de que el cuerpo de la Task termine. Sin defer, un return temprano (de la verificación de cancelación) deja isStartingActivity = true permanentemente y la actividad nunca vuelve a iniciarse hasta que se relanza la app. El flag es un lock; el lock tiene que liberarse en cada ruta de salida.
El argumento pushType: nil. Return no usa actualizaciones de Live Activity vía APNs. La app actualiza la actividad localmente vía activity.update. Si necesitas actualizaciones impulsadas por push (seguimiento de entregas, marcadores deportivos, datos en tiempo real), el tipo es pushType: .token y el contrato es dramáticamente más complejo.5 Las actualizaciones locales son más simples y cubren cualquier flujo de temporizador / contador / app única.
El problema de la pausa
ActivityKit incluye una hermosa vista Text(timerInterval: Date()...endTime, countsDown: true) que renderiza una cuenta regresiva en vivo sin ninguna actualización de la app.6 Estableces el tiempo de finalización, el sistema renderiza un temporizador en vivo. Sin Timer.publish, sin refresco del widget, sin consumo de batería.
Eso es fantástico cuando el temporizador está corriendo. Es incorrecto cuando el temporizador está en pausa.
El texto timerInterval cuenta hacia endTime independientemente de cualquier señal de “pausa” en el estado. No hay un modo “congelado en 10:23” en el API de Apple. Si pasas endTime = Date().addingTimeInterval(623) y el usuario hace pausa en la marca de 10:23, el texto del temporizador sigue contando hacia cero en el widget. El campo de estado dice paused. El widget renderiza running.
La corrección es renderizar dos vistas diferentes desde el mismo estado:7
if context.state.isPaused {
// static text
Text(formatTime(context.state.remainingSeconds))
.monospacedDigit()
} else if context.state.endTime > Date() {
// live countdown
Text(timerInterval: Date()...context.state.endTime, countsDown: true)
.monospacedDigit()
} else {
// post-end static
Text("0:00")
.monospacedDigit()
}
El renderizado de dos pistas es por lo que el ContentState lleva remainingSeconds como un campo separado. Es redundante cuando el temporizador está corriendo (el sistema lo calcula desde endTime). Es la única fuente de verdad cuando el temporizador está en pausa. Las dos mitades de la estructura sirven a dos modos de renderizado diferentes; el booleano isPaused selecciona entre ellas.
Las políticas de descarte
activity.end(_:dismissalPolicy:) toma uno de tres valores ActivityUIDismissalPolicy, y elegir mal es lo que hizo que mi v1 permaneciera en la pantalla de bloqueo del usuario durante lo que se sintió como una eternidad después de un reinicio:13
| Política | Cuándo usarla | Qué obtienes |
|---|---|---|
.immediate |
Reinicio del usuario, error, app en segundo plano sin actividad que rastrear | La actividad desaparece ahora. Sin ventana de gracia |
.after(date) |
Visualización de finalización: “tu meditación está completa” necesita ser legible por un momento. La fecha debe estar dentro de la ventana de cuatro horas que Apple permite | La actividad muestra el estado final, luego se descarta en date |
.default |
Cuando genuinamente quieres que la heurística de Apple decida | El sistema la mantiene visible “por algún tiempo” (palabras de Apple), hasta cuatro horas después de que se llama a end |
Return usa .after(Date().addingTimeInterval(3)) para la ruta de finalización natural:3
await activity.end(
.init(state: contentState, staleDate: nil),
dismissalPolicy: .after(Date().addingTimeInterval(3))
)
Tres segundos es el tiempo que un usuario necesita para mirar la pantalla de bloqueo, registrar que el temporizador terminó y sentir la satisfacción de la marca de verificación. Menos de tres es brusco. Más de tres se siente como si la actividad no supiera que terminó.
Para un reinicio activado por el usuario, la llamada es dismissalPolicy: .immediate. Sin ventana. El usuario ya lo sabe.
La elección incorrecta en v1 fue .default. Para un temporizador de meditación completado, el sistema mantenía la actividad visible el tiempo suficiente para que los usuarios pensaran que la app no había registrado la finalización en absoluto. La documentación de Apple dice que .default mantiene la actividad finalizada “visible por algún tiempo” hasta cuatro horas;13 la postura correcta para un temporizador es hacer el descarte explícito.
La región compacta de la Dynamic Island
La Dynamic Island tiene tres modos de renderizado y necesitas los tres incluso para un temporizador simple:2
- Compacto (forma predeterminada de la Dynamic Island): icono leading + temporizador trailing
- Mínimo (cuando otra Live Activity compite por la misma Dynamic Island): solo icono leading
- Expandido (long-press): cuatro regiones nombradas (
leading,trailing,center,bottom)
El patrón que se ganó su lugar en Return es hacer que la vista expandida sea casi idéntica a la compacta:8
DynamicIsland {
DynamicIslandExpandedRegion(.leading) {
Image("AppIconSmall")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 16, height: 16)
.clipShape(RoundedRectangle(cornerRadius: 4))
}
DynamicIslandExpandedRegion(.trailing) {
TimerText(...)
}
DynamicIslandExpandedRegion(.center) { EmptyView() }
DynamicIslandExpandedRegion(.bottom) { EmptyView() }
} compactLeading: {
Image("AppIconSmall")...
} compactTrailing: {
TimerText(...)
} minimal: {
Image("AppIconSmall")...
}
La mayoría de los tutoriales de Live Activity se inclinan hacia la vista expandida como el diseño “real”, con contenido rico en la región bottom. Para un temporizador de meditación, la expansión es peso muerto. El usuario abre la vista expandida con un long-press, y el long-press ya le da la retroalimentación háptica de que algo sucedió. Agregar contenido hace que la expansión diga algo que el usuario no pidió. Las regiones vacías en modo expandido no son un fallo del diseño; son el diseño.
El bug de RTL
El bug de producción. Usuarios de árabe y hebreo en iOS reportaron que el temporizador compact-trailing de la Dynamic Island renderizaba los dígitos al revés. La cadena numérica latina 5:23 se renderizaba como 32:5 porque la dirección del layout compact-trailing heredaba la configuración RTL de la configuración regional del sistema.
SwiftUI hereda la dirección del layout del sistema dentro del proceso del widget, así que el texto del temporizador de la Dynamic Island captaba RTL cuando el teléfono del usuario estaba configurado en árabe o hebreo. Los numerales latinos deberían renderizarse en LTR incluso dentro de una UI que de otro modo es RTL. La corrección es fijar la dirección del layout en las vistas de texto numéricas:7
.environment(\.layoutDirection, .leftToRight)
La sobreescritura va en las vistas Text numéricas dentro de TimerText (Dynamic Island compacto / expandido) y dentro de la vista de la pantalla de bloqueo, no en toda la vista. Los dígitos latinos se leen de izquierda a derecha sin importar la configuración regional del sistema del usuario; las etiquetas de ciclo como “Cycle 2 of 3” permanecen localizadas para que sigan la dirección del layout del sistema.
El bug no aparece en TestFlight de configuración regional doméstica. Aparece en el momento en que un usuario RTL real abre el temporizador. La lección: lanza la sobreescritura de entorno fijada a LTR en cada vista de texto con dígitos latinos en cualquier Live Activity que pueda ejecutarse en configuraciones regionales RTL.
La historia de la localización
TimerActivityAttributes lleva un campo languageCode: String establecido por la app en la creación de la actividad:9
let attributes = TimerActivityAttributes(
timerDuration: duration,
languageCode: settings.appLanguage // app's selected language, not system's
)
La extensión del widget lo lee para renderizar cadenas localizadas:
private var locale: Locale {
let code = context.attributes.languageCode
return code.isEmpty ? .current : Locale(identifier: code)
}
private func localized(_ key: String.LocalizationValue) -> String {
String(localized: key, locale: locale)
}
Por qué la app pasa su propio código de idioma en lugar de dejar que el widget lea Locale.current: la extensión del widget se ejecuta en su propio proceso. Su Locale.current es la configuración regional del sistema, no la configuración regional seleccionada de la app. Si un usuario ha configurado Return en coreano mientras su iPhone está en inglés, el widget hablaría inglés sin esta sobreescritura. La preferencia de idioma de la app viaja en los atributos de la actividad; el widget la honra.
Localizable.xcstrings vive en el target del widget junto al de la app, pero son archivos separados. Las cadenas usadas en el widget tienen que existir en ReturnWidgets/Localizable.xcstrings incluso si la misma cadena existe en Return/Localizable.xcstrings. Olvidar esto significa que el widget recae en el idioma de desarrollo mientras la app habla coreano.
Lo que construiría diferente
Hacer ContentState más pequeño. Seis campos son demasiados. La redundancia entre endTime y remainingSeconds es el precio de sortear el modo sin pausa en timerInterval. Si empezara de nuevo, llevaría un solo enum displayMode (running, paused(remainingSeconds: Int), cycleEnd, complete) y dejaría que el código de renderizado despachara según el caso. Seis campos son más difíciles de mantener correctamente mutados a través de cinco métodos de transición que cuatro casos.
Agregar botones interactivos de Live Activity (iOS 17+). Return actualmente no expone controles de pausa/reanudación en la Dynamic Island. El usuario tiene que abrir la app para pausar. iOS 17 agregó Button(intent:) para App Intents dentro de Live Activities.10 Un control de pausa interactivo es la extensión obvia y lo siguiente que lanzaré para Return.
Live Activities con actualización por push para sincronización de temporizador entre dispositivos. Return sincroniza sesiones a través de iPhone, iPad, Watch y Apple TV vía NSUbiquitousKeyValueStore (cubierto en Cinco plataformas Apple, tres archivos compartidos). Hoy la actividad se inicia localmente desde la app de iPhone o iPad y se actualiza localmente. Un usuario que inicia un temporizador en Apple Watch idealmente podría ver la Live Activity reflejarlo en el iPhone en tiempo real. Push de APNs a la Live Activity es la ruta.5 No lo he construido.
Cuándo no usar Live Activities
Estado transitorio de una sola vez. Un toast “¡guardado!” no merece una Live Activity. El sistema tiene un banner. Úsalo.
Datos que cambian frecuentemente sin una dimensión de temporizador. Las Live Activities funcionan mejor para cosas con un anclaje temporal claro (un temporizador, un ETA de entrega, un reloj de juego, una duración de llamada telefónica). Los tickers de bolsa y los marcadores deportivos funcionan porque tienen una ventana de sesión. Un dashboard de propósito general no.
Apps sin un caso de uso de pantalla de bloqueo / standby. Las Live Activities requieren una inversión real de ingeniería (configuración del target, diseño de ContentState, decisiones de política de descarte, manejo de RTL, plomería de localización). Las apps que el usuario abre directamente sin consultar nunca la pantalla de bloqueo durante el uso no son la forma correcta. Un editor de fotos no necesita una. Un rastreador de entrenamiento sí.
En superficies que no son iOS, con salvedades. El LiveActivityManager de Return entrega su implementación detrás de #if os(iOS) porque el temporizador se inicia desde la app de iPhone o iPad. ActivityKit en sí describe el banner de la pantalla de bloqueo, la Dynamic Island, el Smart Stack de Apple Watch, Mac y CarPlay como superficies de presentación; iOS 26 expandió varias de esas.4 watchOS todavía tiene sus propias complications API para renderizado en pantalla completa. macOS tiene apps de barra de menú. iPadOS soporta Live Activities desde iPadOS 17 sin región de Dynamic Island. El manager de Return tiene 8 guards #if os(iOS) en un archivo de 224 líneas.
Lo que el patrón significa para apps que se lanzan en iOS 26+
Dos conclusiones.
-
Trata la Live Activity como una máquina de estados, no un número. La máquina de estados tiene estados claros, transiciones claras y reglas claras de descarte. El número en la pantalla es un renderizado de un estado. Acierta los estados primero.
-
El guard de reentrancia es el bug que aún no has tenido. Cada manager de Live Activity que he visto en producción que no implementa
isStartingActivity+ Task cancelable ha lanzado al menos un bug de actividad huérfana. El guard son 6 líneas. Escríbelo una vez.
Combina esta publicación con mis escritos previos para la misma familia de apps: App Intents tipados para Apple Intelligence; servidores MCP para agentes cross-LLM; patrones de Liquid Glass para la capa visual; lanzamiento multiplataforma para alcance entre dispositivos. Las Live Activities son la capa de la pantalla de bloqueo de iOS y la Dynamic Island del mismo stack. El conjunto completo vive en el hub de la Serie Ecosistema Apple. Para el contexto más amplio de iOS con agentes de IA, consulta la guía de Desarrollo de Agentes iOS.
Preguntas frecuentes
¿Cuál es la diferencia entre Live Activities y widgets de WidgetKit?
Los widgets de WidgetKit se renderizan a intervalos definidos por TimelineProvider; el sistema decide cuándo refrescar y el widget se vuelve a renderizar desde una línea de tiempo estática.11 Las Live Activities se renderizan en respuesta a llamadas específicas activity.update(...) impulsadas por la app y viven durante la duración de la actividad subyacente (un temporizador, una entrega, un entrenamiento). Ambos se entregan en el target de extensión de widget; la diferencia es el modelo de disparador.
¿Las Live Activities funcionan en iPad?
Sí, en iPadOS 17+. El banner de la pantalla de bloqueo es la superficie de renderizado principal; iPad no tiene una Dynamic Island. El mismo código de ActivityConfiguration funciona; solo espera que las regiones de la Dynamic Island nunca se rendericen en iPad.
¿Puede una Live Activity sobrevivir a mi proceso de app?
Sí. Una vez que Activity.request tiene éxito, ActivityKit es dueño de la actividad. El proceso de la app puede ser terminado por el sistema; la actividad continúa renderizándose en la pantalla de bloqueo y la Dynamic Island hasta que la termines explícitamente (o hasta que las reglas de obsolescencia del sistema la descarten). Las llamadas explícitas a endActivity() importan por esa razón; sin un fin explícito al reiniciar la app, la actividad sobrevive al temporizador.
¿Por qué la publicación no cubre las Live Activities con actualización por push?
No he lanzado Live Activities con actualización por push en Return. Según la regla del género para este cluster: las publicaciones shipped-code solo documentan lo que hace el código de producción. Las actualizaciones por push están listadas en “Lo que construiría diferente”; una futura publicación las cubrirá después de que las lance.
¿Cuál es el diseño real de archivos para Live Activities en una app SwiftUI?
- En el target principal de la app:
LiveActivityManager.swift(gestiona el ciclo de vida de la actividad),TimerActivityAttributes.swift(la estructuraActivityAttributescompartida con el widget; ambos targets compilan este archivo). - En un target de extensión de widget:
ReturnLiveActivity.swift(la conformidadWidgetcon cuerpoActivityConfiguration),ReturnWidgetsBundle.swift(el@main WidgetBundle). - Configuración:
Info.plistconNSSupportsLiveActivities = YESen el target de la app.
El target de extensión del widget necesita imports de ActivityKit y WidgetKit. TimerActivityAttributes es el único archivo compartido entre ambos targets; todo lo demás está aislado al target.
La Live Activity no es un número en la pantalla de bloqueo. Es una máquina de estados que cruza un límite de proceso en cada transición. Acierta los estados, protege la reentrancia, elige la política de descarte a propósito y fija la dirección del layout. El número se cuida solo.
Referencias
-
Return del autor, un temporizador de meditación SwiftUI publicado en la App Store el 21 de abril de 2026, disponible para iPhone, iPad, Mac, Apple Watch y Apple TV. Las Live Activities se entregan solo en el target de iOS. ↩
-
Apple Developer, “ActivityKit framework”. Banner de pantalla de bloqueo, modos compacto / mínimo / expandido de la Dynamic Island, ciclo de vida de la actividad. Disponible iOS 16.1+; Dynamic Island disponible en iPhone 14 Pro y posteriores. ↩↩
-
Código de producción en
Return/Return/LiveActivityManager.swift(224 líneas, 8 bloques#if os(iOS)) yReturn/Return/TimerActivityAttributes.swift(43 líneas). Compartido entre el target de la app y el target de extensión del widget vía membresía de target. ↩↩↩↩↩ -
Apple Developer, “Displaying live data with Live Activities”. Límites de concurrencia, plataformas soportadas (iOS 16.1+, iPadOS 17+), clave
NSSupportsLiveActivitiesde Info.plist. ↩↩ -
Apple Developer, “Updating and ending your Live Activity with ActivityKit push notifications”. La ruta
pushType: .tokenrequiere una clave de autenticación APNs separada, registro de tokens push del lado del servidor y un protocolo de actualización diferente a las llamadas localesactivity.update(...). ↩↩ -
Apple Developer, “Text(timerInterval:pauseTime:countsDown:showsHours:)”. Temporizador de cuenta regresiva renderizado por el sistema en vivo; se renderiza sin actualizaciones de la app mientras la actividad está corriendo. ↩
-
Código de producción en
Return/ReturnWidgets/ReturnLiveActivity.swift(232 líneas). La conformidadWidgetde la extensión del widget con cuerpoActivityConfiguration<TimerActivityAttributes>. La vistaTimerTexten las líneas 61-102 maneja el renderizado de tres estados pausado / corriendo / post-finalización. ↩↩↩↩ -
Apple Developer, “DynamicIsland”. Las cuatro regiones expandidas nombradas (
leading,trailing,center,bottom) más tres vistas en modo compacto (compactLeading,compactTrailing,minimal). ↩ -
La extensión del widget se ejecuta en su propio proceso y hereda la configuración regional del sistema, no la configuración regional seleccionada de la app. Las apps que soportan cambio de idioma dentro de la app (Return soporta 27 idiomas) deben pasar el código de idioma a través de
ActivityAttributespara que el widget pueda renderizar en el idioma elegido por el usuario. Patrón:Locale(identifier: context.attributes.languageCode)en lugar deLocale.current. ↩ -
Apple Developer, “Button(intent:)”. Disponible en vistas de widget y Live Activity desde iOS 17+. Conecta App Intents en controles de pantalla de bloqueo / Dynamic Island sin requerir que la app esté en primer plano. ↩
-
Apple Developer, “TimelineProvider”. El modelo de refresco de widget que precede a las Live Activities; entradas precalculadas con ventanas de recarga gestionadas por el sistema. ↩
-
Código de producción en
Return/ReturnWidgets/ReturnWidgetsBundle.swift(16 líneas). El@main WidgetBundleque registraReturnLiveActivitycomo el único widget de la extensión del widget. Patrón requerido para extensiones de widget; el bundle es lo que el sistema carga. ↩ -
Apple Developer, “ActivityUIDismissalPolicy”. Tres casos:
.default,.immediate,.after(_:). Apple establece que.defaultmantiene una Live Activity finalizada visible “por algún tiempo” hasta cuatro horas, y.after(_:)acepta una fecha dentro de la misma ventana de cuatro horas. ↩↩