Saltar al contenido
K
Detalle y navegaciónPaso a paso35 min de lectura · 15 min de máquina Intermedio

Navegación en iOS con NavigationStack

Al terminar: Abrir la ficha desde la lista en SwiftUI con NavigationStack, con la pila de ids que ya conoce el núcleo compartido.

Al terminar esta lección, la app de iOS hará lo mismo que la de Android —tocar una tarjeta, ver la ficha, volver— sobre el mismo ViewModel de Kotlin. Y sin escribir ni el botón de volver, ni el título de la barra, ni el gesto de deslizar desde el borde.

Esa es la diferencia grande con las dos lecciones anteriores de Android. En Android hubo que elegir una librería, declarar la pila y dibujar el botón de volver. Aquí la pila también es un dato, pero lo trae el sistema, y con él vienen gratis un montón de gestos que los usuarios de iPhone dan por hechos. Y lo propio de KMP está en el hueco entre las dos mitades: cómo le pide Swift a Kotlin un ViewModel que necesita un parámetro.

Los comandos se escriben en la Terminal de Android Studio, en ~/Pokedex-KMP. Los ficheros de Kotlin se crean en Android Studio con el método de El módulo compartido y la prueba de humo (vista Project, clic derecho sobre la carpeta shared → New → File y la ruta desde la carpeta del módulo); los de Swift se editan en Xcode, con iosApp/iosApp.xcodeproj abierto como en Ejecutar en iOS.

Paso 1 · El puente: un ViewModel con parámetro para Swift

El archivo. DetailIos.kt va en iosMain, el source set que solo se compila para iOS, y es lo que Swift ve en el framework. Hace una cosa: construir un PokemonDetailViewModel para un Pokémon concreto. Créalo con la ruta:

src/iosMain/kotlin/com/sebastiannarvaez/pokedex/feature/detail/DetailIos.kt
shared
src/iosMain/kotlin/.../feature/detail/DetailIos.ktse crea
iosApp/iosApp
Detail/PokemonDetailView.swiftse crea (paso 2)
List/PokemonListView.swiftgana el camino y el destino (paso 3)
Un fichero de Kotlin para el puente, uno de Swift nuevo y uno que cambia. Rutas desde la carpeta de cada módulo.

El concepto. El PokemonDetailViewModel (El ViewModel del detalle) necesita el identificador del Pokémon, y ese dato no sale del grafo de Koin: lo trae la navegación. En Android lo resolvía koinViewModel { parametersOf(pokemonId) }; aquí no hay integración de Koin con SwiftUI, así que se escribe a mano: se le pide a Koin el ViewModel pasándole el parámetro (parametersOf, que llega al (id: Int) -> de la receta de Modules.kt), y el ViewModelProvider lo guarda en el almacén que le dé la vista.

Escríbelo:

shared/src/iosMain/kotlin/com/sebastiannarvaez/pokedex/feature/detail/DetailIos.kt
package com.sebastiannarvaez.pokedex.feature.detail
 
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelStoreOwner
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.rickclephas.kmp.nativecoroutines.NativeCoroutinesState
import kotlinx.coroutines.flow.StateFlow
import org.koin.core.parameter.parametersOf
import org.koin.mp.KoinPlatform
 
/**
 * La ficha para iOS.
 *
 * El identificador no lo sabe el grafo, lo trae la navegacion: por eso se pasa
 * con `parametersOf` en vez de inyectarlo.
 */
fun pokemonDetailViewModel(owner: ViewModelStoreOwner, pokemonId: Int): PokemonDetailViewModel {
    val koin = KoinPlatform.getKoin()   // el grafo de Koin que ya está arrancado en la app
    return ViewModelProvider.create(    // el «encargado» de crear el ViewModel y guardarlo en un almacén
        owner = owner,                  // el almacén que le da la vista de Swift: si es el mismo, devuelve el mismo ViewModel
        factory = viewModelFactory {    // la receta para construirlo la primera vez
            initializer { koin.get<PokemonDetailViewModel> { parametersOf(pokemonId) } }   // se lo pide a Koin, con el número
        },
    )[PokemonDetailViewModel::class]    // y esto es «dame el de este tipo»: el almacén lo guarda bajo su tipo
}
 
@NativeCoroutinesState
val PokemonDetailViewModel.uiStateForIos: StateFlow<PokemonDetailUiState>
    get() = uiState

Es la misma pieza que en Android resolvía koinViewModel { parametersOf(pokemonId) }, escrita a mano. Cada línea está comentada arriba; lo importante es que el almacén es el de esa vista: en el paso siguiente cada ficha crea su propio IosViewModelStoreOwner con un @StateObject, así que cada ficha tiene su propio PokemonDetailViewModel. Es lo que en Android consigue el decorador del ViewModel (sin él, abrir Ivysaur enseñaba Bulbasaur), y aquí viene hecho de esta manera. Y el almacén, y con él el ViewModel, se vacía cuando la vista desaparece y su owner se destruye: nadie lo limpia a mano. Comprueba que el código de iOS compila:

./gradlew :shared:compileKotlinIosSimulatorArm64

Debe terminar en BUILD SUCCESSFUL.

Paso 2 · La ficha, con el mismo estado que Android

El archivo. PokemonDetailView.swift es la pantalla de la ficha en SwiftUI: la imagen que llega hasta arriba, los tipos, la descripción, las medidas y las seis barras. Tiene los mismos tres estados que la de Android (cargando, error con reintento y ficha), los de PokemonDetailUiState.

Créalo con el mismo método de La lista en iOS: primero la carpeta, aquí con la Terminal de Android Studio (el plan B de esa lección), en ~/Pokedex-KMP:

mkdir -p iosApp/iosApp/Detail

En el Project navigator de Xcode (Cmd+1) aparece la carpeta Detail. Clic derecho sobre ella → New File from Template… → Swift File → Next, nombre PokemonDetailView → Create. (Plan B si ese menú no te lo ofrece: touch iosApp/iosApp/Detail/PokemonDetailView.swift y, si Xcode no lo muestra, cierra el proyecto y ábrelo de nuevo.)

El fichero. Son 196 líneas de SwiftUI, casi todo presentación (pilas, tamaños, colores) como la lista de La lista en iOS, así que no se escriben a mano: abre el fichero vacío, pulsa Copiar fichero (aquí debajo), haz clic en el fichero, selecciona todo (Cmd+A) y pega (Cmd+V), como en Del JSON al Pokémon: DTO y repositorio.

PokemonDetailView.swift

Lo que hay que ver es cómo se engancha al StateFlow. Es el patrón de Cancelar desde Swift, reutilizado (ahí está explicado qué es uiStateForIos, por qué existe uiStateForIosFlow y qué hace asyncSequence(for:)):

iosApp/iosApp/Detail/PokemonDetailView.swift
struct PokemonDetailView: View {
 
    let pokemonId: Int32
 
    @StateObject private var owner = IosViewModelStoreOwner()
    @State private var estado = PokemonDetailUiState(cargando: true, detalle: nil, error: nil)
    @State private var viewModel: PokemonDetailViewModel?
 
    var body: some View {
        contenido
            .task {
                let vm = viewModel ?? DetailIosKt.pokemonDetailViewModel(owner: owner, pokemonId: pokemonId)
                viewModel = vm
                estado = vm.uiStateForIos
                do {
                    for try await nuevo in asyncSequence(for: vm.uiStateForIosFlow) {
                        estado = nuevo
                    }
                } catch {
                    // Cancelar no es fallar.
                }
            }
    }

DetailIosKt es el nombre que Swift da a las funciones sueltas del fichero DetailIos.kt (Kotlin las junta en una clase con el nombre del fichero y el sufijo Kt), como viste en El mismo ViewModel en iOS. estado = vm.uiStateForIos toma el valor que el StateFlow tiene ahora mismo, de forma síncrona; el bucle for try await se ocupa de los siguientes. Y pokemonId es Int32, no Int: el Int de Kotlin llega a Swift como Int32.

El área segura. Es la zona de la pantalla que no tapan la isla dinámica, el reloj, los iconos de la barra de estado ni la barra de gestos de abajo. Es el equivalente de los insets de Android, con una diferencia cómoda: SwiftUI coloca el contenido dentro del área segura sin que se lo pidas, y en Android lo pedías tú con statusBarsPadding(). Cuando quieres lo contrario, que algo llegue hasta el borde, se lo dices con .ignoresSafeArea(edges: .top). Eso es lo que hace la ficha con su cabecera: la imagen sube por debajo de la barra de estado, con el degradado de color, y la lista de datos queda por debajo.

El botón de volver y el gesto no están en el fichero, porque no hay que escribirlos; el título es una sola línea, .navigationTitle:

iosApp/iosApp/Detail/PokemonDetailView.swift
        .ignoresSafeArea(edges: .top)
        .navigationTitle(detalle.name.capitalized)
        .navigationBarTitleDisplayMode(.inline)

Compáralo con Android, donde el botón de volver es un FilledTonalIconButton con su statusBarsPadding() y su contentDescription. Aquí NavigationStack lo pone, lo coloca respetando el área segura y lo conecta al gesto de deslizar desde el borde. De momento nadie abre esta vista; compílala con Cmd+B en Xcode y no se queja, porque compila sola.

Esa decisión trae una consecuencia, y está en el fichero: lo que se pone dentro de la cabecera ya no está protegido. Por eso el número «N.º 0025» no va arriba a la derecha, que es donde quedaría debajo de la hora y de los iconos de wifi y batería, sino abajo a la derecha de la imagen:

iosApp/iosApp/Detail/PokemonDetailView.swift
                // El numero va abajo, no arriba: la cabecera ignora el area
                // segura para que la imagen suba bajo la barra, y arriba el
                // numero quedaria debajo de la hora, el wifi y la bateria.
                ZStack(alignment: .bottomTrailing) {

ZStack(alignment:) dice en qué esquina se apilan los elementos que no llevan su propia posición. Con .topTrailing (arriba a la derecha) el número quedaba tapado por la barra; con .bottomTrailing, no.

Paso 3 · La lista abre la ficha

El archivo. PokemonListView.swift ya existe (es la lista que escribiste en La lista en iOS). Ábrelo en Xcode: pasa de tener un NavigationStack sin camino (la pila la llevaba el sistema por dentro) a recibir la pila como un dato. Son tres cambios pequeños.

El estado. Una línea nueva, debajo de presenter. Es el equivalente exacto de la lista de claves de Navigation 3, solo que aquí lo recorre el sistema:

iosApp/iosApp/List/PokemonListView.swift
    /// La pila, como dato. El equivalente de la lista de Navigation 3, solo
    /// que aquí lo trae el sistema: `NavigationStack` recibe el camino y
    /// `navigationDestination` dice qué pintar en cada paso.
    @State private var camino: [Int32] = []

El cuerpo. NavigationStack pasa a recibir el camino, y contenido gana el destino: «cuando el camino tenga un Int32, pinta la ficha de ese Pokémon». Sustituye la línea NavigationStack { por la primera de este bloque y añade el .navigationDestination entre contenido y el .navigationTitle("Pokédex"), que ya estaba y no cambia (el bloque lo muestra para que veas el sitio):

iosApp/iosApp/List/PokemonListView.swift
        NavigationStack(path: $camino) {
            contenido
                .navigationDestination(for: Int32.self) { id in
                    PokemonDetailView(pokemonId: id)
                }
                .navigationTitle("Pokédex")

Ese aviso es el motivo de que .navigationDestination cuelgue de contenido y no de la rejilla: la rejilla es perezosa, y un destino declarado dentro solo existiría mientras esa celda estuviera en pantalla.

La tarjeta. En la rejilla, la tarjeta va dentro de un Button. Button { … } label: { … } tiene dos closures seguidas: la primera es la acción y la segunda es lo que se dibuja. Con varias trailing closures, la primera va sin etiqueta y las demás llevan la suya (Closures). Dentro del ForEach, sustituye PokemonCardView(pokemon: pokemon) y su .onAppear por esto (el .onAppear es el mismo, que ahora cuelga del botón):

iosApp/iosApp/List/PokemonListView.swift
                        // Button y no un gesto de toque: así se hereda el
                        // resaltado al pulsar y VoiceOver lo anuncia como algo
                        // pulsable, sin escribir nada.
                        Button { camino.append(pokemon.id) } label: {
                            PokemonCardView(pokemon: pokemon)
                        }
                        .buttonStyle(.plain)
                        // Así se pide la página siguiente: la vista dice por
                        // dónde va mirando y Paging decide si hace falta.
                        .onAppear { presenter?.loadAround(index: Int32(indice)) }

Lo tentador era .onTapGesture { }, que son menos caracteres. Lo que se pierde con él es concreto: el resaltado al pulsar, el estado deshabilitado y, sobre todo, que VoiceOver (el lector de pantalla de Apple, que verás en la lección de accesibilidad) deje de anunciar la tarjeta como un botón. .buttonStyle(.plain) quita la decoración azul de botón pero conserva las tres cosas.

Ejecútala. Cmd+R en el simulador que tengas elegido. Toca una tarjeta: se abre la ficha. Desliza desde el borde izquierdo (o pulsa el botón de volver de la barra) y vuelves a la lista.

Paso 4 · El medidor de estadísticas

Las seis estadísticas se pintan con Gauge, el componente de Apple para «un valor dentro de un rango»: el nivel de batería, la velocidad de un coche, una estadística de 0 a 255. Un ejemplo con datos ajenos, la batería de un móvil:

ejemplo mínimo: un Gauge que se rellena (con comentarios)
import SwiftUI
 
struct BateriaView: View {
    var body: some View {
        // value: 0.4 dentro de 0...1, o sea, el 40 %.
        Gauge(value: 0.4, in: 0...1) {
            EmptyView()                  // la etiqueta del medidor: aquí no se escribe ninguna
        }
        .gaugeStyle(.linearCapacity)     // una barra que se RELLENA hasta el 40 %
        .labelsHidden()                  // sin etiquetas alrededor: solo la barra
        .tint(.green)                    // el color de la parte rellena
        .padding()
    }
}

Lo que dibuja es una barra horizontal con el 40 % de su recorrido en verde y el resto vacío: cuanto mayor el valor, más llena.

El estilo importa, y se entiende mejor rompiéndolo. Está en el fichero que copiaste. Provócalo. En PokemonDetailView.swift, pulsa Cmd+F, busca .gaugeStyle y cambia .gaugeStyle(.linearCapacity) por .gaugeStyle(.accessoryLinear). Ejecuta otra vez y abre una ficha: todas las estadísticas parecen llenas.

Devuélvelo a .linearCapacity. La versión buena, con sus comentarios:

iosApp/iosApp/Detail/PokemonDetailView.swift
                                    Text("\(stat.value)")
                                        .font(.callout.weight(.semibold))
                                        // Dígitos de ancho fijo: sin esto las
                                        // barras bailan al cambiar de fila.
                                        .monospacedDigit()
                                        .frame(width: 34, alignment: .trailing)
                                    // linearCapacity y no accessoryLinear: el
                                    // primero rellena la barra hasta el valor,
                                    // que es lo que se quiere comparar. El
                                    // segundo pinta la barra entera con una
                                    // marca, y todas parecen llenas.
                                    Gauge(value: Double(stat.value), in: 0...Double(PokemonStat.companion.MAXIMO)) {
                                        EmptyView()
                                    }
                                    .gaugeStyle(.linearCapacity)
                                    .labelsHidden()
                                    .tint(tinte)

El .monospacedDigit() es un detalle pequeño pero visible. Sin él, 45 es más estrecho que 100, el número empuja la barra y las seis barras empiezan en sitios distintos. Con dígitos de ancho fijo, la columna queda recta.

Fíjate también en PokemonStat.companion.MAXIMO: el 255 no está escrito en Swift. Es la constante del companion object de Kotlin, y así el día que cambie, cambia en las dos apps. Desde Swift, el companion object de una clase de datos se ve como una propiedad .companion (PokemonStat.companion.MAXIMO); en las interfaces selladas como Destination se ve distinto, como un tipo aparte llamado DestinationCompanion que ya verás en Deep links en iOS.

Opcional: la pila es un dato

Una cosa que la lección ha dado por sentada y que conviene decir en voz alta: el camino (camino) es un dato de la vista, y se puede escribir antes de que el usuario toque nada. Si en PokemonListView lo declararas como @State private var camino: [Int32] = [25], la app arrancaría directamente en la ficha de Pikachu, con la lista debajo en la pila y el botón «‹» de volver en la barra. Si quieres comprobarlo, hazlo y devuelve el camino a [] antes de seguir.

Esa prueba también enseña lo que le falta a este camino. Una: el tipo. [Int32] solo sabe decir «la ficha de un Pokémon»; en cuanto haya una segunda pantalla con argumentos, no podrá distinguirlas. Dos: de dónde sale el 25. Hoy está escrito a mano; en un enlace tendrá que salir de una URL, y esa traducción ya está escrita y probada, Destination.parse(url), en commonMain. Es lo que hace Deep links en iOS: llama a Destination.parse desde Swift y la plataforma solo aporta la plomería (onOpenURL).

Punto de control

  • Tocas una tarjeta y se abre la ficha; el gesto de deslizar desde el borde izquierdo vuelve a la lista.
  • La barra de navegación de la ficha trae el nombre del Pokémon y un botón de volver que no escribiste.
  • El «N.º» de la ficha se ve abajo a la derecha de la imagen, no debajo de la hora ni de los iconos de la barra.
  • Abres una ficha, vuelves y abres otra: siempre es la que tocaste, no la anterior (cada ficha crea su propio ViewModel).
  • Al pulsar una tarjeta se ve el resaltado.
  • Las seis barras de estadísticas se rellenan hasta su valor y no están todas llenas.
  • Los números de las estadísticas quedan alineados en columna, sin bailar entre filas.

Comprueba lo que entendiste

7 preguntas · necesitas 5 para completar la lección

  1. 1.¿Por qué .navigationDestination(for:) se aplica a contenido y no dentro de la rejilla de tarjetas?

  2. 2.La tarjeta se envuelve en un Button con .buttonStyle(.plain) en vez de usar .onTapGesture. ¿Qué se gana?

  3. 3.Pintas las estadísticas con Gauge(...).gaugeStyle(.accessoryLinear) y todas parecen llenas. ¿Qué hace ese estilo, y cuál es el bueno aquí?

  4. 4.En Swift, la descripción del Pokémon se lee como detalle.description_, con guion bajo. ¿Por qué?

  5. 5.En la ficha de iOS no hay código para el botón de volver ni para el gesto de deslizar desde el borde. ¿Por qué?

  6. 6.PokemonDetailView recibe pokemonId: Int32 y no Int. ¿Por qué?

  7. 7.En la ficha de iOS, el «N.º» va abajo a la derecha de la cabecera (ZStack(alignment: .bottomTrailing)) y no arriba. ¿Por qué?

Faltan 7 de 7