Xamarin.Formsで多言語化

多言語化専門フリーランスエンジニアの@chooyan_engです。

今回は`Xamarin.Forms`を使ったアプリの多言語化に挑戦しようと思ったところ、テキストの言語切り替えだけでも意外と手順が多い＆ドキュメント通りだと少し分かりづらい部分がある、という経験をしましたので、"New Solution"から多言語化が動作するところまでを振り返りつつ、`Xamarin.Forms`アプリの多言語化の方法についてまとめたいと思います。

# 前提
* @chooyan_engはXamarin歴、C#歴ともに1週間足らずです。
* Javaが多めです
* Androidアプリも結構やってます

というわけで、XamarinやC#についての記述には誤りが含まれることが予想されます。
記述内容に気になる点がありましたら是非コメントか修正リクエストをいただければと思います！

# 環境
* macOS Sierra (10.12.4)
* Visual Studio for Mac Community版 (7.0.1)

# 参考
基本的には以下の公式ドキュメントに従って進めます。
[Localizing Xamarin.Forms Apps with RESX Resource Files - Xamarin](https://docs.microsoft.com/ja-jp/xamarin/xamarin-forms/app-fundamentals/localization/)

追加で、上記ドキュメントで紹介されている以下のリポジトリを適宜参考にします。
[xamarin-forms-samples/UsingResxLocalization/ - GitHub](https://github.com/xamarin/xamarin-forms-samples/tree/master/UsingResxLocalization)

また、この記事で説明した内容の結果は以下に上げています。
https://github.com/chooyan-eng/LocalizationSample

# 本文
## ソリューションの作成
まずはソリューションを作成します。
のですが、ここでいきなり注意事項です。作成するテンプレートの種類は、__必ず"Blank Forms App"を選んでください。"Forms App"ではありません。__
また、次の画面の"Shared Code"の欄で"Use Portable Class Library"を指定する必要があります。

なぜかというと、"Blank Forms App"の方は上記の指定をすることで共通ソース部分が"Portable Class Library(PCL) として作成される一方、"Forms App"は常に"Shared Project"として作成されます。

僕もちゃんと理解できているわけではないのですが、"Shared Project"だと、後述する `AppResources.resx` ファイルを生成したときに、セットで必要になる `AppResources.Designer.cs` ファイルの自動生成がされません。

[Shared Project - Xamarin 3 の新しいコード共有テクニック : XLsoft ](https://www.xlsoft.com/jp/products/xamarin/shared_projects.html)の、以下の記述あたりがその理由かな、と思います。（推測）

> Shared Project 自体はコンパイルされません。Shared Project は、単純に、他のプロジェクトに含めることができるソース コード ファイルの一つとして存在します。 他のプロジェクトで参照する場合、プロジェクトの 一部 としてコードを効果的にコンパイルします。Shared Projects は、他のプロジェクト タイプを参照しません (他の Shared Projects を含む)。

いずれにしても、ドキュメント通りに多言語化を進める場合は、共通部分はPCLとして作成する必要がありそうです。
（Shared Projectで作成した場合に多言語化できるかどうかは別途検証してみようと思います。）

あとは項目に従ってソリューション作成を完了させます。
完了したら、一度Android、iOSそれぞれで起動してみて正常に動作するかを確認しておきましょう。

## `AppResources.resx`ファイルの作成
ソリューションが作成できたら、次はリソースファイルの作成です。
公式ドキュメント的には[Adding Resources](https://docs.microsoft.com/ja-jp/xamarin/xamarin-forms/app-fundamentals/localization/text?tabs=macos#adding-resources)のあたりです。

まず、PCLプロジェクトの直下に`Resx`というディレクトリを作成します。ドキュメントには書いていませんが、参考にしているUsingResxLocalizationソリューションの方ではそうしていたのでそれに倣います。ここに、リソースファイルをまとめていきます。

次に、以下の手順でデフォルト言語となる`AppResources.resx`ファイルを作成します。

1. `Resx`ディレクトリを右クリック
2. 「追加」 ＞ 「新しいファイル」
3. 「Misc」 ＞ 「リソースファイル」 をクリック
4. ファイル名に「`AppResources`」を入力（拡張子は不要です）
5. 「新規（N)」をクリック

こうすることで、`AppResources.resx`ファイルと、`AppResources.Designer.cs`ファイルが自動生成されるはずです。
※ 前述の通り、"Shared Project"で作成されているとここで`AppResources.Designer.cs`ファイルが自動生成されません。

## `AppResources.resx`に文字列の定義を追加
`AppResources.resx`ファイルは、自動生成された状態では翻訳対象となるテキストが定義されていないため、`</root>`の前に以下を追加します。

```xml:AppResources.resx
<data name="HelloLocalization" xml:space="preserve">
    <value>Hello! Localization!</value>
    <comment>Sample text for localization</comment>
</data>
```

上記の `HelloLocalization` はこのテキストを呼び出す際のタグになります。また、`value`は実際に表示する文字列になります。`comment`はコメントです。

## 翻訳ファイル `AppResources.ja.resx` の追加
公式ドキュメントの[Language-Specific Resources](https://docs.microsoft.com/ja-jp/xamarin/xamarin-forms/app-fundamentals/localization/text?tabs=macos#language-specific-resources)のあたりです。

次に、言語設定ごとの翻訳ファイルを以下の手順で作成します。（`AppResources.resx`の作成方法とは違うので注意してください）

1. `Resx`ディレクトリを右クリック
2. 「追加」 ＞ 「新しいファイル」
3. __「XML」 ＞ 「空のXMLファイル」 をクリック__
4. __ファイル名に「`AppResources.ja.resx`」を入力（拡張子も入力します）__
5. 「新規（N)」をクリック

`AppResources.ja.resx`ファイルが生成されるので、__先ほど作成した`AppResources.resx`の内容を全てコピーして上書きペーストします。__

その上で、上記の `HelloLocalization` の `<value>` 内を日本語に翻訳します。

```xml:AppResources.ja.resx
<data name="HelloLocalization" xml:space="preserve">
    <value>多言語化するぜ！</value>
    <comment>Sample text for localization</comment>
</data>
```

## 翻訳ファイルのビルドアクションを`EmbeddedResource`に設定する
ここで、以下の設定を忘れずにやる必要があります。（僕はこれを見落としていてだいぶハマりました）

> These language-specific resources files do not require a .designer.cs partial class so they can be added as regular XML files, with the __Build Action: EmbeddedResource__ set.

手順は以下の通りです。

1. `AppResources.ja.resx`ファイルを右クリック
2. 「ビルドアクション」 ＞ 「EmbeddedResource」 をクリック

他の言語ファイルについても同様です。（デフォルトの`AppResources.resx`ファイルについては不要です）

ここまでできれば、 `AppResources.HelloLocalization` と書けばプログラム中で言語ごとの文字列が取得できるようです。ただし、今回は `.xaml` ファイル内での多言語化をゴールとしているため、もう一手間あります。（というかここからが面倒臭いです）

## 端末の言語設定を反映させる（共通プロジェクト部分）
公式ドキュメントの[Displaying the Correct Language](https://docs.microsoft.com/ja-jp/xamarin/xamarin-forms/app-fundamentals/localization/text?tabs=macos#displaying-the-correct-language)のあたりです。

まず、共通プロジェクトの直下に `ILocale.cs' インターフェースを追加します。
これを実装した `Locale.cs` クラスをAndroid、iOS各プラットフォームのプロジェクトに定義することで、ロケールの記述方法など、プラットフォーム固有の事情を吸収します。

また、実装クラスで `PlatformCulture` という、"ja-JP"などのロケール文字列を言語を表す"ja"と国を表す"JP"に分割して保持するクラスを共通で利用するため、このクラスについても `ILocale.cs` ファイルに一緒に記述してしまいます。

コードは以下のようになります。

```C#:ILocale.cs

using System;
using System.Globalization;

namespace LocalizationSample
{
    public interface ILocalize
    {
        CultureInfo GetCurrentCultureInfo();
        void SetLocale(CultureInfo cl);
    }

	public class PlatformCulture
	{
		public PlatformCulture(string platformCultureString)
		{
			if (String.IsNullOrEmpty(platformCultureString))
			{
				throw new ArgumentException("Expected culture identifier", "platformCultureString"); // in C# 6 use nameof(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 string PlatformString { get; private set; }
		public string LanguageCode { get; private set; }
		public string LocaleCode { get; private set; }
		public override string ToString()
		{
			return PlatformString;
		}
	}
}

```

※ 4行目の `namespace` 部分はソリューション名に合わせて修正してください。

次に、このクラスを使って実際に言語設定をセットする以下のコードを、共通プロジェクトの `App.xaml.cs` クラスのコンストラクタに追記します。Appクラスに記述することによって、アプリ起動時に必ず現在の言語設定が反映されるようになります。

```C#:App.xaml.cs
using Xamarin.Forms;

namespace LocalizationSample
{
    public partial class App : Application
    {
        public App()
        {
            InitializeComponent();
			if (Device.OS == TargetPlatform.iOS || Device.OS == TargetPlatform.Android)
			{
				var ci = DependencyService.Get<ILocalize>().GetCurrentCultureInfo();
				Resx.AppResources.Culture = ci; // set the RESX for resource localization
				DependencyService.Get<ILocalize>().SetLocale(ci); // set the Thread for locale-aware methods
			}
            MainPage = new LocalizationSamplePage();
        }

        protected override void OnStart()
        {
            // Handle when your app starts
        }

... 省略 ...

    }
}
```

※ 3行目の `namespace` 部分はソリューション名に合わせて修正してください。

## 端末の言語設定を反映させる（プラットフォーム毎のプロジェクト部分）
公式ドキュメントの[Platform-Specific Code](https://docs.microsoft.com/ja-jp/xamarin/xamarin-forms/app-fundamentals/localization/text?tabs=macos#platform-specific-code)のあたりです。

先ほど作成した `ILocalize.cs` インターフェースを実装した `Localize.cs` クラスを各プラットフォームで定義します。

まず、iOSは以下の通りです。.iOSプロジェクトの直下に作成してください。

```C#:Localize.cs
using System;
using System.Globalization;
using System.Threading;
using Foundation;

[assembly: Xamarin.Forms.Dependency(typeof(LocalizationSample.iOS.Localize))]

namespace LocalizationSample.iOS
{
	public class Localize : LocalizationSample.ILocalize
	{
		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;
		}
	}
}
```

※ 6, 8, 10行目の `namespace` 部分などはソリューション名に合わせて修正してください。

次に、Androidです。 .Droidプロジェクトの直下に作成してください。

```C#:Localize.cs
using System;
using Xamarin.Forms;
using System.Threading;
using System.Globalization;

[assembly:Xamarin.Forms.Dependency(typeof(LocalizationSample.Droid.Localize))]

namespace LocalizationSample.Droid
{
	public class Localize : LocalizationSample.ILocalize
	{
		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;
		}
	}
}

```

※ 6, 8, 10行目の `namespace` 部分などはソリューション名に合わせて修正してください。

Android, iOSいずれのコードも、ネイティブのクラスからロケール情報を取得し、.NETがサポートしていないロケールを丸めたり、区切り文字として使われる `-` と `_` の違いを修正したりしているみたいですね。

これで、プラットフォームごとのロケールの扱いの違いを吸収できるようになりました。

## XAMLファイル内で多言語化できるようにする

最後の工程です。
公式ドキュメントの[Localizing XAML](https://docs.microsoft.com/ja-jp/xamarin/xamarin-forms/app-fundamentals/localization/text?tabs=macos#localizing-xaml)の部分です。

まず、`XAML`ファイルから多言語化のために呼び出す処理を以下の `TranslateExtensin.cs` ファイルに記述します。ファイルはXAMLファイルと同じ階層に置いてください。（つまり今回は共通プロジェクトの直下）

```C#:TranslateExtensin.cs
using System;
using System;
using System.Globalization;
using System.Reflection;
using System.Resources;
using Xamarin.Forms;
using Xamarin.Forms.Xaml;

namespace LocalizationSample
{
	// You exclude the 'Extension' suffix when using in Xaml markup
	[ContentProperty("Text")]
	public class TranslateExtension : IMarkupExtension
	{
		readonly CultureInfo ci;
		const string ResourceId = "LocalizationSample.Resx.AppResources";

		public TranslateExtension()
		{
			if (Device.OS == TargetPlatform.iOS || Device.OS == TargetPlatform.Android)
			{
				ci = DependencyService.Get<ILocalize>().GetCurrentCultureInfo();
			}
		}

		public string Text { get; set; }

		public object ProvideValue(IServiceProvider serviceProvider)
		{
			if (Text == null)
				return "";

			ResourceManager resmgr = new ResourceManager(ResourceId
								, typeof(TranslateExtension).GetTypeInfo().Assembly);

			var translation = resmgr.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;
		}
	}
}
```

※ 9, 16行目の `namespace` 部分などはソリューション名に合わせて修正してください。

最後に、多言語化したいページのXAMLファイルを修正します。
以下の記述をXAMLファイルの`<ContentPage>`に追記してください。

```xml:
xmlns:i18n="clr-namespace:LocalizationSample;assembly=LocalizationSample"
```
※ LocalizationSampleの部分２箇所はソリューション名に合わせて修正してください

結果はこんな感じになります。

```xml:LocalizationSamplePage.xaml
<ContentPage 
    xmlns="http://xamarin.com/schemas/2014/forms" 
    xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" 
    xmlns:local="clr-namespace:LocalizationSample" 
    x:Class="LocalizationSample.LocalizationSamplePage"
    xmlns:i18n="clr-namespace:LocalizationSample;assembly=LocalizationSample">
```

最後に、表示したいテキスト部分に `{i18n:Translate HelloLocalization}` で記述します。

```xml:LocalizationSamplePage.xaml
<Label 
    Text="{i18n:Translate HelloLocalization}" 
    VerticalOptions="Center" 
    HorizontalOptions="Center" />
```

これで対応は完了です。
アプリを起動し、端末の言語設定を日本語や英語、その他の言語に切り替えながら動作確認してみてください。

# まとめ
ドキュメントには他にも、iOSが自動で翻訳してくれちゃうコンポーネントの制御方法や、Windowsアプリでの多言語化のやり方、画像ファイルの切り替え方などが記載されています。
この記事では分量の関係でAndorid, iOSアプリでテキストを多言語化する部分にしか触れていませんが、必要に応じて公式ドキュメントを参照していただければと思います。

全体を通して試行錯誤してみて、Android Javaでは言語別のリソースフォルダ（`values-ja`など）を定義して、その各フォルダに`strings.xml`を配置すれば勝手に多言語化してくれたのと同じノリでいけるのかと思って手をだしてみたら、だいぶ痛い目を見てしまいました。。。

おそらく各プラットフォームの差分をちゃんと埋めて共通コードで多言語化しようとすると、いろいろとやらなければならないことが増えるということなのだと思います

まだ記載したクラスの処理はあまり追えていませんので、それぞれなぜあんな長い処理を入れなければならないのかを、C#の勉強も兼ねて調べていければと思います。