wandres.dev
WIDGETS Y APP INTENTS · fuera de la app

Live Activities y Dynamic Island: el evento en curso

Una actividad en vivo no es un widget más ni una notificación persistente: es la representación de un evento con principio y final que ocurre ahora mismo, con su propio ciclo de vida, sus cuatro presentaciones obligatorias y un canal de actualización remota que se paga con presupuesto. Esta lección recorre el modelo de atributos y estado, la anatomía completa de la isla dinámica, las tres vías de actualización con sus límites, y las reglas de diseño que separan una actividad útil de una que el sistema o la persona acaban silenciando.

⏱ 19 min

Todo lo aprendido sobre líneas temporales se suspende aquí. Una actividad en vivo no se archiva por adelantado ni se refresca según un presupuesto de recargas: se empuja, desde la app o desde un servidor, mientras dura un evento concreto. Ese cambio de modelo trae consigo una obligación que el sistema toma en serio: solo son legítimos los eventos con principio, transcurso y final observables por la persona —un pedido en camino, un partido en juego, un temporizador corriendo, un vuelo, un trayecto—. Lo que no tiene final no es una actividad; es un widget, o una notificación, o publicidad. Y cuando una actividad no representa nada en curso, el sistema la caduca, la persona la descarta y la app pierde el privilegio.

🎯 Al terminar esta lección sabrás
  • Modelar un evento con atributos estáticos y estado dinámico, y razonar sobre su ciclo de vida completo.
  • Construir las cuatro presentaciones obligatorias y distribuir la información según el espacio real de cada una.
  • Elegir entre actualización local, remota por token y notificación de alerta, con sus límites y su presupuesto.
  • Aplicar las reglas de caducidad, obsolescencia, privacidad y pantalla siempre activa que impone el sistema.

El modelo: lo que no cambia y lo que sí

Una actividad se declara con un tipo que separa dos naturalezas de dato. Los atributos son lo inmutable: se fijan al empezar y no se tocan nunca más. El estado del contenido, anidado dentro, es lo que se actualiza y por tanto lo único que viaja en cada refresco, local o remoto. Esa separación no es estética: determina el tamaño de la carga útil, que en las actualizaciones remotas está severamente acotado, y decide qué se puede cambiar sin terminar y volver a empezar.

struct PedidoAttributes: ActivityAttributes {
    struct ContentState: Codable, Hashable {
        var etapa: Etapa
        var llegadaPrevista: Date
        var repartidor: String?
    }
    let numeroPedido: String     // no cambia jamas
    let comercio: String
}

El ciclo de vida tiene cuatro momentos y cada uno impone condiciones. Se solicita desde la app, que debe estar en primer plano y tener el permiso concedido por la persona en los ajustes. Se actualiza tantas veces como haga falta, desde la app o desde el servidor. Se termina con una política de descarte que decide si desaparece al instante, tras un plazo o en una fecha concreta. Y caduca sola: el sistema da a una actividad varias horas de vida activa —del orden de ocho— y la mantiene visible en la pantalla de bloqueo un rato más antes de retirarla definitivamente.

let contenido = ActivityContent(
    state: PedidoAttributes.ContentState(etapa: .preparando,
                                         llegadaPrevista: .now.addingTimeInterval(1800),
                                         repartidor: nil),
    staleDate: .now.addingTimeInterval(600),
    relevanceScore: 100
)

let actividad = try Activity.request(
    attributes: PedidoAttributes(numeroPedido: "A-1187", comercio: "La Tahona"),
    content: contenido,
    pushType: .token
)

Los dos campos que acompañan al estado son los que casi nadie usa y los que más mejoran la calidad. La fecha de obsolescencia declara a partir de cuándo el dato deja de ser fiable, y permite dibujar honestamente un estado incierto en lugar de mostrar una hora de llegada que ya pasó. La puntuación de relevancia decide qué actividad gana la isla dinámica cuando hay varias vivas, algo perfectamente normal en un dispositivo con un temporizador, un partido y un pedido a la vez.

Cuatro presentaciones, no una

Una actividad se dibuja en cuatro sitios y los cuatro son obligatorios. La presentación de pantalla de bloqueo y banner es una vista completa, del ancho de la pantalla. La expandida de la isla dinámica se reparte en regiones —principal, secundaria, central y una zona inferior más generosa—. La compacta ocupa dos huecos minúsculos a izquierda y derecha del sensor. Y la mínima es un único elemento diminuto, el que aparece cuando hay dos actividades compitiendo.

DynamicIsland {
    DynamicIslandExpandedRegion(.leading) { Icono(etapa: estado.etapa) }
    DynamicIslandExpandedRegion(.trailing) { Text(estado.llegadaPrevista, style: .timer) }
    DynamicIslandExpandedRegion(.center) { Text(contexto.attributes.comercio) }
    DynamicIslandExpandedRegion(.bottom) { BarraDeProgreso(etapa: estado.etapa) }
} compactLeading: {
    Icono(etapa: estado.etapa)
} compactTrailing: {
    Text(estado.llegadaPrevista, style: .timer).frame(maxWidth: 44)
} minimal: {
    Icono(etapa: estado.etapa)
}
.widgetURL(URL(string: "pedidos://detalle/\(contexto.attributes.numeroPedido)"))

El error clásico consiste en diseñar la vista grande y encoger las demás. No funciona, porque las presentaciones pequeñas no admiten resúmenes sino un único dato: el que la persona quiere saber sin abrir nada. En un pedido es el tiempo restante; en un partido, el marcador; en un temporizador, la cuenta atrás. Todo lo demás sobra ahí y estorba. Y hay una restricción física que decide muchos diseños por ti: la zona compacta de la derecha es estrecha y un texto que crezca sin límite empuja el diseño entero, de modo que conviene acotarlo explícitamente y elegir formatos que no cambien de anchura al actualizarse.

⚠️
Lo que no cabe en una actividad

No hay desplazamiento, ni gestos personalizados, ni entrada de texto, ni vídeo, ni animaciones arbitrarias. Las imágenes deben venir del paquete o del contenedor compartido, nunca de la red. La interactividad se limita a botones e interruptores con intent, igual que en un widget, y a un enlace de apertura. Y la vista se dibuja con el mismo motor limitado que un widget: si tu diseño necesita algo más, el diseño está mal planteado, no la plataforma.

Actualizar: local, remota y con alerta

Hay tres vías y elegir mal es la causa más común de actividades que se quedan congeladas. La actualización local sirve mientras la app esté ejecutándose o tenga tiempo concedido en segundo plano; es cómoda para pruebas y para temporizadores propios, e insuficiente para todo lo que dependa de un servidor. La actualización remota usa un token específico de la actividad, distinto del token de notificaciones de la app, que se obtiene tras solicitarla y hay que enviar al servidor de inmediato. Y cualquiera de las dos puede llevar alerta, que hace vibrar el dispositivo y muestra un aviso: reservada a hitos reales, porque abusar de ella es la vía rápida a que la persona desactive las actividades de tu app.

// Local
await actividad.update(ActivityContent(state: nuevoEstado, staleDate: .now.addingTimeInterval(300)))

// Escuchar el token para el servidor
for await token in actividad.pushTokenUpdates {
    await Servidor.registrar(token: token.map { String(format: "%02x", $0) }.joined())
}

// Terminar
await actividad.end(ActivityContent(state: estadoFinal, staleDate: nil),
                    dismissalPolicy: .after(.now.addingTimeInterval(900)))

Las notificaciones que actualizan una actividad llevan un tipo de envío propio, prioridad diferenciada y una carga útil pequeña que contiene el estado nuevo y su marca de tiempo. La prioridad baja se entrega de forma oportunista y no despierta la pantalla; la alta se entrega de inmediato pero se contabiliza contra un presupuesto por actividad que el sistema vigila. La consecuencia de diseño es directa: se envían muchas actualizaciones de baja prioridad y muy pocas de alta, reservadas a los cambios que la persona notaría aunque no estuviera mirando.

flowchart TB
a[La app en primer plano solicita la actividad] --> b[El sistema entrega un token de push]
b --> c[El servidor guarda el token]
c --> d{Cambia el evento}
d -->|cambio menor| e[Push de prioridad baja]
d -->|hito real| f[Push con alerta]
e --> g[Se redibujan las cuatro presentaciones]
f --> g
g --> h{Termino el evento}
h -->|si| i[Fin con politica de descarte]
h -->|no| d
j[Se supera el limite de horas] --> i
🧩

Local

Inmediata y gratis mientras la app vive. Perfecta para temporizadores y para reflejar acciones que la persona acaba de hacer en pantalla.

📡

Remota por token

La única vía real para eventos de servidor. Exige registrar el token en cuanto llega y volver a registrarlo si el sistema lo renueva.

🔔

Con alerta

Interrumpe y suena. Solo para hitos: el repartidor ha llegado, el partido ha terminado, el vuelo cambia de puerta. Nunca para el progreso rutinario.

Las reglas de diseño que sí se aplican

La primera es que el tiempo se dibuja solo. Un texto con estilo de temporizador o un rango de fechas avanza cada segundo sin consumir ni una actualización, y por tanto una cuenta atrás jamás debería enviarse desde el servidor: se envía la fecha de fin una vez y el sistema hace el resto. Confundir esto multiplica por cien el tráfico y agota el presupuesto sin cambiar nada visible.

La segunda es que hay que admitir la incertidumbre. Cuando se supera la fecha de obsolescencia, el entorno de la vista lo indica y el diseño debe responder: atenuar la cifra, cambiar el texto por una llegada aproximada, mostrar la última actualización conocida. Una hora estimada que ya pasó y sigue mostrándose como si fuera cierta destruye la credibilidad de toda la superficie.

La tercera es la pantalla siempre activa. En los dispositivos que la tienen, la actividad permanece visible con la pantalla atenuada, y el sistema reduce el brillo y desatura el color; los diseños con fondos densos o texto de bajo contraste desaparecen ahí. Conviene además marcar como sensible lo que no deba leerse a un metro de distancia, porque una actividad en vivo es, por definición, contenido que se mira sin desbloquear.

La cuarta cierra el ciclo y es la más incumplida: terminar. Una actividad que sobrevive a su evento es basura visual y una invitación a que la persona revoque el permiso. Hay que llamar al final en el momento en que el evento acaba, elegir una política de descarte proporcionada —inmediata para lo trivial, con plazo corto para que dé tiempo a leer el resultado— y limpiar las actividades huérfanas al arrancar la app, porque un cierre inesperado del proceso puede dejarlas vivas.

Una actividad en vivo es un contrato sobre la atención de otra persona

Hay algo poco habitual en esta API y conviene nombrarlo, porque explica todas sus restricciones de golpe: es la única superficie de iOS donde una app ocupa el espacio más caro del dispositivo —la pantalla de bloqueo y el borde superior— de forma continuada y sin que nadie lo pida en ese instante. Todo lo demás compite por atención puntual; una actividad en vivo se instala en la periferia visual de la persona durante horas. Por eso el sistema no la trata como una función sino como una concesión, con caducidad, presupuesto, permiso revocable y un requisito semántico explícito: debe existir un evento real con final. Y por eso el criterio técnico y el ético coinciden aquí de forma poco frecuente en ingeniería. La pregunta que hay que hacerse antes de escribir una línea no es si la API lo permite, sino si la persona miraría el reloj para saber esto; si la respuesta es no, cualquier implementación, por buena que sea, es una intrusión. Las reglas que parecen arbitrarias —termina, declara obsolescencia, alerta solo en hitos, un dato por presentación compacta— son en realidad la formalización de esa pregunta, y quien las interioriza descubre que ha aprendido algo transferible a cada notificación, insignia y sonido que su producto emita: la atención ajena es un recurso finito que se pide prestado, y toda API que la consume acaba imponiendo, antes o después, un presupuesto.

📝
Lo esencial

Separa atributos inmutables de estado dinámico, porque solo el segundo viaja en cada actualización. Diseña las cuatro presentaciones desde la más pequeña, con un único dato en las compactas. Actualiza en local lo que dependa de la app y por token lo que dependa del servidor, reservando la alerta para hitos reales y usando prioridad baja para el resto. Deja que el tiempo se dibuje solo con estilos de temporizador, responde a la obsolescencia y a la pantalla atenuada, y termina la actividad en cuanto el evento acabe.

⚔️ Un evento con final de verdad
  1. Elige un evento de tu dominio con principio y final observables y escribe sus atributos y su estado; justifica por qué cada campo está en un lado y no en el otro.
  2. Diseña primero la presentación mínima y la compacta con un solo dato cada una, y solo después la expandida y la de pantalla de bloqueo.
  3. Sustituye cualquier cuenta atrás enviada por el servidor por una fecha de fin y un texto con estilo de temporizador; mide cuántas actualizaciones te ahorras.
  4. Registra el token de la actividad y envía una actualización remota de prioridad baja y otra con alerta; compara ambas con el dispositivo bloqueado y en pantalla siempre activa.
  5. Fuerza la caducidad superando la fecha de obsolescencia y diseña el estado incierto; después termina la actividad con dos políticas de descarte distintas y elige la proporcionada.