Desde el lanzamiento del iPhone 14 Pro, la Dynamic Island (Isla Dinámica) ha transformado la forma en que los usuarios interactúan con sus aplicaciones en segundo plano. Lo que comenzó como una solución inteligente para ocultar el hardware de la cámara frontal, se ha convertido en un lienzo interactivo vital para la experiencia de usuario (UX) en iOS.
Para los desarrolladores de SwiftUI, esto no es solo un cambio estético; es una oportunidad. Integrar tu app con la Dynamic Island mediante ActivityKit y las Live Activities permite mantener a tus usuarios informados en tiempo real sin obligarlos a desbloquear el teléfono o entrar en la aplicación.
En este tutorial paso a paso, exploraremos cómo implementar una Live Activity desde cero, diseñar las diferentes vistas de la Dynamic Island y gestionar el ciclo de vida de los datos.
1. Entendiendo el Ecosistema: ActivityKit y Live Activities
Antes de escribir una sola línea de código, es crucial entender la arquitectura. La Dynamic Island no funciona sola; es la cara visible de una Live Activity (Actividad en Vivo).
Una Live Activity es una notificación persistente e interactiva que vive en la pantalla de bloqueo y, en los modelos compatibles, en la Dynamic Island. A diferencia de un Widget tradicional que se actualiza periódicamente (timeline), una Live Activity recibe actualizaciones instantáneas, ya sea desde la propia app en primer plano o mediante notificaciones push remotas.
Los tres estados de la UI
Cuando desarrollas para la Dynamic Island, no estás diseñando una sola vista, sino varias adaptaciones dependiendo del contexto del sistema:
- Compact (Compacta): La vista estándar cuando la isla está en reposo. Se divide en Leading (Izquierda) y Trailing (Derecha).
- Expanded (Expandida): La vista que aparece cuando el usuario mantiene pulsada la isla. Aquí tienes mucho más espacio para mostrar detalles.
- Minimal (Mínima): Se muestra cuando hay múltiples apps usando la isla simultáneamente. Tu app se reduce a un pequeño círculo o icono.
2. Configuración del Proyecto y Prerrequisitos
Para seguir este tutorial, necesitarás:
- Xcode 14.1 o superior.
- Un dispositivo o simulador con iOS 16.1 o superior.
- Un iPhone con soporte para Dynamic Island (iPhone 14 Pro en adelante) para probar la experiencia completa.
Paso 1: Modificar el Info.plist
Las Live Activities requieren un permiso explícito en la configuración del proyecto.
- Ve a tu archivo
Info.plist. - Añade una nueva clave:
NSSupportsLiveActivities. - Establece su valor booleano en
YES(otrue).
Paso 2: Crear el Target de Widget
Las Live Activities viven dentro de una extensión de Widget, no en el código principal de tu app.
- En Xcode, ve a File > New > Target.
- Busca y selecciona Widget Extension.
- Asegúrate de marcar la casilla “Include Live Activity” (si está disponible) o simplemente crea el widget y lo configuraremos manualmente.
- Llámalo, por ejemplo,
DeliveryTrackerWidget.
3. Definiendo los Datos: ActivityAttributes
El corazón de ActivityKit son los ActivityAttributes. Esta estructura define qué datos son estáticos (no cambian durante la vida de la actividad) y cuáles son dinámicos (se actualizan en tiempo real).
Imaginemos que estamos creando una app de Reparto de Pizza.
Crea un archivo llamado PizzaDeliveryAttributes.swift y asegúrate de que pertenezca tanto al Target de tu app principal como al Target de tu Widget.
import ActivityKit
import Foundation
struct PizzaDeliveryAttributes: ActivityAttributes {
public typealias PizzaDeliveryStatus = ContentState
// Variables dinámicas (cambian con el tiempo)
public struct ContentState: Codable, Hashable {
var driverName: String
var estimatedDeliveryTime: Date
var deliveryStatus: String // Ej: "En el horno", "En camino"
}
// Variables estáticas (no cambian una vez iniciada la actividad)
var totalAmount: String
var orderNumber: String
var numberOfPizzas: Int
}Nota: Es vital que
ContentStateconforme aCodableyHashable, ya que ActivityKit serializa estos datos para pasarlos entre la app y el sistema operativo.
4. Diseñando la Interfaz de la Dynamic Island
Ahora entramos en el terreno de SwiftUI. Dentro de tu extensión de Widget, vamos a crear la configuración de la actividad. Aquí es donde definimos cómo se ve la isla en sus diferentes estados.
El código utiliza el modificador ActivityConfiguration. Observa cómo manejamos las diferentes regiones.
import WidgetKit
import SwiftUI
import ActivityKit
struct PizzaDeliveryWidget: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: PizzaDeliveryAttributes.self) { context in
// VISTA DE LA PANTALLA DE BLOQUEO
// Esta es la vista clásica que aparece en notificaciones/lock screen
VStack {
Text("Pedido #\(context.attributes.orderNumber)")
.font(.headline)
HStack {
Image(systemName: "box.truck.badge.clock")
Text(context.state.deliveryStatus)
}
}
.padding()
.activityBackgroundTint(Color.cyan)
.activitySystemActionForegroundColor(Color.black)
} dynamicIsland: { context in
// CONFIGURACIÓN DE LA DYNAMIC ISLAND
DynamicIsland {
// MARK: - Expanded UI
// Esta vista se muestra al mantener pulsada la isla
DynamicIslandExpandedRegion(.leading) {
Label("\(context.attributes.numberOfPizzas) Pizzas", systemImage: "bag")
.font(.caption2)
}
DynamicIslandExpandedRegion(.trailing) {
Label {
Text(timerInterval: Date()...context.state.estimatedDeliveryTime, countsDown: true)
.multilineTextAlignment(.trailing)
.frame(width: 50)
.monospacedDigit()
} icon: {
Image(systemName: "timer")
}
.font(.caption2)
}
DynamicIslandExpandedRegion(.center) {
Text("\(context.state.driverName) está en camino")
.lineLimit(1)
.font(.caption)
}
DynamicIslandExpandedRegion(.bottom) {
// Una barra de progreso o botón de acción
Link(destination: URL(string: "pizzapp://call-driver")!) {
Label("Llamar al repartidor", systemImage: "phone.fill")
.padding()
.background(Color.green.opacity(0.2))
.clipShape(Capsule())
}
}
} compactLeading: {
// MARK: - Compact Leading (Izquierda)
HStack {
Image(systemName: "cart.fill")
.foregroundColor(.orange)
Text("\(context.attributes.numberOfPizzas)")
}
.padding(.leading, 4)
} compactTrailing: {
// MARK: - Compact Trailing (Derecha)
Text(timerInterval: Date()...context.state.estimatedDeliveryTime, countsDown: true)
.multilineTextAlignment(.trailing)
.frame(width: 40)
.font(.caption2)
.monospacedDigit()
} minimal: {
// MARK: - Minimal
// Cuando hay dos apps en la isla, la tuya se ve así (generalmente un icono)
Image(systemName: "timer")
.foregroundColor(.orange)
}
}
}
}Desglose de las Regiones
- CompactLeading & CompactTrailing: Tienes muy poco espacio horizontal. Usa iconos SF Symbols y texto muy corto. El sistema automáticamente recorta el contenido si excede el ancho.
- Expanded Regions:
.leading,.trailing,.center,.bottom.- La región
.centerse coloca debajo de la cámara (el “notch” físico), mientras que.bottomes ideal para mostrar más contexto o botones interactivos (que funcionan mediante Deep Links, ya que los widgets no soportan botones normales de SwiftUI).
- Minimal: Esta es tu “bandera de supervivencia”. Si el usuario está usando Mapas y tu App de Pizza al mismo tiempo, el sistema decidirá quién se lleva la vista minimal. Asegúrate de que tu icono sea reconocible instantáneamente.
5. Gestionando el Ciclo de Vida desde la App
Una vez diseñada la UI, necesitamos “encender” la isla desde nuestra aplicación principal. Esto se hace mediante la clase Activity.
Debes importar ActivityKit en tu ViewModel o controlador.
Iniciar la Actividad
import ActivityKit
func startDelivery() {
// 1. Definir los datos estáticos
let attributes = PizzaDeliveryAttributes(
totalAmount: "$25.50",
orderNumber: "A-998",
numberOfPizzas: 2
)
// 2. Definir el estado inicial
let initialContentState = PizzaDeliveryAttributes.ContentState(
driverName: "Carlos",
estimatedDeliveryTime: Date().addingTimeInterval(30 * 60), // +30 mins
deliveryStatus: "Preparando ingredientes"
)
// 3. Solicitar la actividad
do {
let activity = try Activity<PizzaDeliveryAttributes>.request(
attributes: attributes,
content: .init(state: initialContentState, staleDate: nil),
pushType: nil // Usar 'token' si vas a usar Push Notifications
)
print("Actividad iniciada con ID: \(activity.id)")
} catch {
print("Error al iniciar la actividad: \(error.localizedDescription)")
}
}Actualizar la Actividad
Cuando el estado del pedido cambia (ej. “En el horno”), actualizamos la actividad. Esto refrescará la UI en la Dynamic Island inmediatamente.
func updateDeliveryStatus() {
let newStatus = PizzaDeliveryAttributes.ContentState(
driverName: "Carlos",
estimatedDeliveryTime: Date().addingTimeInterval(15 * 60),
deliveryStatus: "En el horno 🔥"
)
Task {
// Obtenemos la actividad actual (en una app real gestionarías el ID específico)
for activity in Activity<PizzaDeliveryAttributes>.activities {
await activity.update(using: newStatus)
}
}
}Finalizar la Actividad
Es fundamental limpiar la actividad cuando el proceso termina. No querrás que una pizza entregada siga ocupando espacio en la Dynamic Island del usuario.
func endDelivery() {
let finalStatus = PizzaDeliveryAttributes.ContentState(
driverName: "Carlos",
estimatedDeliveryTime: Date(),
deliveryStatus: "¡Entregado! 🍕"
)
Task {
for activity in Activity<PizzaDeliveryAttributes>.activities {
// DismissalPolicy:
// .default: Se queda en la pantalla de bloqueo un tiempo antes de desaparecer.
// .immediate: Desaparece al instante.
// .after(Date): Desaparece en un momento específico.
await activity.end(using: finalStatus, dismissalPolicy: .default)
}
}
}6. Mejores Prácticas y Diseño (Human Interface Guidelines)
Hacer que funcione es fácil; hacer que se sienta nativo es el arte. Apple es muy estricta con el diseño de la Dynamic Island. Aquí tienes claves para pulir tu implementación:
Colores y Fondo
La Dynamic Island es siempre negra. Nunca intentes ponerle un fondo blanco o de color sólido a toda la isla.
- Usa texto blanco o gris claro.
- Usa tu color de marca (Brand Color) solo para iconos, acentos o barras de progreso, no para fondos completos.
- El sistema aplica un desenfoque y opacidad alrededor de la isla; respeta los márgenes que SwiftUI te da por defecto.
Animaciones
SwiftUI maneja las transiciones de tamaño (de Compact a Expanded) automáticamente con animaciones fluidas (interpolating spring). Sin embargo, dentro de tu vista, puedes usar .contentTransition(.numericText()) para contadores o temporizadores. Esto hace que los números se deslicen elegantemente como en el cronómetro nativo de iOS.
Actualizaciones Esporádicas
No abuses de activity.update. Aunque ActivityKit es eficiente, enviar actualizaciones cada segundo puede agotar la batería.
- Correcto: Actualizar cuando el estado cambia (preparando -> cocinando -> enviando).
- Incorrecto: Usar la actualización para un contador de segundos manual. Para cuentas regresivas, usa el estilo de texto
Text(timerInterval:...)que delega el conteo al sistema operativo con coste de batería cero.
Deep Linking
La Dynamic Island no es un mini-teléfono. No puedes poner formularios o scrolls complejos. Cualquier interacción compleja debe llevar al usuario a la App principal. Usa Link(destination: ...) para envolver tus controles en la vista expandida.
7. Limitaciones y Consideraciones Técnicas
Al implementar esto en producción, ten en cuenta:
- El límite de tamaño: El payload de actualización (el JSON de tu
ContentState) no puede exceder los 4KB. Si intentas enviar una imagen codificada en Base64, fallará. Las imágenes deben estar en el bundle de la app o ser descargadas y cacheadas previamente (aunque lo ideal es usar SF Symbols o assets locales). - Tiempo de ejecución: Las Live Activities pueden durar hasta 8 horas activas, pero el sistema las eliminará de la Dynamic Island si no son relevantes, manteniéndolas solo en la pantalla de bloqueo hasta 12 horas.
- Red (Push Notifications): En este tutorial usamos actualizaciones locales. En una app real (como Uber o Glovo), las actualizaciones vendrían desde tu servidor backend usando ActivityKit Push Notifications. Esto requiere configurar certificados APNs específicos.
Conclusión
La Dynamic Island representa un cambio de paradigma: pasamos de pedirle al usuario que entre a nuestra app, a llevar nuestra app a donde el usuario ya está mirando.
Dominar ActivityKit en SwiftUI no es solo cuestión de aprender una nueva API; es entender cómo sintetizar la información más valiosa de tu producto en el espacio más pequeño y privilegiado del iPhone. Si logras que esa pequeña píldora negra aporte valor real, habrás ganado un lugar permanente en la rutina diaria de tus usuarios.










