Dynamic Island en SwiftUI en Español
Dynamic Island en SwiftUI en Español

Crea tu primer Dynamic Island en tu Aplicación en SwiftUI

La Dynamic Island es una isla que aparece en la parte superior de nuestro iPhone. Junto con las Live Activities podemos añadir vistas que muestren información relevante de nuestra aplicación. En este post explico cómo enviar información valiosa para un usuario y mostrarla dentro de la Isla Dinámica.

SwiftBeta

Tabla de contenido


👇 SÍGUEME PARA APRENDER SWIFTUI, SWIFT, XCODE, etc 👇
Aprende a usar la Dynamic Island en SwiftUI
Aprende a usar la Dynamic Island en SwiftUI

Hoy en SwiftBeta vamos a aprender a usar el Dynamic Island que Apple lanzó con iOS 16.1. Dynamic Island, también conocido como Isla Dinámica, es el nombre que damos a la zona central superior de los iPhone 14, esta zona actualmente es la de la cámara frontal y también de otros sensores como el FaceID, y Apple se las ha ingeniado para que podamos aprovechar este espacio.

Pero ¿cómo?, ahora podemos aprovechar esta isla para añadir funcionalidad de nuestra app, hay varios ejemplos de Apps nativas. Y hoy vamos a aprender a crear una app muy sencilla que utilice la Isla Dinámica para proporcionarnos información, sin necesidad de abrir nuestra aplicación.

La app que te estoy mostrando es la que vamos a crear hoy en SwiftUI. Tendremos una app muy sencilla, que simulará que hacemos la compra de un producto, y podremos ver dentro de nuestra Isla Dinámica el estado de nuestro pedido. Si entramos a la app y pulsamos el Button de cambiar estado, vemos como el estado y la información que aparece en la Isla Dinámica también varía. También aprenderemos a ver cómo se comporta la Isla Dinámica cuando la pantalla está bloqueada, y los diferentes tamaños que tiene, como el expanded y el compacto. Pudiendo customizar toda la información que queremos mostrar.

Displaying live data with Live Activities | Apple Developer Documentation
Offer Live Activities that display your app’s most current data in the Dynamic Island and on the Lock Screen.

Para poder actualizar la información dentro de nuestra Isla Dinámica o la pantalla de bloqueo del iPhone, usaremos las famosas Live Activities. Dentro de la Live Activity podemos compartir actualizaciones de información de nuestra app para que se muestren en la Isla Dinámica, podemos verlo como la conexión, el camino que creamos para que los datos viajen desde nuestra App a la Isla Dinámica.

En el video de hoy vamos a crear una app como hemos visto en muchos de los videos del canal y aparte como novedad vamos a crear un Target, este Target será una extensión, parecido a un Widget de nuestra app.

Y no te preocupes con tanto nombre nuevo que está apareciendo como: Target, Extensión, Widget, etc parece complicado pero es muy muy sencillo, y te lo voy a explicar todo paso por paso. Vamos a empezar

Creamos un proyecto en Xcode

Lo primero de todo que vamos hacer es crear una app en Xcode. Añadimos el nombre de SwiftBetaMarket, y muy importante, en Interface seleccionamos SwiftUI (ya que vamos a utilizar SwiftUI para crear la UI de nuestra app).

Lo siguiente que vamos hacer es crear la pantalla que te estoy mostrando. Dentro de nuestra app, en la vista ContentView vamos a crear la siguiente vista. Esta pantalla va a simular que compramos una camiseta. Al pulsar el Button de comprar, el estado del producto, en este caso la camiseta, va a pasar a enviado. Esta información nos servirá más tarde para ver el estado actual de dónde está el producto que acabamos de comprar, así en lugar de abrir la app y ver el estado, podemos recuperar esta información desde la Isla Dinámica. Fíjate también que hay 2 Buttons más, uno es para actualizar el estado de envío del producto, pasará de enviado a en reparto. Y el último Button sirve para eliminar la información que aparece en la Isla Dinámica.

Vamos a empezar a picar código, lo primero de todo que vamos hacer es crear 3 propiedades:

struct ContentView: View {
    
    @State var productName: String = "Camiseta 20€"
    @State var activityIdentifier: String = ""
    @State var currentDeliveryState: DeliveryStatus = .pending
    ...
}

El compilador nos indica que hay un error, y es normal ya que el tipo DeliveryStatus aún no lo hemos creado en nuestra app. Este tipo va a simular el estado de nuestro producto, vamos a crearlo:

enum DeliveryStatus: String, Codable {
    case pending = ""
    case sent = "Enviado"
    case inTransit = "En Reparto"
    case delivered = "Entregado"
}

Una vez hemos añadido este nuevo tipo, ahora vamos a crear la View, y vas a ver que es una Vista muy sencilla, todo lo hemos visto en los videos de la serie de SwiftUI, voy a poner el modo rápido y luego explicaré cada parte:

struct ContentView: View {
    
    @State var productName: String = "Camiseta 20€"
    @State var activityIdentifier: String = ""
    @State var currentDeliveryState: DeliveryStatus = .pending
    
    var body: some View {
        VStack {
            Text("¡Suscríbete a SwiftBeta! 🚀")
                .font(.system(size: 28, weight: .bold))
                .padding(.bottom, 32)
            
            AsyncImage(url: .init(string: "https://fakestoreapi.com/img/71-3HjGNDUL._AC_SY879._SX._UX._SY._UY_.jpg")) { image in
                image
                    .resizable()
                    .scaledToFit()
                    .frame(width: 200, height: 200)
            } placeholder: {
                ProgressView()
            }
            
            Text(productName)
                .font(.system(.largeTitle))
            
            Text(currentDeliveryState.rawValue)
                .font(.system(.body))
            
            Button {
                // buyProduct()
            } label: {
                Label("Comprar", systemImage: "cart.fill")
            }
            .buttonStyle(.borderedProminent)
            .padding(.top, 32)
            
            Spacer()
            
            Button {
                // changeState()
            } label: {
                Label("Cambiar Estado del Producto", systemImage: "arrow.clockwise.circle.fill")
            }
            .buttonStyle(.borderedProminent)
            .tint(.orange)
            .padding(.top, 32)
            
            Button {
                // removeState()
            } label: {
                Label("Eliminar Información Dynamic Island", systemImage: "trash.fill")
            }
            .buttonStyle(.borderedProminent)
            .tint(.red)

            Spacer()
        }
        .padding()
    }
}

Una vez que hemos creado la View, podemos ver que hemos usado componentes básicos como AsyncImage, Button, Text, y Spacers. Vistas muy sencillas de SwiftUI.

Una vez tenemos la vista de nuestra app principal. Vamos a crear un nuevo grupo llamado Model, y dentro de la carpeta creamos el modelo que hemos añadido en la view, el enum llamado DeliveryStatus. De esta manera vamos a organziar nuestro código de la mejor manera posible.

Perfecto, una vez hemos llegado hasta aquí, ahora falta crear la lógica que se llamará cada vez que se pulsen los Buttons de nuestra vista. Para ser exactos crearemos 3 lógicas diferentes:

  • Crear el Live Activity para mostrar la información dentro de la Isla Dinámica
  • Actualizar la información de nuestro Live Activity, así mostrará la información más actualizada
  • Eliminar el Live Activity, y por lo tanto ya no aparecerá información en la Isla Dinámica.

Para crear esta implementación, vamos a crear una nueva clase llamada DeliveryActivityUseCase.

import Foundation

final class DeliveryActivityUseCase {
    
}

Ahora, vamos a empezar por crear nuestro Live Activity, de esta manera podremos ver la información de nuestra app en la zona del Dynamic Island. Creamos el primer método, dentro de nuestra class DeliveryActivityUseCase.

Lo vamos a llamar startActivity:

static func startActivity(

Pero qué parámetros le pasamos a este método? le pasamos la información que queremos mostrar dentro del Activity, y por lo tanto dentro del Dynamic Island. En este caso quiero pasar solo la información clave:

  • Delivery Status, para saber el estado del producto: enviado, en reparto, etc
  • El nombre del producto
  • La fecha de entrada estimada (esta fecha será inventada y solo servirá para dar información extra)

Voy a añadir estos parámetros:

static func startActivity(deliveryStatus: DeliveryStatus,
                          productName: String,
                          estimatedArrivalDate: String)

Y en este caso, en la firma de la función, vamos a permitir que se puedan lanzar errores, y también que se retorne un tipo String. El tipo String será el ID del Activity, de esta manera podremos crear una referencia y poderlo actualizar o borrar en los próximos minutos. En resumen quedaría de la siguiente manera nuestra firma del método:

static func startActivity(deliveryStatus: DeliveryStatus,
                          productName: String,
                          estimatedArrivalDate: String) throws -> String {
...
}

Lo siguiente que vamos hacer es crear la implementación del método, al añadir muy pocas líneas de código podremos alimentar a nuestro Activity para que esta información se muestre en el Dynamic Island.

Lo siguiente que vamos a comprobar, es que nuestra app puede mostrar Activities, para hacerlo escribimos la siguiente comprobación:

guard ActivityAuthorizationInfo().areActivitiesEnabled else { return "" }

Al hacerlo, Xcode nos avisa de un error, debemos importar un Framework que no hemos visto hasta ahora, y este es el Framework ActivityKit. Como su nombre indica este framework sirve para poder trabajar con Activity y la Isla Dinámica. Así que vamos a importarlo.

A continuación viene una parte muy interesante, cada Activity tiene un estado, este estado no es más que una Struct, un modelo de datos que queremos transportar desde nuestra App hasta el Activity. Tenemos que definir que información queremos que viaje desde nuestra app hasta el Activity, para hacerlo, vamos a crear un fichero nuevo llamado DeliveryAttributes dentro de la carpeta Model que hemos creado hace un momento

import Foundation
import ActivityKit

struct DeliveryAttributes: ActivityAttributes {

}

Fíjate que aquí también tenemos que importar ActivityKit. Este framework nos sirve para poder usar el protocolo ActivityAttributes. Al conformar este protocolo, estamos obligados a crear un tipo llamado ContentState con el modelo que queremos que viaje de nuestra App al Activity (al Dynamic Island). Pues vamos a crear este nuevo tipo:

struct DeliveryAttributes: ActivityAttributes {
    public struct ContentState: Codable, Hashable {

    }
}

Por qué debemos conformar Codable y Hashable en ContentState? por que es un requerimiento del protocolo, puedes ver la documentación de ActivityAttributed si pulsas COMMAND y haces CLICK en el protocolo.

Una vez hemos creado esta Struct, ahora falta añadir la información que queremos que viaje de la App al Activity. Vamos a añadir 3 propiedades (las mismas que hemos añadido en los parámetros de entrada de nuestro método), y quedaría de la siguiente manera:

struct DeliveryAttributes: ActivityAttributes {
    public struct ContentState: Codable, Hashable {
        var deliveryStatus: DeliveryStatus
        var productName: String
        var estimatedArrivalDate: String
    }
}

Perfecto, ya podemos volver a nuestra class DeliveryActivityUserCase y acabar la implementación de crear nuestro Activity. Vamos a continuar.

Lo siguiente que vamos hacer es crear el paquete de información que queremos que viaje desde nuestra app, hasta el Activity, y para hacerlo vamos a usar el DeliveryAttributes que acabamos de crear:

final class DeliveryActivityUseCase {
    static func startActivity(deliveryStatus: DeliveryStatus,
                              productName: String,
                              estimatedArrivalDate: String) throws -> String {
        guard ActivityAuthorizationInfo().areActivitiesEnabled else { return "" }

        let initialState = DeliveryAttributes.ContentState(deliveryStatus: deliveryStatus,
                                                           productName: productName,
                                                           estimatedArrivalDate: estimatedArrivalDate)

Con esta parte ya tenemos el estado que le pasaremos a nuestro Activity para que lo muestre dentro de la Isla Dinámica. Pero ¿cuánto tiempo debe aparecer esta información en la Isla Dinámica? Es decir, queremos que esté 5 minutos? 1 hora? 2 horas? En mi caso voy a añadir que esté una hora. Para especificar este tiempo, usamos el tipo ActivityContent, donde le pasamos el modelo que queremos que viaje desde la App hasta el Activity, y también especificamos el tiempo que queremos que aparezca esta información en la Isla Dinámica:

// Añadimos 1 hora extra
let futureDate: Date = .now + 3600

let activityContent = ActivityContent(state: initialState,
                                      staleDate: futureDate)

Y finalmente, solicitamos empezar un nuevo Activity, de esta manera podremos ver la información en la Isla Dinámica:

let attributes = DeliveryAttributes()
do {
    let activity = try Activity.request(attributes: attributes,
                                        content: activityContent,
                                        pushType: nil)
    return activity.id
} catch {
    throw error
}

Hasta ahora todo lo que hemos hecho, ha sido preparar los datos de nuestra app para que viajen hacia el Activity. Pero ¿dónde está el Activity? Es justo lo que vamos a crear ahora.

Vamos a File -> New -> Target y aquí escogemos la opción de Widget Extension. Añadimos un nombre a nuestra Extensión. Este paso es muy parecido a cuando creamos una app en Xcode y tenemos que añadir un nombre. Nos fijamos que estén todas las opciones seleccionadas igual que aparecen aquí y le damos a Finish.

Al hacerlo, fíjate que aparece una nueva carpeta en nuestro listado de ficheros. Esta carpeta contiene el código del nuevo Target que hemos añadido, en nuestro caso vamos a borrar 2 ficheros que no vamos a necesitar en este video.

  • MyExtensionBundle
  • MyExtension

Antes de borrar MyExtensionBundle, vamos a copiar el @main, esto indica el punto de entrada de nuestro Target. Lo pegaremos dentro del único fichero que dejaremos, el llamado MyExtensionLiveActivity.

Borramos MyExtensionBundle y MyExtension. Y en MyExtensionLiveActivity añadimos el @main justo encima de la struct:

@main
struct MyExtensionLiveActivity: Widget {
...
}

Dentro de esta struct, podemos ver que aparece el código de Dynamic Island. Y justo en las previews podemos ver cómo hay diferentes previews para crear diferentes vistas, una para el tamaño y tipo. Esto es tremendo ya que podemos ver la preview en el Canvas automáticamente, sin la necesidad de compilar nuestro código.

De momento, durante el video de hoy, hemos creado una app, esta app ha creado una struct para poderse comunicar con el Activity y así poder mostrar datos en la Isla Dinámica, y también hemos creado el Target para que la app pueda enviar la información al Activity, que es un Widget y así pueda mostrar los datos. Justo estamos en este último paso, ahora cuando enviemos los datos a nuestro Activity, debemos recuperar esta información para poderla mostrar dentro de la Isla Dinámica, es decir, vamos a adaptar este código para mostrar la información que enviamos de nuestro DeliveryAttributed.

Lo primero de todo, vamos a borrar MyExtensionAttributes, esta información, este modelo ya lo hemos creado en la App principal. Al eliminarlo obviamente tenemos un error ya que esta referencia no existe, acabamos de borrar el tipo. Vamos a sustituirlo por DeliveryAttributes:

@main
struct MyExtensionLiveActivity: Widget {
    var body: some WidgetConfiguration {
        ActivityConfiguration(for: DeliveryAttributes.self) { context in
        ...
}

Al hacerlo, obtenemos un error. Estamos intentando compartir un modelo que está en otro Target. Aquí podríamos crear un módulo para compartir este modelo, o podemos hacerlo más rápido y compartir este fichero en 2 Targets diferentes. De esta manera ambos targets podrán trabajar con el mismo modelo.

Vamos al DeliveryAttributes, y en el inspector de atributos, hay una sección llamada Target Membership, aquí marcamos también la extensión y volvemos para ver si se ha arreglado el error. Correcto! funciona perfectamente, ahora vamos a arreglar las previews

struct MyExtensionLiveActivity_Previews: PreviewProvider {
    static let attributes = DeliveryAttributes()
    static let contentState = DeliveryAttributes.ContentState(deliveryStatus: .sent, productName: "Camiseta 20€", estimatedArrivalDate: "21:00")

Al hacerlo, estamos identificando otro error, parece que este fichero no entiende del tipo DeliveryStatus. Vamos a realizar los mismos pasos que hemos hecho antes, de esta manera este Target de la extensión también conocerá del tipo DeliveryStatus.

Una vez hecho, vemos que el error desaparecer. Antes de crear la vista que te mostraba al inicio del video, donde podemos mostrar la información que le pasamos de la App principal al Activity, vamos a probar que al menos se está lanzando el Activity cuando pulsamos el Button. Pero no se puede lanzar aún, ya que no hemos conectado la lógica de que se pulse el Button y se ejecute el método del DeliveryActivityUseCase. Vamos a hacerlo, vamos a ContentView y añadimos el siguiente método:

func buyProduct() {
    currentDeliveryState = .sent
    do {
        activityIdentifier = try DeliveryActivityUseCase.startActivity(deliveryStatus: currentDeliveryState,
                                                           productName: productName,
                                                           estimatedArrivalDate: "21:00")
    } catch {
        print(error.localizedDescription)
    }
}

Y descomentamos la línea // buyProduct() que tenemos más arriba. De esta manera ya tenemos la conexión de la Vista con la lógica que queremos aplicar. Y antes de compilar nos falta un último paso. Vamos al Info.plist y añadimos la siguiente Key  NSSupportsLiveActivities, y nos aseguramos que el valor sea YES. De esta manera indicamos que nuestra App soporta el uso de Live Activities.

Ahora ya estamos preparados para compilar. Importante, fíjate bien que compiles la App principal en lugar de la extensión. Y vamos a probar. Compilamos.

Al hacerlo, si damos al Button de comprar podemos ver que si vamos al Home pulsando COMMAND+SHIFT+H, vemos este efecto, nuestra app interacturando con el Dynamic Island. Y también vemos el modo compacto, con el valor L y T. Si pulsamos, veremos el modo expanded, donde se muestra más información. Y por último, si bloqueamos la pantalla. Podemos ver también que aparece la información de nuestra Extensión

Para ver esta última vista, podemos usar el ratón y desplazar la View directamente de Arriba a Abajo. O podemos ir a las opciones del simulador en Device -> Lock (aquí también vemos una opción para ir al Home).

De momento, ya hemos aprendido a cómo lanzar un Activity para que muestre la información en la Dynamic Island de nuestro iPhone. Ahora vamos a ver a cómo actualizar esta información y a cómo eliminarla para dejar libre la Dynamic Island.

Primero vamos a actualizar esta información, y para hacerlo vamos a crear un nuevo método en DeliveryActivityUseCase.

static func updateActivity(activityIdentifier: String,
                           newDeliveryStatus: DeliveryStatus,
                           productName: String,
                           estimatedArrivalDate: String) async {
    let updatedContentState = DeliveryAttributes.ContentState(deliveryStatus: newDeliveryStatus,
                                                              productName: productName,
                                                              estimatedArrivalDate: estimatedArrivalDate)
    let activity = Activity<DeliveryAttributes>.activities.first(where: { $0.id == activityIdentifier })
    
    let activityContent = ActivityContent(state: updatedContentState,
                                          staleDate: .now + 3600)
    
    await activity?.update(activityContent)
}

Este método será llamado con la nueva información desde ContentView. En unos minutos haremos la conexión del Button de la Vista que llamará a este método y le pasará todos los parámetros necesarios.

Lo único que hemos hecho ha sido crear un nuevo estado para nuestro Activity, y hemos buscando nuestro Activity para poderlo actualizar con la nueva información. Para más tarde llamar a uno de sus método llamado update con la nueva información.

Lo siguiente que vamos hacer es crear el método que borre el Activity, de esta manera ya no se mostrará más información en la Dynamic Island. Esto es muy útil cuando ya no necesitamos mostrar esta información al user. Al implementar este método forzamos a eliminar el Activity en lugar a que pase su tiempo de vida que especificamos en el parámetro staleDate.

El método es muy sencillo:

static func endActivity(withActivityIdentifier activityIdentifier: String) async {
    let value = Activity<DeliveryAttributes>.activities.first(where: { $0.id == activityIdentifier })
    await value?.end(nil)
}

Una vez hemos implementado estos 2 métodos. Nos vamos a ContentView y creamos los siguientes métodos:

func changeState() {
    currentDeliveryState = .inTransit
    Task {
        await DeliveryActivityUseCase.updateActivity(activityIdentifier: activityIdentifier,
                                                     newDeliveryStatus: currentDeliveryState,
                                                     productName: productName,
                                                     estimatedArrivalDate: "21:00")
    }
}

func removeState() {
    Task {
        await DeliveryActivityUseCase.endActivity(withActivityIdentifier: activityIdentifier)
    }
}

Y en la misma Vista, más arriba, vamos a los 2 Buttons que llaman a estos métodos y descomentamos su llamada.

Perfecto, si ahora compilamos vamos a ver que funciona correctamente.

  • El Activity aparece
  • El Activity es actualizado después de pulsar el Button naranja
  • El Activity desaparece después de pulsar el Button rojo

Ya podemos decir que tenemos nuestro Activity funcionando 100%, ahora solo falta customizar nuestro Dynamic Island. Es decir, vamos a modificar la vista del fichero MyExtensionLiveActivity para mostrar la información que queremos mostrar al usuario.

Vamos a ir añadiendo esta vista poco a poco, y vamos a aprovechar el Canvas para ver el resultado. En este caso empezamos por la pantalla de bloqueo

HStack {
    Image(systemName: "box.truck.badge.clock.fill")
        .resizable()
        .scaledToFit()
        .frame(width: 40, height: 40)
        .foregroundColor(.indigo)
        .padding(.leading, 12)
    VStack(alignment: .leading) {
        Text(context.state.productName)
            .bold()
        + Text(" está ")
        + Text(context.state.deliveryStatus.rawValue)
            .bold()
    }
    Spacer()
    VStack(alignment: .center) {
        Text("Hora de entrega")
        Text(context.state.estimatedArrivalDate)
            .bold()
    }
    .padding(.trailing, 12)
}

En el Canvas podemos ver como aparece correctamente, en este caso hay que ir a la opción de Notification.

Vamos a continuar y vamos a crear la vista expanded. Ahora borramos lo que tenemos dentro del closure dynamicIsland y añadimos el inicializador de DynamicIsland, fíjate que tiene varios parámetros de entrada, vamos a ir añadiendo las vistas de cada parámetro.

dynamicIsland: { context in
    DynamicIsland {
        <#code#>
    } compactLeading: {
        <#code#>
    } compactTrailing: {
        <#code#>
    } minimal: {
        <#code#>
    }
}

Y nos queda el siguiente código:

DynamicIsland {
    DynamicIslandExpandedRegion(.leading) {
        Image(systemName: "box.truck.badge.clock.fill")
            .resizable()
            .scaledToFit()
            .frame(width: 20, height: 20)
            .padding(.leading, 12)
    }
    DynamicIslandExpandedRegion(.trailing) {
        Text(context.state.productName)
            .bold()
            .multilineTextAlignment(.center)
    }
    DynamicIslandExpandedRegion(.center) {
        Text("Paquete: \(context.state.deliveryStatus.rawValue)")
    }
    DynamicIslandExpandedRegion(.bottom) {
        Button {
            
        } label: {
            Label("Cancelar Pedido", systemImage: "xmark.circle.fill")
        }
        .buttonStyle(.borderedProminent)
    }
} compactLeading: {
    HStack {
        Image(systemName: "box.truck.badge.clock.fill")
            .resizable()
            .scaledToFit()
            .frame(width: 20, height: 20)
            
        Text(context.state.productName)
    }
    
} compactTrailing: {
    Text(context.state.deliveryStatus.rawValue)
} minimal: {
    Image(systemName: "box.truck.badge.clock.fill")
        .resizable()
        .scaledToFit()
        .foregroundColor(.green)
}

Si compilamos podemos ver nuestra Activity como muestra la información dentro del Dynamic Island. Fíjate que con muy pocas líneas de código podemos tener un control absoluto de la información que mostramos a los users en diferentes secciones del iPhone. Desde la pantalla de bloqueo hasta el Dynamic Island.

Este ha sido un ejemplo para que veas todas las acciones que podemos realizar con la DynamicIsland, en tu app podrías mostrar otro tipo de información, y añadir las vistas que sean necesarias.

Conclusión

Hoy en SwiftBeta hemos aprendido a cómo usar DynamicIsland y así mostrar información que es importante de nuestra aplicación. Hemos aprendido a usar el framework ActivityKit, a como crear un modelo que viaja desde nuestra app al Activity del sistema, para que así pueda mostrar la información que le estamos mostrando dentro de nuestra vista customizada del DynamicIsland.

Y hasta aquí el video de hoy!