Saltar al contenido
K
BúsquedaPaso a paso28 min Intermedio

La búsqueda en iOS con .searchable

Al terminar: Poner el campo de búsqueda nativo de SwiftUI sobre el mismo estado compartido.

Al terminar esta lección, la app de iOS busca con el campo de búsqueda nativo de SwiftUI, sobre el mismo PokemonSearchViewModel de Kotlin que ya usa Android. Y sin escribir el campo, ni el botón de borrar, ni dónde se coloca: eso lo hace un modificador de una línea y el sistema decide el resto.

Es la mitad de iOS de la búsqueda. Lo propio de KMP está en el hueco entre las dos mitades: un puente en Kotlin para que Swift pida el ViewModel, y un límite que ya conoces, el de los valores por defecto de Kotlin, que no llegan a Swift.

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

Antes de empezar: el mínimo de iOS

La lección usa dos piezas de iOS 17: ContentUnavailableView y la forma de .onChange con dos parámetros. Compruébalo antes de escribir nada: en Xcode, selecciona el proyecto iosApp en el Project navigator (Cmd+1), elige el target de la app (el único de la lista TARGETS) y abre la pestaña General. En Minimum Deployments debe poner 17.0 o más; si pone menos, cámbialo a 17.0. Con menos, Xcode marcaría ContentUnavailableView con un error de disponibilidad.

El concepto · Un modificador que añade el campo

.searchable(text:prompt:) es un modificador: una llamada que se escribe detrás de una vista con un punto y le cambia algo. Este le dice a SwiftUI «esta vista se puede buscar». Tú solo le das dos cosas: un enlace (Binding) al texto, donde el sistema escribe lo que teclea el usuario, y el prompt, el texto de sugerencia del campo. El campo en sí, su botón de borrar y dónde se coloca lo pone el sistema.

Y dónde se coloca depende de la versión de iOS: en iOS 26 flota abajo, al alcance del pulgar; hasta iOS 18 está arriba, bajo el título grande. Comprobado en el simulador con las dos. Tu código es el mismo en las dos, y esa es exactamente la ventaja de que lo ponga el sistema. En tu simulador verás una de las dos, según su versión de iOS; no hace falta probar las dos.

Un ejemplo solo para leer (no lo escribas en el proyecto), con datos ajenos a la Pokédex: una lista de frutas que se puede buscar. Varias piezas que ya conoces trabajan alrededor del modificador nuevo:

ejemplo mínimo: una lista de frutas que se puede buscar (con comentarios)
struct FrutasBuscables: View {
    let frutas = ["Manzana", "Mango", "Pera", "Plátano"]
    @State private var texto = ""                       // lo escrito: un String de Swift
 
    var filtradas: [String] {                           // si no hay texto, todas; si hay, las que lo contienen
        texto.isEmpty ? frutas : frutas.filter { $0.localizedCaseInsensitiveContains(texto) }   // sin distinguir mayúsculas
    }
 
    var body: some View {
        NavigationStack {
            Group {
                if filtradas.isEmpty {
                    // El estado vacío de búsqueda, ya redactado por el sistema.
                    ContentUnavailableView.search(text: texto)
                } else {
                    List(filtradas, id: \.self) { Text($0) }
                }
            }
            .navigationTitle("Frutas")
            // Un modificador: el campo lo coloca el sistema, no tú.
            .searchable(text: $texto, prompt: "Buscar fruta")
            // Se ejecuta cada vez que cambia `texto`: recibe el valor viejo y el nuevo.
            .onChange(of: texto) { _, nuevo in print("buscando:", nuevo) }
        }
    }
}

Lo que se ve, comprobado con esta misma vista en la app:

  • en iOS 26, el campo «Buscar fruta» flotando abajo; en iOS 18, bajo el título «Frutas» (en tu simulador verás una de las dos);
  • al escribir «ma», quedan «Manzana» y «Mango»;
  • con «xyz» no queda ninguna: aparece una lupa con «No hay resultados para "xyz"» y, debajo, «Comprueba la ortografía o prueba con una nueva búsqueda.» (con el simulador en español). Esos textos no los escribiste: son del sistema.

Fíjate en que texto vive en un @State de Swift, no en ningún sitio compartido. .searchable pide un enlace de Swift, y eso es lo que decide dónde vive el texto en la Pokédex.

Los ficheros de hoy

shared/src/iosMain/…/feature/search/SearchIos.ktse crea (paso 1)
iosApp/iosApp/List
PokemonListView.swiftgana .searchable (paso 2)
SearchResultsView.swiftse crea (paso 3)
Un fichero de Kotlin para el puente, uno de Swift nuevo y uno que cambia.

Paso 1 · El puente a Swift del buscador

El archivo. SearchIos.kt, en iosMain (el source set que solo compila para iOS). Es el mismo puente que el de la ficha, DetailIos.kt, sin el parámetro del identificador. Créalo: clic derecho sobre la carpeta shared → New → File y la ruta:

src/iosMain/kotlin/com/sebastiannarvaez/pokedex/feature/search/SearchIos.kt
shared/src/iosMain/kotlin/com/sebastiannarvaez/pokedex/feature/search/SearchIos.kt
package com.sebastiannarvaez.pokedex.feature.search
 
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.mp.KoinPlatform
 
fun pokemonSearchViewModel(owner: ViewModelStoreOwner): PokemonSearchViewModel {
    val koin = KoinPlatform.getKoin()
    return ViewModelProvider.create(
        owner = owner,
        factory = viewModelFactory { initializer { koin.get<PokemonSearchViewModel>() } },
    )[PokemonSearchViewModel::class]
}
 
@NativeCoroutinesState
val PokemonSearchViewModel.uiStateForIos: StateFlow<SearchUiState>
    get() = uiState

Paso 2 · La barra es un modificador

El archivo. PokemonListView.swift ya existe, en iosApp/iosApp/List. Se le añaden tres cosas: estado, el modificador y la suscripción al buscador. El .onChange de arriba se ejecuta aquí cada vez que el usuario teclea, y { _, nuevo in … } ignora el valor anterior (_) y se queda con el nuevo.

1 · El estado del buscador: tres @State nuevos, debajo de presenter (buscador guarda el ViewModel de Kotlin; busqueda, lo último que emitió; texto, lo que hay escrito en el campo). Fíjate en que SearchUiState(...) lleva los cuatro parámetros, aunque en Kotlin todos tienen valor por defecto:

iosApp/iosApp/List/PokemonListView.swift
    @State private var buscador: PokemonSearchViewModel?
    @State private var busqueda = SearchUiState(
        consulta: "", resultados: [], buscando: false, error: nil
    )
    @State private var texto = ""

2 · El body. Antes el NavigationStack envolvía contenido con .navigationDestination y .navigationTitle. Ahora los envuelve un Group que decide qué enseñar según haya texto o no. Sustituye desde NavigationStack(path: $camino) { hasta .navigationTitle("Pokédex"); lo que hay después (el comentario y el .navigationBarTitleDisplayMode(.large) del título, la llave de cierre del NavigationStack y el .task de la lista) se queda igual:

iosApp/iosApp/List/PokemonListView.swift
        NavigationStack(path: $camino) {
            Group {
                // `.searchable` es un modificador: el campo lo pone el sistema
                // (arriba hasta iOS 18; abajo, flotando, en iOS 26).
                // En Android la barra de búsqueda es un componente que hay que
                // colocar, y hay que decidir qué pasa con el título grande.
                if texto.isEmpty {
                    contenido
                } else {
                    SearchResultsView(estado: busqueda) { id in
                        texto = ""
                        camino.append(id)
                    }
                }
            }
            .searchable(text: $texto, prompt: "Buscar Pokémon")
            .onChange(of: texto) { _, nuevo in buscador?.escribir(texto: nuevo) }
            .navigationDestination(for: Int32.self) { id in
                PokemonDetailView(pokemonId: id)
            }
            .navigationTitle("Pokédex")

Un detalle del cierre de la función: antes de navegar se vacía el texto (texto = ""). Si no se hiciera, al volver de la ficha texto seguiría escrito y se vería la lista de resultados en vez de la rejilla.

3 · La suscripción, un segundo .task justo debajo del que ya hay (el de la lista): crea el ViewModel y copia en busqueda cada estado nuevo. owner es el dueño del ciclo de vida que ya usa el primer .task, el mismo que declaraste en La lista en iOS. Dos nombres de aquí se explicaron antes: uiStateForIosFlow es el uiStateForIos del puente con el sufijo …Flow que añade el plugin de corrutinas nativas (Cancelar desde Swift), y asyncSequence(for:) lo convierte en algo que for await entiende (Un Flow hasta Swift).

iosApp/iosApp/List/PokemonListView.swift
        .task {
            let vm = buscador ?? SearchIosKt.pokemonSearchViewModel(owner: owner)
            buscador = vm
            do {
                for try await nuevo in asyncSequence(for: vm.uiStateForIosFlow) {
                    busqueda = nuevo
                }
            } catch {
                // Cancelar no es fallar.
            }
        }

Compara el campo con el campo: dos modificadores (.searchable y .onChange) frente al SearchBarDefaults.InputField de Android, con su lupa, su botón de borrar y sus dos rutas de expansión. A cambio no eliges nada: ni dónde va el campo, ni cuándo se recoge, ni cómo se anima. En iOS 26 el sistema coloca el campo flotando en la parte de abajo, al alcance del pulgar; hasta iOS 18 estaba arriba, bajo el título.

Paso 3 · El estado vacío que iOS regala

El archivo. SearchResultsView.swift, en la misma carpeta List: lo que se ve cuando hay texto escrito. Créalo como en La lista en iOS: clic derecho sobre la carpeta List del Project navigator → New File from Template… → Swift File → Next, escribe SearchResultsView y Create (si el menú de tu versión se llama distinto, touch iosApp/iosApp/List/SearchResultsView.swift en la Terminal, desde ~/Pokedex-KMP; en ese caso comprueba en el Project navigator que SearchResultsView.swift aparece dentro de List, y si no aparece, créalo con el menú).

iosApp/iosApp/List/SearchResultsView.swift
import SwiftUI
import Shared
 
/// Los resultados de la búsqueda, como lista.
///
/// En Android la barra de Material se expande a pantalla completa y lleva los
/// resultados dentro. Aquí no hace falta: con `.searchable` el campo lo pone
/// el sistema (arriba hasta iOS 18; abajo, flotando, en iOS 26) y la vista
/// decide qué pintar debajo, que es más simple y más de iOS.
struct SearchResultsView: View {
 
    let estado: SearchUiState
    let alElegir: (Int32) -> Void
 
    var body: some View {
        if estado.enReposo {
            ContentUnavailableView(
                "Busca un Pokémon",
                systemImage: "magnifyingglass",
                description: Text("Escribe un nombre para empezar")
            )
        } else if estado.buscando && estado.resultados.isEmpty {
            ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
        } else if let error = estado.error {
            ContentUnavailableView("No se pudo buscar", systemImage: "wifi.exclamationmark", description: Text(error.detalle))
        } else if estado.sinResultados {
            // ContentUnavailableView es de iOS 17: el estado vacío con su
            // icono, su título y su texto, ya maquetado por el sistema. En
            // Android hay que componerlo a mano.
            ContentUnavailableView.search(text: estado.consulta)
        } else {
            List(estado.resultados, id: \.id) { ref in
                Button {
                    alElegir(ref.id)
                } label: {
                    HStack(spacing: 14) {
                        AsyncImage(url: URL(string: ref.artworkUrl)) { imagen in
                            imagen.resizable().scaledToFit()
                        } placeholder: {
                            Color.clear
                        }
                        .frame(width: 44, height: 44)
 
                        VStack(alignment: .leading, spacing: 1) {
                            Text(ref.name.capitalized)
                                .font(.body.weight(.medium))
                            Text(String(format: "N.º %04d", ref.id))
                                .font(.caption2)
                                .foregroundStyle(.secondary)
                        }
                    }
                }
                .buttonStyle(.plain)
            }
            .listStyle(.plain)
        }
    }
}

Compara las cinco ramas con el when de La barra de búsqueda en Android: son las mismas cinco (reposo, cargando, error, sin resultados y lista), en el mismo orden, sobre los mismos dos cálculos del estado compartido. Lo que cambia es la rama de «sin resultados»: ContentUnavailableView.search(text:) ya trae el icono y dos textos redactados y traducidos por el sistema. Con «zzzz» se lee «No hay resultados para "zzzz"» y, debajo, «Comprueba la ortografía o prueba con una nueva búsqueda.». En Android eso es un Aviso que hubo que escribir («Ningún Pokémon se llama así»). De las otras tres, el reposo y el error usan el ContentUnavailableView de siempre, con textos tuyos; cargando usa un ProgressView y la lista, un List.

En String(format: "N.º %04d", ref.id), %04d significa «un entero, rellenado con ceros hasta 4 cifras» (el 25 sale «0025»), igual que padStart en Android. Compila con Cmd+B y ejecuta (Cmd+R).

Punto de control

  • Cmd+B en Xcode termina en «Build Succeeded» (los errores en rojo de antes han desaparecido), y el campo «Buscar Pokémon» aparece: flotando abajo si tu simulador tiene iOS 26, arriba bajo el título si tiene iOS 18. Verás una de las dos.
  • Escribes «char» y salen los mismos diez resultados que en Android y en el mismo orden. Las formas pueden salir con otras mayúsculas que en Android («Charizard-Mega-X» frente a «Charizard-mega-x»): cada plataforma pone las suyas.
  • Escribes solo espacios y se lee «Busca un Pokémon» con «Escribe un nombre para empezar»; no dice «sin resultados».
  • Escribes «zzzz» (con el simulador en español) y se lee «No hay resultados para "zzzz"» con «Comprueba la ortografía o prueba con una nueva búsqueda.» debajo.
  • Eliges un resultado, por ejemplo Charizard, y se abre su ficha. Al volver, el texto se ha vaciado y se ve la rejilla otra vez; en Android la barra conservaba el texto, y aquí no.
  • Los tests siguen en 41 en la JVM, 40 en Android host y 42 en iOS (./gradlew :shared:jvmTest :shared:testAndroidHostTest :shared:iosSimulatorArm64Test, sin contar los de la carpeta ejercicios). Esta lección no añade ninguno.

Comprueba lo que entendiste

5 preguntas · necesitas 4 para completar la lección

  1. 1.¿Dónde coloca iOS el campo de .searchable?

  2. 2.En iOS, el caso «no hay resultados» se resuelve con ContentUnavailableView.search(text:). ¿Qué aporta frente a Android?

  3. 3.Desde Swift, SearchUiState(...) se escribe con sus cuatro parámetros aunque en Kotlin todos tengan valor por defecto. ¿Por qué?

  4. 4.¿Por qué el texto del buscador de iOS vive en un @State de Swift y no en el ViewModel de Kotlin?

  5. 5.¿Qué dos piezas de la lección exigen que el proyecto de iOS pida 17.0 como mínimo?

Faltan 5 de 5