Saltar al contenido
K
CalidadPaso a paso25 min de lectura · 8 min de máquina Intermedio

Dos idiomas en iOS

Al terminar: Crear el catálogo de cadenas, traducir la app de iPhone y arreglar los sitios donde SwiftUI no traduce solo.

En iOS los textos de la interfaz viven en un catálogo de cadenas (String Catalog): un solo fichero, Localizable.xcstrings, con una fila por texto y una columna por idioma. Esta lección crea el catálogo, traduce al inglés la app de iPhone y arregla los sitios donde la traducción no ocurre sola, que fallan sin ningún error. Parte de Dos idiomas: el idioma en el núcleo, que dejó el módulo compartido pidiendo a PokeAPI el idioma del usuario; la mitad de Android está en Dos idiomas en Android: los recursos y Dos idiomas en Android: todas las pantallas, y no hace falta para esta.

Al terminar, con el iPhone (o el simulador) en inglés, toda la app está en inglés: la lista, la ficha con sus tipos y estadísticas, Ajustes, el formulario de entrada y la notificación del Pokémon del día. VoiceOver lee los tipos con la «and» del idioma. Y los cuatro tests de interfaz siguen pasando.

iosApp/iosApp
Localizable.xcstringsnuevo (paso 1)
List/TypeStyle.swiftpaso 2
Detail/PokemonDetailView.swiftpasos 2, 3 y 4
Settings/SettingsView.swiftpaso 3
Daily/RecordatorioDiario.swiftpaso 3
Auth/AuthView.swiftpaso 3
Core/Numero.swiftnuevo (paso 4)
List/PokemonCardView.swiftpasos 4 y 5
Favorites/FavoritesView.swift, List/SearchResultsView.swiftpaso 4
iosApp/PokedexUITests
PokedexUITests.swiftpaso 6
Qué se crea y qué cambia, con las rutas reales del disco (en Xcode, el Project navigator las muestra igual).

El concepto · Un diccionario, y tres formas de pintar un texto

Un catálogo de cadenas es un diccionario: una fila por frase, con la frase en español como entrada (la clave) y su traducción al inglés al lado. Cuando SwiftUI va a pintar un texto, busca la clave en el diccionario y pinta la traducción del idioma del teléfono. Si la clave no está o no tiene traducción, pinta la clave misma.

La parte difícil es saber cuándo SwiftUI busca y cuándo no. Hay tres formas casi iguales de escribir un texto, y solo dos traducen. Un ejemplo sin nada de Pokémon, con la app en inglés y una fila "Hola" → "Hello" en el catálogo:

ejemplo mínimo: tres Text casi iguales (con comentarios)
import SwiftUI
 
struct Saludos: View {
    // Una variable de tipo `String`: SwiftUI la trata como un texto cualquiera.
    let saludo: String = "Hola"
 
    var body: some View {
        VStack {
            // Un LITERAL escrito dentro de `Text(…)`: SwiftUI lo convierte en un
            // `LocalizedStringKey` (una clave de catálogo) y la BUSCA → «Hello».
            Text("Hola")
 
            // Una VARIABLE `String`: se pinta TAL CUAL, sin buscar → «Hola».
            Text(saludo)
 
            // `String(localized:)` hace la búsqueda a mano y devuelve el texto
            // ya traducido → «Hello». Sirve donde no hay `Text` (una notificación).
            Text(String(localized: "Hola"))
        }
    }
}

Con la app en inglés se ve Hello, Hola, Hello. El segundo es el fallo que da nombre a media lección: la app se ve perfecta en español y solo falla en los demás idiomas, sin ningún error.

Dos piezas más, que verás en los pasos:

  • El hueco de un número. Si un texto lleva un número dentro ("Descubre quién es el número \(id)"), la clave del catálogo lleva el hueco en lugar del número: Descubre quién es el número %d. El especificador (el símbolo que marca el hueco y dice de qué clase de número es) depende del tipo: %d para un Int32 (así llega a Swift el Int de Kotlin) y %lld para un Int de Swift. Si la fila no se llama exactamente así, no hay traducción.
  • Etiqueta y valor de VoiceOver. La etiqueta dice qué es un elemento y el valor dice cómo está: VoiceOver lee los dos, con una pausa entre ellos («Bulbasaur, Planta y Veneno»).

Paso 1 · El catálogo de cadenas

Las vistas de SwiftUI que escriben un literal (Text("Ajustes"), Label("Favoritos", …), .navigationTitle("Pokédex")) ya buscan ese literal en el catálogo: el literal es la clave, y el catálogo dice cómo se traduce. Por eso la mayoría de la interfaz no cambia de código.

Crea el fichero. Clic derecho sobre la carpeta iosApp/iosApp (la de dentro) → New File from Template… y elige String Catalog, con el nombre Localizable (plan B: touch iosApp/iosApp/Localizable.xcstrings en la Terminal, desde la carpeta de tu proyecto). Aparece Localizable.xcstrings en el Project navigator.

Ábrelo como texto. Xcode lo enseña como una tabla, y una tabla no admite pegar un fichero entero. Clic derecho sobre Localizable.xcstrings → Open As → Source Code (así se ve como texto, igual que hiciste con Info.plist en Configuración y secretos por entorno). Selecciona todo y pega encima la versión hecha: es una tabla de unas cien filas, español en la fuente e inglés traducido, y no aporta nada nuevo escribirla a mano. (Plan B: open -e iosApp/iosApp/Localizable.xcstrings la abre como texto en TextEdit.)

Localizable.xcstrings

La primera línea del fichero dice "sourceLanguage": "es": el español es el idioma de las claves. Entre las filas hay tres con un hueco: %lld días programados (Ajustes), Descubre quién es el número %d (la notificación) y Al menos %d caracteres (el formulario). La primera ya funciona sin tocar nada, porque Ajustes la escribe como literal dentro de un Text y SwiftUI la busca solo; las otras dos las usa el paso 3. Para ver el resultado como tabla, cierra el editor y vuelve a abrir el fichero con doble clic: salen las filas con la columna de inglés. (Si Xcode ya creó un catálogo vacío con ese nombre, se sustituye su contenido.) Compila con Cmd + B: debe terminar sin errores.

Cuando falta una traducción, iOS usa la clave: no falla ni deja el hueco vacío, da una app mezclada en vez de una app rota. Como red de seguridad es excelente; como control de calidad es malísimo, porque nada avisa de que falta una fila. Por eso se repasa la columna de inglés antes de dar la traducción por terminada. Y si al compilar Xcode añade por su cuenta filas como %d o una vacía, son ruido (un número suelto, un Picker sin título): márcalas con Don't Translate (clic derecho sobre la fila, en la vista de tabla) y sigue; si no encuentras la opción, déjalas, no hacen daño.

Paso 1b · Ver la app en inglés, antes de arreglar nada

Cambiar el idioma de todo el simulador es lento. Con la app ya instalada (Cmd + R una vez), se puede arrancar una sola vez en otro idioma con argumentos de lanzamiento. En la Terminal:

xcrun simctl launch --terminate-running-process booted com.sebastiannarvaez.pokedex -AppleLanguages "(en)" -AppleLocale en_US

La orden, pieza a pieza: xcrun simctl es la herramienta de Xcode que manda órdenes al simulador; launch abre una app; --terminate-running-process la cierra antes si ya estaba abierta (sin eso, la app abierta con Cmd + R no se relanzaría con los argumentos nuevos); booted quiere decir «el simulador que esté encendido»; com.sebastiannarvaez.pokedex es el identificador de tu app (el BUNDLE_ID que ya viste en Ejecutar en iOS; si el tuyo es otro, está en el target de la app → pestaña General → Bundle Identifier). Lo que va después son los argumentos para la app: -AppleLanguages "(en)" pone su idioma y -AppleLocale en_US el formato de números y fechas. Mira la lista: las pestañas dicen «Favourites» y «Settings». Abre la ficha de Bulbasaur y fíjate bien, que es lo que vas a arreglar: los tipos dicen «Planta» y «Veneno», y debajo de la altura y el peso, «Altura» y «Peso», con el resto de la pantalla en inglés. Vuelve a esta orden cuando quieras comprobar un paso.

Paso 2 · El fallo más silencioso: Text solo traduce literales

Text("Ajustes") traduce porque el compilador convierte el literal en una clave de catálogo (LocalizedStringKey). Con una variable de tipo String, Text(variable) usa otra forma de construir el Text (otro inicializador) y pinta el texto tal cual, sin mirar el catálogo: es el segundo Text del ejemplo. En la ficha hay dos sitios así.

Los tipos. El nombre de cada tipo sale de una propiedad (etiqueta) que devuelve un String. Abre iosApp/iosApp/List/TypeStyle.swift: hay dos cambios en el mismo sitio. Uno: la propiedad que ya existe se queda con sus dieciocho case, pero su primera línea pasa de var etiqueta: String { a private var claveDelTipo: String { (solo cambian esas palabras: ahora devuelve la clave en español). Dos: encima de ella escribe la nueva etiqueta, que traduce esa clave. Queda así:

TypeStyle.swift: etiqueta, ahora traducida
    /// El nombre del tipo, ya traducido.
    ///
    /// `String(localized:)` busca la clave en el catálogo y devuelve su
    /// traducción. Un `String` a secas no la busca. Recibe un
    /// `String.LocalizationValue`, el tipo de «texto que se puede traducir»:
    /// se construye a partir de un literal o, como aquí, de una variable.
    var etiqueta: String {
        String(localized: String.LocalizationValue(claveDelTipo))
    }
 
    // La que había antes, con otro nombre: devuelve la clave en español.
    private var claveDelTipo: String {
        switch name {
        // … los dieciocho `case` que ya tenías, sin cambios …

Los títulos de la ficha. Abre iosApp/iosApp/Detail/PokemonDetailView.swift y busca, hacia el final, private struct Medida. Recibe el título como String y lo pinta con Text(titulo), igual que los tipos. Cambia el tipo de la propiedad; las llamadas (Medida(titulo: "Altura", …)) no cambian, porque un literal sirve igual para un tipo y para el otro:

PokemonDetailView.swift: Medida, antes → después
// antes
    let titulo: String
 
// después
    // `LocalizedStringKey` es lo que `Text` usa para los literales: con este
    // tipo, `Text(titulo)` sí busca la traducción.
    let titulo: LocalizedStringKey

Compila (Cmd + B) y ejecuta la orden del paso 1b: los tipos dicen «Grass» y «Poison», y las medidas «Height» y «Weight». Provócalo: devuelve LocalizedStringKey a String y repite la orden: «Altura» y «Peso» vuelven, y nada te avisa. Deja LocalizedStringKey.

Paso 3 · Cinco textos que viven en una propiedad, una notificación o un formulario

El mismo problema con otros disfraces: un String que nadie pasa por el catálogo. Cinco sitios: el nombre del tema, el de cada estadística, el texto de la notificación del Pokémon del día, los errores del formulario de entrada y un texto suelto de Ajustes. En todos basta envolver el literal en String(localized:), que es lo que hace el catálogo con un literal dentro de Text.

El tema. En iosApp/iosApp/Settings/SettingsView.swift, al final del fichero, en extension Tema:

SettingsView.swift: la etiqueta de Tema, antes → después
// antes
        case "CLARO": return "Claro"
        case "OSCURO": return "Oscuro"
        default: return "Lo que diga el sistema"
 
// después
        case "CLARO": return String(localized: "Claro")
        case "OSCURO": return String(localized: "Oscuro")
        default: return String(localized: "Lo que diga el sistema")

Las estadísticas. En iosApp/iosApp/Detail/PokemonDetailView.swift, al final del fichero, extension StatKind:

PokemonDetailView.swift: la etiqueta de StatKind, antes → después
// antes
        case "HP": return "PS"
        case "ATTACK": return "Ataque"
        case "DEFENSE": return "Defensa"
        case "SPECIAL_ATTACK": return "At. especial"
        case "SPECIAL_DEFENSE": return "Def. especial"
        case "SPEED": return "Velocidad"
 
// después
        case "HP": return String(localized: "PS")
        case "ATTACK": return String(localized: "Ataque")
        case "DEFENSE": return String(localized: "Defensa")
        case "SPECIAL_ATTACK": return String(localized: "At. especial")
        case "SPECIAL_DEFENSE": return String(localized: "Def. especial")
        case "SPEED": return String(localized: "Velocidad")

La notificación. En iosApp/iosApp/Daily/RecordatorioDiario.swift, dos líneas de contenido(anio:mes:dia:). Una notificación no es una vista: no hay Text, así que String(localized:) es la única vía. La segunda lleva un número dentro:

RecordatorioDiario.swift: título y cuerpo, antes → después
// antes
        contenido.title = "El Pokémon de hoy"
        contenido.body = "Descubre quién es el número \(id)"
 
// después
        contenido.title = String(localized: "El Pokémon de hoy")
        // El número dentro del texto pasa a ser un hueco de la clave:
        // `id` es un `Int32` (el `Int` de Kotlin), y su hueco es `%d`;
        // con un `Int` de Swift sería `%lld`. Por eso la fila del catálogo
        // se llama «…número %d».
        contenido.body = String(localized: "Descubre quién es el número \(id)")

Los errores del formulario. En iosApp/iosApp/Auth/AuthView.swift hay dos funciones texto(de:) que devuelven un String con literales dentro, y por eso no se traducen. Es el mismo caso. La de la contraseña lleva el mínimo de caracteres dentro: MINIMO_PASSWORD es un Int32 de Kotlin, así que el hueco es otra vez %d, y la clave es Al menos %d caracteres.

AuthView.swift: los textos de error, antes → después
// antes
        case .vacio: "Escribe tu correo"
        default: "Escribe un correo completo, como nombre@correo.com"
        …
        case .vacia: "Escribe tu contraseña"
        default: "Al menos \(AuthFormState.companion.MINIMO_PASSWORD) caracteres"
 
// después
        case .vacio: String(localized: "Escribe tu correo")
        default: String(localized: "Escribe un correo completo, como nombre@correo.com")
        …
        case .vacia: String(localized: "Escribe tu contraseña")
        default: String(localized: "Al menos \(AuthFormState.companion.MINIMO_PASSWORD) caracteres")

El correo que falta. Una última línea de SettingsView.swift, en la sección «Cuenta»: cuando el servidor no da el correo, Ajustes enseña un texto de relleno, que es un String y no se traducía. La fila Sesión iniciada ya está en el catálogo:

SettingsView.swift: el correo, antes → después
// antes
                        LabeledContent("Correo", value: sesion.email ?? "Sesión iniciada")
 
// después
                        LabeledContent("Correo", value: sesion.email ?? String(localized: "Sesión iniciada"))

Compila. Cada clave nueva (Claro, PS, El Pokémon de hoy…) ya estaba en el catálogo que pegaste en el paso 1; las de los números son las tres con hueco.

Paso 4 · El número de la ficha

Otro disfraz del mismo problema. String(format: "N.º %04d", id) tampoco mira el catálogo: coge la cadena que le das. En inglés, la app seguía enseñando «N.º 0001» con toda la interfaz traducida alrededor. La solución es buscar el formato en el catálogo y rellenarlo después. Crea un fichero nuevo, iosApp/iosApp/Core/Numero.swift (la carpeta Core ya existe desde Configuración y secretos por entorno; clic derecho sobre ella → New File from Template… → Swift File):

iosApp/iosApp/Core/Numero.swift
import Foundation
 
/// El número de la ficha, ya traducido.
func numeroDePokedex(_ id: Int32) -> String {
    // `String(localized:)` devuelve el formato del idioma (en inglés,
    // «No. %04d»); `String(format:)` lo rellena con el número.
    // `Int32` porque así llega el `Int` de Kotlin a Swift.
    String(format: String(localized: "N.º %04d"), id)
}

Y en los cuatro sitios que pintaban String(format: "N.º %04d", …), se llama a la función:

Fichero (en iosApp/iosApp)AntesDespués
Detail/PokemonDetailView.swiftText(String(format: "N.º %04d", detalle.id))Text(numeroDePokedex(detalle.id))
Favorites/FavoritesView.swiftText(String(format: "N.º %04d", favorito.id))Text(numeroDePokedex(favorito.id))
List/PokemonCardView.swiftText(String(format: "N.º %04d", pokemon.id))Text(numeroDePokedex(pokemon.id))
List/SearchResultsView.swiftText(String(format: "N.º %04d", ref.id))Text(numeroDePokedex(ref.id))

Compila.

Paso 5 · La frase que lee VoiceOver

En iosApp/iosApp/List/PokemonCardView.swift, la etiqueta de VoiceOver de la tarjeta se armaba concatenando, con ", tipo " y " y " escritos a mano: otra cadena que ningún catálogo ve (se lee «Bulbasaur, tipo Grass y Poison» en un iPhone en inglés). El sistema sabe unir una lista en el idioma del usuario, sin que tengas que traducir la «y»: formatted(.list(type: .and)). Al final de la tarjeta, sustituye el bloque .accessibilityLabel(…) por una etiqueta (el nombre) y un valor (los tipos):

PokemonCardView.swift: la etiqueta de VoiceOver, antes → después
// antes
        .accessibilityLabel(
            pokemon.types.isEmpty
                ? pokemon.name.capitalized
                : "\(pokemon.name.capitalized), tipo \(pokemon.types.map(\.etiqueta).joined(separator: " y "))"
        )
 
// después
        .accessibilityLabel(pokemon.name.capitalized)
        // El sistema une la lista con la «y» o la «and» de cada idioma.
        .accessibilityValue(pokemon.types.map(\.etiqueta).formatted(.list(type: .and)))

VoiceOver lee la etiqueta y después el valor: «Bulbasaur, Planta y Veneno» en español, «Bulbasaur, Grass and Poison» en inglés. En el simulador no puedes encender VoiceOver; en un iPhone de verdad, Ajustes → Accesibilidad → VoiceOver. Compila.

Paso 6 · Los tests de iOS dicen en qué idioma miran

Los tests de interfaz de Pruebas de interfaz en iOS buscan «Ajustes» y «Favoritos»: los textos que ven. (Al arrancar con -uiTests la app empieza siempre con Favoritos vacío y el tema por defecto, aunque tú tengas favoritos o hayas cambiado el tema.) En un simulador en inglés encontrarían «Settings». Se les dice el idioma, ampliando los argumentos de lanzamiento que ya tienen (-uiTests sigue siendo necesario). En iosApp/PokedexUITests/PokedexUITests.swift, en setUp:

PokedexUITests.swift: los argumentos, ampliados
        // `-uiTests` activa las dobles; los otros cuatro fijan el idioma y el
        // formato de la app. Sin ellos, los tests corren en el del simulador.
        app.launchArguments = ["-uiTests", "-AppleLanguages", "(es)", "-AppleLocale", "es_ES"]

-AppleLanguages "(es)" pone el idioma; -AppleLocale es_ES pone el formato de números y fechas, así que el test no depende de la región del simulador (el paso 1b usa los mismos dos, con en).

Provócalo: cambia temporalmente (es) por (en) y ejecuta los tests (Cmd + U): fallan los cuatro: tres con Failed to tap "Ajustes" o Failed to tap "Favoritos", y el de las pestañas con XCTAssertTrue failed, porque la app dice «Settings» y «Favourites». Vuelve a poner (es).

Paso 7 · Comprobarlo todo en inglés

Con la app instalada (Cmd + R), repite la orden del paso 1b. Mira la lista: «No. 0001» en lugar de «N.º 0001»; las pestañas, «Favourites» y «Settings». Abre la ficha de Bulbasaur: las etiquetas dicen «Height», «Weight» y «Base stats», las estadísticas «HP», «Attack» y «Defence», los tipos «Grass» y «Poison», y la descripción dice «A strange seed was planted on its back at birth». Ese texto viene de PokeAPI, no del catálogo: la prueba de que el cambio llegó al módulo compartido. Lo que sigue en español son los avisos de error que construye shared (como «Sin conexión»), que no son de ninguna vista y quedan fuera de esta lección.

Después, en Ajustes, mira la fila del Pokémon del día: dice «Turn on» mientras no esté activada. (Si la activas y concedes el permiso de notificaciones, dirá «7 days scheduled»; el número depende de cuántos avisos queden programados.) Esa fila ya se traducía sola, es la del hueco %lld del paso 1.

Cuatro tests de interfaz, con Cmd + U: en el Test navigator (Cmd + 6) salen los cuatro con una marca verde, y en el registro, Executed 4 tests, with 0 failures.

Punto de control

  • Con la app en inglés, la lista, la ficha y Ajustes están en inglés, incluidos los tipos, «Height», «Weight» y las estadísticas, y la ficha enseña la descripción de PokeAPI en inglés.
  • Ningún texto de la interfaz llega a Text( como variable de tipo String sin pasar por String(localized:) o LocalizedStringKey. Para revisarlo: Cmd + Shift + F, busca Text( y mira los resultados de iosApp/iosApp: los que llevan comillas están bien; si alguno recibe un nombre sin comillas, mira de dónde viene ese texto (el nombre de un Pokémon, que viene de PokeAPI, no se traduce).
  • Con ["-uiTests", "-AppleLanguages", "(es)", "-AppleLocale", "es_ES"], Cmd + U deja los cuatro tests de interfaz de iOS en verde.
  • En Ajustes, la fila del Pokémon del día dice «Turn on» sin activar (y «7 days scheduled» si la activas y concedes el permiso de notificaciones).
  • ./gradlew :shared:jvmTest :shared:testAndroidHostTest sigue en 129 y 91, y el de iOS en 94: esta lección no toca shared.

Comprueba lo que entendiste

7 preguntas · necesitas 5 para completar la lección

  1. 1.En SwiftUI, Text(unaVariableString) ¿se traduce?

  2. 2.La vista Medida recibía let titulo: String y pintaba «Altura» en una app en inglés. ¿Qué la arregla sin cambiar las llamadas Medida(titulo: "Altura", …)?

  3. 3.¿Por qué String(format: "N.º %04d", id) no se traduce aunque «N.º %04d» esté en el catálogo?

  4. 4.¿Por qué la etiqueta de VoiceOver usa formatted(.list(type: .and)) y no joined(separator: " y ")?

  5. 5.En RecordatorioDiario, String(localized: "Descubre quién es el número \(id)") busca la clave Descubre quién es el número %d. ¿Qué pasa con ese %d?

  6. 6.¿Para qué sirve ampliar los argumentos de los tests de interfaz a ["-uiTests", "-AppleLanguages", "(es)", "-AppleLocale", "es_ES"]?

  7. 7.En AuthView, la función texto(de:) devuelve un String con literales dentro y los errores del formulario salen en español con la app en inglés. ¿Qué hace falta para que se traduzcan?

Faltan 7 de 7