はじめに

String Catalogで設定した言語をアプリ内の操作で切り替え、Viewに反映する方法を書いてみました。

String Catalogを実装したアプリは通常、OSの設定画面から言語を変更できます。しかし、変更時にそのアプリがバックグラウンドで動作していると、強制終了してしまうという仕様があります。

強制終了させずにアプリ内で変更する方法を調べました。参考になれば幸いです。

String Catalogの詳細やファイルの作成方法については割愛しております。また、String Catalogファイルはプロジェクト直下に配置しています。

String Catalogを言語別に用意

String Catalogファイル(.xstrings)を作成し、必要な言語を追加します。

今回は日本語、中国語を追加しました。

選択中の言語を管理するEnumを定義

アプリで切り替える言語タイプのEnumクラスを定義します。

import Foundation

enum LanguageType: CaseIterable {
    case english, japanese, chinese
    
    var locale: Locale {
        switch self {
        case .english: Locale(identifier: "en")
        case .japanese: Locale(identifier: "ja")
        case .chinese: Locale(identifier: "zh-HK")
        }
    }
}

今回は言語選択にPickerを用いる関係上、CaseIterableプロトコルに準拠させてます。

対応する言語のLocaleクラスを渡すための変数locale: Localeを用意します。Localeクラスの引数に、各言語のLocale ID(String型)を渡します。Locale IDはString Catalogで追加する言語を選ぶ際、各言語の末尾の()の中に記載されています。

Locale.availableIdentifiersLocale IDの一覧を取得することもできます。ただし、国と地域の組み合わせにより細かく設定されており、全て出力してみると相当な数のIDがあります。対応する言語の数が少ない場合は、言語選択時にメモしておくほうが楽でしょう。

for id in Locale.availableIdentifiers {
    print(id)    
}

ss_ZA
en_DE
zgh_MA
mi
lu_CD
...
...
...
...

選択中の言語タイプを保持するObservableクラスを作成

アプリ内で言語を切り替えるため、選択中の言語を保持するクラスをObservableマクロで定義します。

import Foundation

@Observable
final class LanguageState {
    var type: LanguageType = .english
}

Observableクラスを環境変数としてViewにセットする

import SwiftUI

@main
struct TestApp: App {
    // Observableクラスを保持
    @State var languageState = LanguageState() 
    
    var body: some Scene {
        WindowGroup {
            LanguagePickerView()
                // 環境値に設定
                .environment(languageState) 
        }
    }
}

アプリのエントリーポイントの構造体で作成したObservableクラスを保持し、State装飾子でラップして変更監視状態にします。WindowGroupでラップしているViewに.environmentを追加し、それをセットします。

View側で言語切り替え機能を実装

import SwiftUI

struct LanguagePickerView: View {
    // 環境変数として保持
    @Environment(LanguageState.self) var languageState 
        
    var body: some View {
        // View側の変更を監視する
        @Bindable var bindableState = languageState
        
        VStack(spacing: 20) {
            
            // 言語選択Picker
            Picker("Pick Language", selection: $bindableState.type) {
                Text("English").tag(LanguageType.english)
                Text("Japanese").tag(LanguageType.japanese)
                Text("Chinese").tag(LanguageType.chinese)
            }
            .pickerStyle(.palette)
            .frame(width: 300)
            
            // テスト用Text
            Text("Airplane")
                .font(.system(size: 64))
        }
        // テキストを表示するViewに対して環境値を設定
        .environment(\.locale, languageState.type.locale) 
    }
}

Environment装飾子をつけて環境変数をプロパティで保持します。

body内部でViewを生成する際、ObservableクラスのプロパティをBindable装飾子をつけた変数に渡します。その変数をBindすることでViewからの変更を監視するようにします。

Pickerを配置し、言語を選択できるようにします。クロージャ内のTexttagを付与し、言語タイプのEnumの各caseを設定します。そうすることで、一意のデータとして認識し、選択した言語タイプに更新することができます。

言語変更を受信したいViewに、選択中の言語タイプを環境値としてセットします。

以下のように親Viewに対して環境値をセットすると、変更されるたびにView自体が初期化されてしまい、その分イニシャライザが実行されてしまうため不具合の原因になります。テキストを表示する子Viewに付けた方が良いです。

WindowGroup {
    LanguagePickerView()
        // 言語変更のたびにLanguagePickerViewが初期化される
        .environment(\.locale, languageState.type.locale) 
}

String Catalogに登録する

Buildが成功すればString Catalogにアプリ内で定義したテキストがリストに追加されます。

Keyに対応するテキストを入力しましょう。

再度Buildして確認してみましょう。

Pickerで定義した言語を変更すると、対応した言語に各テキストが更新されます。

プロパティの文字列データをローカライズ対象テキストとして認識させる

例えばあるデータのプロパティをローカライズ対象テキストとして認識させてString Catalogに登録したいとします。
データモデルItemを作り、String型のnameプロパティを定義します。

import Foundation

struct Item {
    let name: String
}

Itemをリストで表示するViewを作ります。

import SwiftUI

struct ItemListView: View {
    
    @Environment(LanguageState.self) var languageState
    // Itemデータのリスト
    private let items: [Item] = [
        Item(name: "Pencil"),
        Item(name: "Eraser"),
        Item(name: "Pencil Case"),
    ]
    
    var body: some View {
        Form {
            // リストで表示
            ForEach(0..<items.count, id: \.self) { index in
                VStack(alignment: .leading) {
                    Text(items[index].name)
                        .font(.system(size: 36))
                }
                .padding(20)
            }
        }
    }
}

このViewを先ほどの画面に追加します。

この状態でBuildしてもnameにセットされてあるPencil(鉛筆)/Eraser(消しゴム)/Pencil Case(筆箱)はString Catalogに追加されません。

nameプロパティをStringからLocalizedStringKeyに変更します。

import Foundation

struct Item {
    let name: LocalizedStringKey // 型変更
}

ここでBuildしましょう。nameにセットされてあるPencil(鉛筆)/Eraser(消しゴム)/Pencil Case(筆箱)がString Catalogに追加されます。日本語、中国語のテキストを入れてテストしてみましょう。

無事反映されます。

当然ですが、API通信等で受け取るデータのパターンが無限にある場合はカタログに追加できないため、レスポンスデータに各言語のテキストを含めてもらう必要があります。

ロジック内で変換する

ロジック内で処理される文字列をString Catalogに追加する場合、LocalizedStringKeyではなくString.LocalizationValueを使用します。

この例では、すでに定義してあるnameプロパティに別の文字列を連結し、それをローカライズした状態でViewに返すロジックを書いてみます。

import SwiftUI

struct Item {
    // LocalizedStringKeyから変更
    let name: String.LocalizationValue 
    // 連結用のテキスト
    static let descriptionText: String.LocalizationValue = "is Good Item!!!" 
}

extension Item {
    // タイトル
    func title(type: LanguageType) -> String {
        // Stringの拡張メソッドを使って指定の言語にローカライズされた文字列を返す
        let localizedNameString = String(localized: name, locale: type.locale)
        return localizedNameString
    }
    // 説明文
    func description(type: LanguageType) -> String {
        // Stringの拡張メソッドを使って指定の言語にローカライズされた文字列を返す
        let localizedDescriptionString = String(localized: Self.descriptionText, locale: type.locale)
        // titleに連結して返す
        return title(type: type) + " " + localizedDescriptionString
    }
}
extension String {
    // LocalizedStringResourceを使ってStringを生成する
    init (localized: LocalizationValue, locale: Locale) {
        if #available(iOS 16, *) {
            self.init(
                localized: LocalizedStringResource(
                    localized,
                    locale: locale,
                    bundle: .atURL(Bundle.main.bundleURL)
                )
            )
        } else {
            self.init(localized: localized, bundle: Bundle.main)
        }
    }
}

LocalizedStringKeyからLocalizationValueに変えています。LocalizedStringKeyではロジック内で選択中の言語の文字列を取得できないため変更が必要です。LocalizationValueでも同じようにString Catalogに追加されます。

Stringに追加したカスタムメソッドは、iOS16から使用できるLocalizedStringResourceをString生成時の引数に渡してます。

LocalizedStringResourceは初期化時に引数locale: を渡すことができ、Localeクラスに対応する言語のテキストがString Catalogに定義されていたら対応したテキストにローカライズしてStringを生成できます。

タイトルの文字列と説明文の文字列をそれぞれローカライズ済みのStringに変換してViewに返します。Stringで返すため、そのままViewに渡すことができます。

struct ItemListView: View {
    
    @Environment(LanguageState.self) var languageState
    
    private let items: [Item] = [
        Item(name: "Pencil"),
        Item(name: "Eraser"),
        Item(name: "Pencil Case"),
    ]
    
    var body: some View {
        Form {
            ForEach(0..<items.count, id: \.self) { index in
                VStack(alignment: .leading) {
                    // タイトル
                    Text(items[index].title(type: languageState.type))
                        .font(.system(size: 36))
                    // 説明文
                    Text(items[index].description(type: languageState.type))
                        .font(.system(size: 20))
                }
                .padding(20)
            }
        }
    }
}

なぜ既存のString(localized:)を使わないのか?

既存のString(localized:)にも引数にlocal:があり、Localeクラスを渡すことができます。

しかし、なぜかこのメソッドを使用してローカライズをしても、アプリのプライマリ言語(OSの設定から変更できる優先言語)が反映されてしまいます。

Zennの記事(執筆者: Kyome様)を参考にしました。

このAPIはlocalizedに指定されたフォーマット込みの文字列に埋め込まれる変数にDateなどローカライズ要素のあるものが含まれていた際に指定したLocaleが活用される仕様らしいです。

同記事に対応コードが書いておりましたが、String Catalogのpath取得に失敗するため別の方法を探しました。

LocalizedStringResource

LocalizedStringResourceはiOS16から実装された構造体で、Documentを読むと、

Use LocalizedStringResource to provide localizable strings with lookups you defer to a later time.

When you create a localized string or a localized attributed string with an initializer that takes String.LocalizationValue, those initializers lookup the localized string immediately.

If you want to perform the lookup at a later time, use this LocalizedStringResource type to refer to the localizable strings.

Then, when you need to perform localization, create a String or AttributedString from an initializer that takes a LocalizedStringResource parameter, such as:

(翻訳文)LocalizedStringResource を使用して、検索を後で延期するローカライズ可能な文字列を提供します。String.LocalizationValue を受け取る初期化子を使用してローカライズされた文字列またはローカライズされた属性付き文字列を作成すると、それらの初期化子はローカライズされた文字列をすぐに検索します。後で検索を実行する場合は、この LocalizedStringResource タイプを使用してローカライズ可能な文字列を参照します。次に、ローカリゼーションを実行する必要がある場合は、次のように LocalizedStringResource パラメーターを取るイニシャライザーから String または AttributedString を作成します。String Catalogでローカライズを管理する場合はLocalizedStringResourceでStringを生成するようにしましょう。

このうち、

When you create a localized string or a localized attributed string with an initializer that takes String.LocalizationValue, those initializers lookup the localized string immediately.

(翻訳文)String.LocalizationValue を受け取る初期化子を使用してローカライズされた文字列またはローカライズされた属性付き文字列を作成すると、それらの初期化子はローカライズされた文字列をすぐに検索します。

という文章で『すぐに検索』と記載があり、おそらくアプリビルド時に設定されている言語でローカライズされているため、後からLocaleデータを渡しても反映されないのかな、と推測します。

LocalizedStringResourceは生成時に渡したLocaleデータでローカライズしてくれる仕様なのでアプリ操作中でも反映してくれます。

String Catalogでローカライズを管理する場合はLocalizedStringResourceでStringを生成するようにしましょう。



ギャップロを運営しているアップフロンティア株式会社では、一緒に働いてくれる仲間を随時、募集しています。 興味がある!一緒に働いてみたい!という方は下記よりご応募お待ちしております。
採用情報をみる