Programación en Swift y SwiftUI para iOS Developers

Cómo usar la Dynamic Island del iPhone en SwiftUI

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:

  1. Compact (Compacta): La vista estándar cuando la isla está en reposo. Se divide en Leading (Izquierda) y Trailing (Derecha).
  2. Expanded (Expandida): La vista que aparece cuando el usuario mantiene pulsada la isla. Aquí tienes mucho más espacio para mostrar detalles.
  3. 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.

  1. Ve a tu archivo Info.plist.
  2. Añade una nueva clave: NSSupportsLiveActivities.
  3. Establece su valor booleano en YES (o true).

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.

  1. En Xcode, ve a File > New > Target.
  2. Busca y selecciona Widget Extension.
  3. Asegúrate de marcar la casilla “Include Live Activity” (si está disponible) o simplemente crea el widget y lo configuraremos manualmente.
  4. 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 ContentState conforme a Codable y Hashable, 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

  1. 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.
  2. Expanded Regions:
    • .leading.trailing.center.bottom.
    • La región .center se coloca debajo de la cámara (el “notch” físico), mientras que .bottom es ideal para mostrar más contexto o botones interactivos (que funcionan mediante Deep Links, ya que los widgets no soportan botones normales de SwiftUI).
  3. 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:

  1. 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).
  2. 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.
  3. 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.

Leave a Reply

Your email address will not be published. Required fields are marked *

Previous Article

Cómo activar y desactivar botones en SwiftUI

Next Article

Cómo añadir botones a una toolbar en SwiftUI

Related Posts