Ama Xamarin desde todas las localizaciones

Antes de comenzar, he de decir que este post forma parte del #mesxamarin, en concreto, del mes del amor y la amistad, lo cual es todo un orgullo para mí. Es una iniciativa de Universo Xamarin. Podéis encontrar todos los detalles del mes de febrero en este enlace. Además, encontraréis enlaces a otros 27 post relacionados con Xamarin. Uno por cada día del mes de Febrero. Escritos por unos cuantos apasionados de este framework que utilizamos en el desarrollo de nuestras apps.
En esta ocasión, voy a hablar sobre cómo gestionar varias localizaciones en nuestra aplicación Xamarin. Muchas veces querremos que nuestra app soporte varios idiomas, incluso que muestre distintos recursos visuales en función de la configuración regional del dispositivo. Ambas cosas se pueden conseguir de una manera sencilla siguiendo los pasos que a continuación se detallan.
El objetivo es implementar una aplicación que muestre el texto “Amo Xamarin” y un corazón. Tanto el texto, como el color del corazón se adaptarán a las distintas localizaciones soportadas por la app, tal y como se muestra a continuación.

Añadiendo recurso
Si pretendemos desarrollar una aplicación localizada, no podemos incluir textos hardcodeados, sino que éstos deben estar en un archivo de recursos. Realmente en varios archivos, uno por cada idioma soportado por nuestra app. Siempre que se desee mostrar un texto, se debe hacer accediendo a estos recursos.
Para añadir un archivo de recursos, basta con agregarlo en la librería común de .net standard.

Este archivo va a contener los textos en el idioma estandar, en este ejemplo será el inglés.
Se puede comprobar que se añaden 2 archivos. un XML, en el que se irán introduciendo todos los textos. Y un .cs que da acceso a los textos definidos en el XML
Incluyendo texto como recursos
Una vez que se ha creado el archivo de recursos, el siguiente paso es añadir los texto que mostraremos en nuestra app. En este caso solamente existirá un texto, al que se va a llamar love y cuyo contenido, en inglés, será I love Xamarin.

Si estamos desarrollando una app que tiene muchos textos, puede ser interesante introducir comentarios para cada texto, con el fin de saber para qué se usa cada cadena.
Decir que, desde Visual Studio en windows, no es necesario editar directamente el archivo XML ya que existe un editor que facilita esta tarea.
Creando localizaciones
Hasta ahora, solamente se ha creado un archivo de recurso con el idioma por defecto. Ahora bien, si se quiere soportar varios idiomas, es necesario añadir un archivo de configuración por cada uno de ellos. En este ejemplo, van a ser soportados el Inglés, Francés, Español y Portugués de Brasil. Para conseguirlo, se tienen que añadir 3 archivos de recursos (para el inglés no es necesario añadir archivo por ser el idioma por defecto):
- AppResources.fr.resx: Archivo de recursos en Francés.
- AppResources.es.resx: Archivo de recursos en Español.
- AppResources.pt-BR.resx: Archivo de recursos en Portugués de Brasil.
Llegado a este punto, es necesario indicar de qué manera se crean y cómo se utilizan los archivos de configuración.
- Archivo para un lenguaje y una región específica: Se crea añadiendo 2 caracteres para el idioma (EJ: pt para Portugués) y otros dos para la región(EJ: BR para Brasil), separados por un guión
- Archivo para un lenguaje, sin especificar la región: Se crea añadiendo 2 caracteres para el idioma (EJ: es para Español)
A la hora de buscar el archivo de recursos a utilizar en una configuración regional, se siguen los 3 pasos que se detallan a continuación:
- Se busca un AppResources para el idioma y la región de la configuración regional (EJ: es-ES, para español de España)
- Se busca un AppResource para el idioma, sin especificar la región (EJ: es, para Español)
- Se utiliza el AppResource por defecto.
Tal y como se ha comentado anteriormente, nuestra app soportará el Inglés, Francés, Español y Portugués de Brasil, por lo que se tendrán los siguientes archivos de recursos.

Por defecto, el ámbito de los archivos de recursos es internal, por lo que no los podríamos utilizar en un proyecto distinto. Si se desea, se puede cambiar el ámbito de estos archivos desde el panel de propiedades.
Obteniendo el idioma del dispositivo
Al cargar la aplicación, se debe obtener el idioma del dispositivo, para mostrar los textos correctos. La obtención del idioma se tiene que hacer desde cada plataforma, por lo que es necesario crear una interfaz que será implementada en las plataformas a utilizar (Android e iOS en este ejemplo). En nuestro caso, se ha llamado a la interfaz ILocalizeService, y tiene los siguientes métodos.
public interface ILocalizeService
{
CultureInfo GetCurrentCultureInfo();
void SetLocale(CultureInfo ci);
}
En el arranque de la aplicación, es necesario establecer la configuración regional que se va a utilizar, para ello, utilizando DependecyService, se accede a la implementación de ILocalizaService.
var ci = DependencyService.Get<ILocalizeService>().GetCurrentCultureInfo();
AppResources.Culture = ci;
DependencyService.Get<ILocalizeService>().SetLocale(ci);
Para que el código anterior funcione, es necesario tener una implementación de la interfaz ILocalizeService en cada una de las plataformas. Antes de continuar, se va a añadir la clase PlatformCulture, que será de utilizada en dichas implementaciones para realizar ciertas adaptaciones de algunas propiedades de la configuración regional y hacerlas comprensibles a .NET
public class PlatformCulture
{
public string PlatformString { get; private set; }
public string LanguageCode { get; private set; }
public string LocaleCode { get; private set; }
public PlatformCulture(string platformCultureString)
{
if (String.IsNullOrEmpty(platformCultureString))
throw new ArgumentException("Expected culture identifier", "platformCultureString");
PlatformString = platformCultureString.Replace("_", "-"); // .NET expects dash, not underscore
var dashIndex = PlatformString.IndexOf("-", StringComparison.Ordinal);
if (dashIndex > 0)
{
var parts = PlatformString.Split('-');
LanguageCode = parts[0];
LocaleCode = parts[1];
}
else
{
LanguageCode = PlatformString;
LocaleCode = "";
}
}
public override string ToString()
{
return PlatformString;
}
}
Llegado a este punto, se va a proceder a realizar las implementaciones de ILocalizeService. En iOS, sería la siguiente.
[assembly: Dependency(typeof(LocalizeService))]
namespace XamarinLocalization.iOS.Services
{
public class LocalizeService : ILocalizeService
{
public void SetLocale(CultureInfo ci)
{
Thread.CurrentThread.CurrentCulture = ci;
Thread.CurrentThread.CurrentUICulture = ci;
}
public CultureInfo GetCurrentCultureInfo()
{
var netLanguage = "en";
if (NSLocale.PreferredLanguages.Length > 0)
{
var pref = NSLocale.PreferredLanguages[0];
netLanguage = iOSToDotnetLanguage(pref);
}
// this gets called a lot - try/catch can be expensive so consider caching or something
System.Globalization.CultureInfo ci = null;
try
{
ci = new System.Globalization.CultureInfo(netLanguage);
}
catch (CultureNotFoundException e1)
{
// iOS locale not valid .NET culture (eg. "en-ES" : English in Spain)
// fallback to first characters, in this case "en"
try
{
var fallback = ToDotnetFallbackLanguage(new PlatformCulture(netLanguage));
ci = new System.Globalization.CultureInfo(fallback);
}
catch (CultureNotFoundException e2)
{
// iOS language not valid .NET culture, falling back to English
ci = new System.Globalization.CultureInfo("en");
}
}
return ci;
}
string iOSToDotnetLanguage(string iOSLanguage)
{
var netLanguage = iOSLanguage;
//certain languages need to be converted to CultureInfo equivalent
switch (iOSLanguage)
{
case "ms-MY": // "Malaysian (Malaysia)" not supported .NET culture
case "ms-SG": // "Malaysian (Singapore)" not supported .NET culture
netLanguage = "ms"; // closest supported
break;
case "gsw-CH": // "Schwiizertüütsch (Swiss German)" not supported .NET culture
netLanguage = "de-CH"; // closest supported
break;
// add more application-specific cases here (if required)
// ONLY use cultures that have been tested and known to work
}
return netLanguage;
}
string ToDotnetFallbackLanguage(PlatformCulture platCulture)
{
var netLanguage = platCulture.LanguageCode; // use the first part of the identifier (two chars, usually);
switch (platCulture.LanguageCode)
{
case "pt":
netLanguage = "pt-PT"; // fallback to Portuguese (Portugal)
break;
case "gsw":
netLanguage = "de-CH"; // equivalent to German (Switzerland) for this app
break;
// add more application-specific cases here (if required)
// ONLY use cultures that have been tested and known to work
}
return netLanguage;
}
}
}
Mientras que en Android quedaría de la forma que se muestra a continuación.
[assembly: Dependency(typeof(LocalizeService))]
namespace XamarinLocalization.Droid.Services
{
public class LocalizeService : ILocalizeService
{
public void SetLocale(CultureInfo ci)
{
Thread.CurrentThread.CurrentCulture = ci;
Thread.CurrentThread.CurrentUICulture = ci;
}
public CultureInfo GetCurrentCultureInfo()
{
var netLanguage = "en";
var androidLocale = Java.Util.Locale.Default;
netLanguage = AndroidToDotnetLanguage(androidLocale.ToString().Replace("_", "-"));
// this gets called a lot - try/catch can be expensive so consider caching or something
System.Globalization.CultureInfo ci = null;
try
{
ci = new System.Globalization.CultureInfo(netLanguage);
}
catch (CultureNotFoundException e1)
{
// iOS locale not valid .NET culture (eg. "en-ES" : English in Spain)
// fallback to first characters, in this case "en"
try
{
var fallback = ToDotnetFallbackLanguage(new PlatformCulture(netLanguage));
ci = new System.Globalization.CultureInfo(fallback);
}
catch (CultureNotFoundException e2)
{
// iOS language not valid .NET culture, falling back to English
ci = new System.Globalization.CultureInfo("en");
}
}
return ci;
}
string AndroidToDotnetLanguage(string androidLanguage)
{
var netLanguage = androidLanguage;
//certain languages need to be converted to CultureInfo equivalent
switch (androidLanguage)
{
case "ms-BN": // "Malaysian (Brunei)" not supported .NET culture
case "ms-MY": // "Malaysian (Malaysia)" not supported .NET culture
case "ms-SG": // "Malaysian (Singapore)" not supported .NET culture
netLanguage = "ms"; // closest supported
break;
case "in-ID": // "Indonesian (Indonesia)" has different code in .NET
netLanguage = "id-ID"; // correct code for .NET
break;
case "gsw-CH": // "Schwiizertüütsch (Swiss German)" not supported .NET culture
netLanguage = "de-CH"; // closest supported
break;
// add more application-specific cases here (if required)
// ONLY use cultures that have been tested and known to work
}
return netLanguage;
}
string ToDotnetFallbackLanguage(PlatformCulture platCulture)
{
var netLanguage = platCulture.LanguageCode; // use the first part of the identifier (two chars, usually);
switch (platCulture.LanguageCode)
{
case "gsw":
netLanguage = "de-CH"; // equivalent to German (Switzerland) for this app
break;
// add more application-specific cases here (if required)
// ONLY use cultures that have been tested and known to work
}
return netLanguage;
}
}
}
Traduciendo cadenas de controles Xamarin.Forms
En Xamarin Forms existen algunos controles que muestran texto. Por ejemplo, en un Picker, en una S__earchBar… En Android, estas traducciones se hacen de forma automática, pero en iOS se deben indicar los idiomas soportados en el archivo Info.plist
Utilizando textos de recursos
Para utilizar un recurso desde un archivo .cs, basta con acceder a él a través del AppResources.
loveLabel.Text = AppResources.love;
En cambio, si se quiere acceder a un recurso desde un archivo .xaml, es necesario crear una extensión que nos facilite ese acceso a los recursos. En nuestro ejemplo, se ha llamado a la extensión TranslateExtension
[ContentProperty("Text")]
public class TranslateExtension : IMarkupExtension
{
readonly CultureInfo ci = null;
const string ResourceId = "XamarinLocalization.AppResources";
static readonly Lazy ResMgr = new Lazy(() => new ResourceManager(ResourceId, IntrospectionExtensions.GetTypeInfo(typeof(TranslateExtension)).Assembly));
public string Text { get; set; }
public TranslateExtension()
{
if (Device.RuntimePlatform == Device.iOS || Device.RuntimePlatform == Device.Android)
ci = AppResources.Culture; //DependencyService.Get().GetCurrentCultureInfo();
}
public object ProvideValue(IServiceProvider serviceProvider)
{
if (Text == null)
return string.Empty;
var translation = ResMgr.Value.GetString(Text, ci);
if (translation == null)
{
#if DEBUG
throw new ArgumentException(string.Format("Key '{0}' was not found in resources '{1}' for culture '{2}'.", Text, ResourceId, ci.Name), "Text");
#else
translation = Text; // HACK: returns the key, which GETS DISPLAYED TO THE USER
#endif
}
return translation;
}
}
Una vez creada la extensión, ya se podría acceder a los recursos desde el .XAML tal y como se muestra a continuación
<ContentPage
...
xmlns:translate="clr-namespace:XamarinLocalization.Extensions;assembly=XamarinLocalization">
...
<Label Text="{translate:Translate love}" />
...
</ContentPage>
El amor es más que palabras
Hasta ahora, se ha conseguido tener recursos de texto en varios idiomas, pero es posible que, además de texto, se desee utilizar imágenes diferentes en función de la configuración regional. Tienen en común, que para cada configuración regional soportada, va a existir una carpeta en la que se almacenan los recursos.
Para iOS, el nombre de la carpeta será ln o ln-RG, donde ln es el idioma y RG la región, seguidos de .lproj Para el ejemplo que hemos desarrollado, se tendría la siguiente estructura

Asimismo, es necesario añadir las configuraciones regionales soportadas en el archivo info.plist. A continuación se muestra como quedaría nuestro ejemplo

La manera de tener distintas imágenes en función de la configuración regional en Android es similar, simplemente cambia la nomenclatura de las carpetas que almacenan dicha imágenes. En este caso, el nombre será drawable- seguido de ln o ln-rRG, donde ln es el idioma y RG la región. Es necesario apuntar que, si se especifica la región hay que poner un r delante de ésta. Para nuestro ejemplo, la estructura de carpetas sería la siguiente.

Aunque en este ejemplo solo he incluido una imagen por idioma, lo correcto sería añadir una imagen por cada resolución en cada idioma, tanto en Android como iOS, para así, utilizar la imagen que más se adapte a la resolución del dispositivo.
Con todo lo anterior, tendríamos una app perfectamente localizada, tanto para texto como para imágenes.
Como es habitual, he creado un proyecto en GitHub para ofrecer más detalles de la implementación que he realizado. Espero que os sea de ayuda https://github.com/jorgediegocrespo/XamarinLocalization
Bibliografía
https://docs.microsoft.com/es-es/xamarin/xamarin-forms/app-fundamentals/localization/text?tabs=macos
https://developer.xamarin.com/samples/xamarin-forms/TodoLocalized/
https://docs.microsoft.com/es-es/xamarin/android/app-fundamentals/localization
https://docs.microsoft.com/es-es/xamarin/ios/app-fundamentals/localization/index
https://github.com/xamarin/xamarin-forms-samples/tree/master/UsingResxLocalization