Saltar al contenido
K
El Pokémon del díaPaso a paso32 min Avanzado

Del aviso a la ficha, en iOS

Al terminar: Abrir la ficha del Pokémon al tocar el aviso, con la app cerrada o abierta.

Al final de la lección anterior el aviso llegaba al móvil, pero tocarlo no abría la ficha del Pokémon. Esta lección lo arregla, y en el camino aparece la sorpresa del módulo: el toque en una notificación no llega por .onOpenURL. Llega a otro sitio, y hay que estar escuchando antes de que la app termine de arrancar.

Al terminar, tocar el aviso (con la app cerrada, en segundo plano o abierta) lleva a la ficha de ese Pokémon, y habrás quitado la prueba temporal.

iosApp/iosApp
Daily/DelegadoDeNotificaciones.swiftse crea
iOSApp.swiftse modifica: instala el delegado
ContentView.swiftse modifica: escucha el enlace
Daily/RecordatorioDiario.swiftse quita la función de prueba
Settings/SettingsView.swiftse quita el botón de prueba
Un fichero nuevo en la app de iOS, dos que se modifican y dos de los que se quita la prueba temporal.

No se instala nada y no se toca shared. Todo se hace en Xcode, con iosApp/iosApp.xcodeproj abierto.

Por qué el toque no llega por .onOpenURL

Si el enlace va en el userInfo y .onOpenURL ya sabe abrir fichas (Deep links en iOS), parece razonable que tocar la notificación lo use. No es así: la app se abriría donde estaba y el enlace quedaría sin leer.

.onOpenURL atiende URLs que entran a la app —un esquema propio, un Universal Link—. El toque en una notificación no es eso: es una respuesta a una notificación, y va al delegado del centro de notificaciones.

Lo nuevo, con un ejemplo ajeno al proyecto

El delegado que necesitamos, primero con la app de plantas de la lección anterior: un oyente que recibe el toque en un aviso de riego y guarda qué planta era. Es para leer, no para escribirlo en el proyecto.

ejemplo mínimo: saber qué aviso de riego se tocó (con comentarios)
import SwiftUI
import UserNotifications
 
// Recibe el toque en un aviso de riego y dice qué planta era.
// `@MainActor`: toda la clase vive en el hilo principal (el de la pantalla); ver abajo por qué.
@MainActor
final class OyenteDeRiego: NSObject, ObservableObject, UNUserNotificationCenterDelegate {
    static let compartido = OyenteDeRiego()   // una sola instancia: la app y la vista usan la misma
    @Published var plantaTocada: String?      // la vista lo observa
 
    // iOS llama aquí cuando el usuario toca el aviso (con la app abierta o cerrada).
    func userNotificationCenter(_ center: UNUserNotificationCenter,
                                didReceive response: UNNotificationResponse) async {
        // userInfo es un diccionario cuyos valores pueden ser de cualquier tipo (`Any`):
        // hay que decir qué tipo esperas.
        let planta = response.notification.request.content.userInfo["planta"] as? String
        plantaTocada = planta   // la clase entera es del hilo principal: se cambia sin más
    }
}
 
struct PlantasView: View {
    @StateObject private var oyente = OyenteDeRiego.compartido   // la vista observa al oyente compartido
    var body: some View {
        if let planta = oyente.plantaTocada {
            Text("Has tocado el aviso del \(planta)")
        } else {
            Text("Ningún aviso tocado")
        }
    }
}
 
#Preview { PlantasView() }

Mientras nadie toque un aviso, la vista dice «Ningún aviso tocado»; al tocar el de un helecho, pasaría a decir «Has tocado el aviso del helecho». Las piezas nuevas, una a una:

  • static let compartido = OyenteDeRiego() crea una sola instancia que vive mientras vive la app. Así la App, que se la da al centro de notificaciones, y la vista, que la observa, hablan con el mismo objeto.
  • response.notification.request.content.userInfo["planta"] baja por las capas de la respuesta (el toque, el aviso, su petición, su contenido) hasta los datos ocultos que se pusieron al programar. El diccionario puede guardar cualquier tipo de cosa, por eso:
  • as? String intenta ver el valor como texto y da nil si no lo es. Es el mismo as? que usaste en Deep links en iOS con destino as? DestinationDetalle.
  • @MainActor, puesto delante de la clase, hace que todo lo que hay dentro corra en el hilo principal, el que dibuja la pantalla (@MainActor y el hilo principal). Aquí no es opcional: sin él, la versión async de didReceive devuelve el control a iOS desde otro hilo, y cuando el toque abre la app desde segundo plano o desde cerrada, iOS lo exige en el principal y la app se cierra nada más tocar el aviso. Con @MainActor no ocurre, y además la línea plantaTocada = planta cambia lo que pinta la vista desde el sitio correcto, sin await.
  • NSObject: UNUserNotificationCenterDelegate es un protocolo de Objective-C (el lenguaje antiguo de Apple, anterior a Swift), y esos protocolos solo los cumplen clases que descienden de NSObject. final significa que nadie hereda de esta clase.
  • @Published es lo que hace que la vista se entere (ObservableObject y @StateObject): cada propiedad marcada con él avisa a SwiftUI cuando cambia.
  • @StateObject private var oyente = OyenteDeRiego.compartido: aquí el objeto no nace en la vista, viene del static let. Por eso @ObservedObject (el que solo observa un objeto que otro mantiene vivo) también valdría. Se usa @StateObject porque es lo que ya usa el resto de la app, y porque el resultado es el mismo: la vista observa un único objeto compartido.

Paso 1 · El delegado

El archivo. DelegadoDeNotificaciones.swift recoge el toque y lo convierte en un enlace publicado. Créalo en la carpeta Daily (clic derecho sobre Daily → New File from Template… → Swift File → DelegadoDeNotificaciones; plan B: touch iosApp/iosApp/Daily/DelegadoDeNotificaciones.swift):

iosApp/iosApp/Daily/DelegadoDeNotificaciones.swift
import Foundation
import UserNotifications
 
/// Recoge el toque en una notificación y lo convierte en un enlace.
///
/// No navega: publica el enlace y la vista lo observa, para que el camino sea el
/// mismo que el de un enlace externo. Una sola forma de llegar a una pantalla.
///
/// `@MainActor`: la clase entera corre en el hilo principal. Sin esto, tocar el aviso
/// con la app en segundo plano o cerrada la cierra (ver la explicación de la lección).
@MainActor
final class DelegadoDeNotificaciones: NSObject, ObservableObject, UNUserNotificationCenterDelegate {
 
    /// Una sola instancia, para que la app y la vista hablen con la misma.
    static let compartido = DelegadoDeNotificaciones()
 
    /// El enlace que acaba de llegar; la vista lo lee y lo pone a nil.
    @Published var enlacePendiente: URL?
 
    /// iOS llama aquí cuando el usuario toca la notificación.
    func userNotificationCenter(
        _ center: UNUserNotificationCenter,
        didReceive response: UNNotificationResponse
    ) async {
        guard
            let texto = response.notification.request.content.userInfo["enlace"] as? String,
            let url = URL(string: texto)
        else { return }
        enlacePendiente = url   // la clase es del hilo principal: se publica sin más
    }
 
    /// Sin esto, una notificación que llega con la app abierta no se ve.
    /// Es el `willPresent` de Marcador, sin el `nonisolated` (ver la explicación de abajo).
    func userNotificationCenter(
        _ center: UNUserNotificationCenter,
        willPresent notification: UNNotification
    ) async -> UNNotificationPresentationOptions {
        [.banner, .sound]
    }
}

El delegado no navega: publica el enlace y se aparta. Así el de una notificación y el de fuera recorren el mismo camino, y no hay dos formas de llegar a la misma pantalla que puedan divergir.

Por qué @MainActor aquí, si en Marcador era nonisolated. En Marcador, el proyecto aislaba todo al hilo principal por defecto, y el willPresent se marcaba nonisolated para poder llamarlo desde otro hilo (@MainActor y el hilo principal explica los dos). El proyecto de la Pokédex no tiene ese ajuste: sus clases no pertenecen a ningún hilo concreto. Por eso hay que decirlo a mano, con @MainActor en la clase. Es lo que evita que la app se cierre al tocar el aviso con la app en segundo plano o cerrada, y con la clase entera en el hilo principal, el willPresent funciona sin nonisolated.

Paso 2 · Instalar el delegado a tiempo

Abre iosApp/iosApp/iOSApp.swift (la que ya llama a KoinIosKt.startKoinIos()). Añade el import arriba, con los demás, y, dentro del init() y debajo de esa llamada, la asignación del delegado. El cuándo importa tanto como el qué. Primero el import:

iosApp/iosApp/iOSApp.swift: el import
import UserNotifications

Y después, la línea nueva dentro del init():

iosApp/iosApp/iOSApp.swift: la línea nueva, dentro de init()
        // Va después de KoinIosKt.startKoinIos().
        // El delegado hay que ponerlo antes de que la app termine de arrancar,
        // o iOS descarta el toque que abrió la app.
        UNUserNotificationCenter.current().delegate = DelegadoDeNotificaciones.compartido

Ese requisito tiene una consecuencia práctica: el caso que se rompe si llegas tarde es justo el más importante, el de la app cerrada. Con la app abierta funcionaría igual, así que el fallo se esconde en las pruebas cómodas.

Paso 3 · Que el toque acabe en la ficha

Abre iosApp/iosApp/ContentView.swift. Hay tres cambios. Primero, una propiedad nueva junto a las otras @State: la vista observa al delegado. Es un @StateObject por la misma razón que en el ejemplo de las plantas: el objeto viene de compartido, y la vista solo lo observa.

iosApp/iosApp/ContentView.swift: la propiedad nueva
    // La vista observa al delegado: cuando publica un enlace, la vista se entera.
    @StateObject private var notificaciones = DelegadoDeNotificaciones.compartido

Segundo, un observador, justo debajo de .preferredColorScheme(ajustes.tema.esquema) y encima del .onChange de scenePhase que añadiste en la lección anterior:

iosApp/iosApp/ContentView.swift: el observador
        // El toque en una notificación acaba en el mismo sitio que un enlace
        // externo: no hay dos caminos a la misma pantalla.
        .onChange(of: notificaciones.enlacePendiente) { _, url in
            guard let url else { return }
            abrir(url)
            notificaciones.enlacePendiente = nil   // ya se atendió
        }

Ese .onChange no lleva initial: true (el que usaste en la lección anterior para el scenePhase), y no hace falta: en las pruebas de abajo, incluso con la app cerrada, el enlace llega cuando la vista ya está lista para escucharlo, y se abre la ficha. Ten en cuenta la línea notificaciones.enlacePendiente = nil al final: al ponerlo a nil, el propio .onChange se dispara otra vez (el valor cambió), y esa segunda vez el guard let url lo para sin hacer nada.

Tercero, el cuerpo de tu .onOpenURL { url in … } (lo que escribiste en Deep links en iOS para abrir la ficha) pasa a una función, para que los dos caminos lo usen. El .onOpenURL queda en una línea, abrir(url), y la función va al final del fichero, antes de #Preview, fuera del struct ContentView. private extension ContentView { … } es una extensión que solo ve este fichero; sirve para dejar las funciones auxiliares aparte del body.

iosApp/iosApp/ContentView.swift: la función compartida
private extension ContentView {
    func abrir(_ url: URL) {
        // `DestinationCompanion.shared` y no `Destination.companion`: una interfaz
        // sellada llega a Swift como protocolo, y un protocolo no tiene companion.
        guard let destino = DestinationCompanion.shared.parse(url: url.absoluteString) else { return }
        pestana = .lista
        if let detalle = destino as? DestinationDetalle {
            caminoLista = [detalle.pokemonId]
        } else {
            caminoLista = []
        }
    }
}

Poner el enlace a nil después de usarlo no es limpieza: .onChange solo se dispara cuando el valor cambia. Si el enlace se quedara guardado, tocar otra notificación con el mismo Pokémon dejaría el valor igual y no pasaría nada. Al volver a nil, el siguiente toque, aunque sea del mismo enlace, es un cambio.

Con eso, ContentView.swift queda como debe quedar al terminar el módulo. Compáralo con el tuyo: los comentarios «Nuevo» y «Cambia» marcan lo que ha tocado esta lección; el resto lo escribiste antes (la insignia de Favoritos viene de Ajustes en iOS y el scenePhase, de la lección anterior):

iosApp/iosApp/ContentView.swift: el fichero entero
import SwiftUI
import Shared
import KMPNativeCoroutinesAsync
 
/// Las tres secciones de la app, como valores que `TabView` puede seleccionar.
// `Hashable` (visto en Marcador): SwiftUI puede comparar estos valores.
private enum Pestana: Hashable {
    case lista, favoritos, ajustes
}
 
struct ContentView: View {
 
    // Un camino de fichas abiertas por pestaña: cada pestaña lleva su pila.
    @State private var caminoLista: [Int32] = []
    @State private var caminoFavoritos: [Int32] = []
 
    @StateObject private var owner = IosViewModelStoreOwner()
    @State private var ajustes = Settings(tema: Tema.sistema, favoritosPorNumero: false)
    @State private var insignia: String? = nil   // nil: sin insignia en Favoritos
 
    // La pestaña visible. Tiene que ser estado porque un enlace
    // entrante la cambia: si llega una ficha estando en Favoritos, hay que
    // saltar a la Pokédex antes de apilar nada.
    @State private var pestana = Pestana.lista
 
    // Nuevo: la vista observa al delegado; cuando publica un enlace, se entera.
    @StateObject private var notificaciones = DelegadoDeNotificaciones.compartido
    // De la lección anterior: si la app está activa, en segundo plano o inactiva.
    @Environment(\.scenePhase) private var scenePhase
 
    var body: some View {
        TabView(selection: $pestana) {   // `selection` ata el TabView al estado
            PokemonListView(camino: $caminoLista)
                .tabItem { Label("Pokédex", systemImage: "square.grid.2x2") }
                .tag(Pestana.lista)       // el valor que representa esta pestaña
 
            FavoritesView(camino: $caminoFavoritos)
                .tabItem { Label("Favoritos", systemImage: "heart.fill") }
                .tag(Pestana.favoritos)
                .badge(insignia)   // con nil SwiftUI no pinta nada
 
            SettingsView()
                .tabItem { Label("Ajustes", systemImage: "gearshape") }
                .tag(Pestana.ajustes)
        }
        // El tema se aplica en la raíz: dentro de la pantalla de ajustes solo
        // pintaría esa pestaña.
        .preferredColorScheme(ajustes.tema.esquema)
        // Nuevo: el toque en una notificación acaba en el mismo sitio que un enlace externo.
        .onChange(of: notificaciones.enlacePendiente) { _, url in
            guard let url else { return }
            abrir(url)
            notificaciones.enlacePendiente = nil   // ya se atendió
        }
        // De la lección anterior: reponer los avisos cada vez que la app pasa a primer plano.
        .onChange(of: scenePhase, initial: true) { _, fase in
            guard fase == .active else { return }
            Task {
                if await RecordatorioDiario.estadoDelPermiso() == .concedido {
                    await RecordatorioDiario.programar()
                }
            }
        }
        // `.onOpenURL` cubre los dos casos, app cerrada y app abierta,
        // sin distinguirlos. En Android son dos sitios (`onCreate` y
        // `onNewIntent`), y olvidar el segundo hace que el enlace falle si la app ya estaba abierta.
        // Cambia: el cuerpo pasó a `abrir`, que usan los dos caminos.
        .onOpenURL { url in
            abrir(url)
        }
        .task {
            let vm = SettingsIosKt.settingsViewModel(owner: owner)
            ajustes = vm.settingsForIos
            do {
                for try await nuevo in asyncSequence(for: vm.settingsForIosFlow) {
                    ajustes = nuevo
                }
            } catch { }
        }
        // La insignia de Favoritos: otro lector del mismo repositorio de favoritos.
        .task {
            let favoritos = FavoritesIosKt.favoritesViewModel(owner: owner)
            insignia = favoritos.insigniaForIos
            do {
                for try await nuevo in asyncSequence(for: favoritos.insigniaForIosFlow) {
                    insignia = nuevo
                }
            } catch { }
        }
    }
}
 
// Nuevo: lo que antes hacía `.onOpenURL`, ahora compartido por los dos caminos.
private extension ContentView {
    func abrir(_ url: URL) {
        // La traducción vive en Kotlin; aquí solo se llama. Devuelve nil si
        // no entiende el enlace, y entonces no se hace nada.
        // `DestinationCompanion.shared` y no `Destination.companion`: una interfaz
        // sellada llega a Swift como protocolo, y un protocolo no tiene companion.
        guard let destino = DestinationCompanion.shared.parse(url: url.absoluteString) else { return }
        pestana = .lista   // antes de apilar, la pestaña de la Pokédex
        if let detalle = destino as? DestinationDetalle {   // ¿es una ficha? (`DestinationDetalle` es `Destination.Detalle` de Kotlin)
            caminoLista = [detalle.pokemonId]   // una ficha: el camino es solo ella
        } else {
            caminoLista = []                     // la lista: sin nada apilado
        }
    }
}
 
#Preview {
    ContentView()
}

Pruébalo

Compila (Cmd+B) y ejecuta iosApp en un simulador (Cmd+R). Abre la pestaña Ajustes. El permiso ya lo diste en la lección anterior; si borraste la app del simulador, activa antes la fila «Pokémon del día».

  1. Con la app delante. Toca Probar dentro de 20 segundos y espera, sin salir de la pantalla de Ajustes: a los 20 segundos baja un banner «El Pokémon de hoy» (gracias a willPresent). Tócalo: la app salta a la ficha de ese Pokémon.
  2. Con la app en segundo plano. Toca el botón otra vez, manda la app al fondo (Device → Home) y espera el banner. Tócalo: la ficha se abre.
  3. Con la app cerrada. Toca el botón otra vez, pulsa Stop en Xcode para cerrar la app y espera el banner (a los 20 segundos). Tócalo: la app arranca de cero y se abre en la ficha de ese Pokémon, no en la lista.
  4. Sin el delegado. Comenta la línea del delegado en iOSApp.swift (paso 2), vuelve a ejecutar y repite la prueba 3: la app se abre en la lista, no en la ficha, porque ninguna otra pieza lee el enlace. Y con la app delante tampoco baja el banner de la prueba 1, porque el willPresent vive en el mismo delegado. Devuelve la línea.

Paso 4 · Quita la prueba

La prueba era un andamio, no parte de la app. Bórrala y deja la app como la dejará el cierre del módulo:

  1. En iosApp/iosApp/Daily/RecordatorioDiario.swift, borra la función programarPrueba entera (desde su comentario /// SOLO PARA PROBAR… hasta su llave de cierre). El enum termina con pendientes().
  2. En iosApp/iosApp/Settings/SettingsView.swift, borra el Button("Probar dentro de 20 segundos") y su comentario.
  3. Cmd+B: tiene que compilar sin errores.

Para dejar los tests verificados, en la Terminal, en la carpeta de tu proyecto: ./gradlew :shared:jvmTest :shared:testAndroidHostTest :shared:iosSimulatorArm64Test y abre los informes como en Ajustes en el núcleo (open shared/build/reports/tests/jvmTest/index.html, y lo mismo con testAndroidHostTest e iosSimulatorArm64Test). Esta lección no añade ninguno: siguen en 80, 44 y 47.

Punto de control

  • Existe DelegadoDeNotificaciones.swift en la carpeta Daily, y iOSApp.swift lo instala en su init().
  • Con la app delante, el aviso de prueba baja como banner; tocarlo abre la ficha de ese Pokémon.
  • Tocar el aviso con la app en segundo plano o cerrada abre la ficha de ese Pokémon, no la lista.
  • Sin la línea del delegado, el toque no lleva a la ficha (ya devolviste la línea).
  • La función programarPrueba y el botón «Probar dentro de 20 segundos» ya no están, y Cmd+B compila sin errores.
  • Los informes de tests dan 80 (JVM), 44 (Android host) y 47 (iOS): sin tests nuevos.

Comprueba lo que entendiste

6 preguntas · necesitas 5 para completar la lección

  1. 1.Se toca la notificación y la app se abre sin ir a la ficha. ¿Por qué?

  2. 2.¿Por qué el delegado se instala en el init de la app y no en la vista?

  3. 3.¿Por qué el delegado publica el enlace en vez de navegar él mismo?

  4. 4.¿Por qué se pone enlacePendiente = nil después de usar el enlace?

  5. 5.La clase DelegadoDeNotificaciones lleva @MainActor. ¿Qué pasaría sin él, al tocar el aviso con la app en segundo plano o cerrada?

  6. 6.Con la app abierta y delante, llega un aviso. ¿Qué hace falta para que baje el banner?

Faltan 6 de 6