SwiftPM上のSwiftUI - PreviewでStringをlocalizeする
はじめに
SwiftUI で Text
を使っている場合には、.environment(\.locale, .init(identifier: "ja"))
を指定すれば Preview 上でも問題なくローカライズを確認できます。しかし、String
の init(localized:bundle:locale:)
を使ったローカライズになると、SwiftPM で作成した SwiftUI プレビューでは反映されないケースがあります。
例えば、パブリックに定義した文字列をローカライズして使いたいときなど、Text
ではなく String
で処理したい場合に遭遇することがあるかと思います。現状のところ、SwiftPM の SwiftUI Preview 上で String
のローカライズが壊れてしまう問題が報告されており、次のようなワークアラウンドを使うことで対応が可能です。
背景
- SwiftPM で SwiftUI のプレビューを利用している
-
.environment(\.locale, .init(identifier: "ja"))
を指定してもString
でのローカライズが効かない - 通常のアプリ開発であれば
@Environment(\.locale) private var locale
を受け取りString.init(localized:bundle:locale:)
に渡すことで解決できるが、SwiftPM 上の Preview に限りローカライズが正しく動作しないバグがある
この問題は将来的に修正されると見込まれていますが、それまでは以下のような実装で .lproj
ディレクトリを直接読み込み、ローカライズを実現する方法があります。
ワークアラウンドの概要
-
.xcstrings
などで管理しているローカライズファイルは、ビルド後に[locale].lproj
配下の.strings
ファイルとして出力される -
Locale
に応じて適切なバンドルを動的に読み込み、String(localized:…bundle:…locale:…)
に渡す - SwiftUI の Preview で実行しているかどうかを環境変数
XCODE_RUNNING_FOR_PREVIEWS
で判定 - Preview 実行時は上記で読み込んだバンドルを使い、通常実行時は
.module
をそのまま使う
デモコード例
ここでは、プロジェクト特有の型を用いず、より一般的な列挙型 MyUnit
として例示します。実際の実装時にはご自身のアプリに合わせた型名やキー文字列に修正してください。
import SwiftUI
import Foundation
/// ユニットを表すサンプルの列挙型
enum MyUnit {
case unitA
case unitB
}
/// MyUnit のローカライズ用実装
extension MyUnit {
@MainActor
public func localizedString(locale: Locale = .current) -> String {
switch self {
case .unitA:
return __localize("unitA_key", locale: locale)
case .unitB:
return __localize("unitB_key", locale: locale)
}
}
}
/// `String.init(localized:bundle:locale:)` の `locale` が
/// SwiftUI Preview に対応していない問題へのワークアラウンド
@MainActor
func __localize(_ key: String.LocalizationValue, locale: Locale) -> String {
__Localizer.shared.localize(key, locale: locale)
}
@MainActor
final class __Localizer {
static let shared = __Localizer()
/// SwiftUI Preview 実行かどうかの判定に利用
private static let RUNNING_ON_PREVIEW =
ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1"
/// ローカライズバンドルをキャッシュする
var bundleTable: [String: Bundle] = [:]
/// 与えられたキーとロケールに対して、適切なバンドルから文字列を取得
func localize(_ key: String.LocalizationValue, locale: Locale) -> String {
if __Localizer.RUNNING_ON_PREVIEW {
// Preview 実行時: Locale に対応した .lproj バンドルを手動で指定
let previewBundle = self.bundle(for: locale)
return String(localized: key, bundle: previewBundle, locale: locale)
} else {
// 通常実行時: SPM が提供する .module を使用
return String(localized: key, bundle: .module, locale: locale)
}
}
/// 指定したロケールに対応するバンドルを取得
private func bundle(for locale: Locale) -> Bundle {
if let cache = self.bundleTable[locale.identifier] {
return cache
}
guard let path = Bundle.module.path(forResource: locale.identifier, ofType: "lproj"),
let localizedBundle = Bundle(path: path) else {
return .module
}
self.bundleTable[locale.identifier] = localizedBundle
return localizedBundle
}
}
使い方の例
struct MyUnitPreview: View {
@Environment(\.locale) var locale
var body: some View {
// SwiftUI の Text だと自動でロケールを拾うが、
// String 側でも意図的にロケールを渡してローカライズをしてみる
let localizedA = MyUnit.unitA.localizedString(locale: locale)
let localizedB = MyUnit.unitB.localizedString(locale: locale)
return VStack(spacing: 8) {
Text("Preview Locale: \(locale.identifier)")
Text("String for unitA: \(localizedA)")
Text("String for unitB: \(localizedB)")
}
}
}
struct MyUnitPreview_Previews: PreviewProvider {
static var previews: some View {
MyUnitPreview()
.environment(\.locale, .init(identifier: "ja"))
.previewDisplayName("日本語")
MyUnitPreview()
.environment(\.locale, .init(identifier: "en"))
.previewDisplayName("English")
}
}
このように、SwiftPM での SwiftUI Preview 実行時だけ、手動で .lproj
配下のバンドルをロードしてあげることで、String
のローカライズを正しく動作させることができます。もちろん、通常のアプリ実行時には String.init(localized:bundle:locale:)
がそのまま機能するため、追加の処理は不要です。
まとめ
Text
なら SwiftUI の環境ロケールを自然に拾って簡単にローカライズできる-
String
でローカライズしたい場合、特に SwiftPM + SwiftUI Preview での実行時には現状バグがあり、そのままではローカライズが反映されない -
.lproj
フォルダを直接読み込み、XCODE_RUNNING_FOR_PREVIEWS
で条件分岐することで回避可能
将来的にはこの問題は修正されると予想されますが、それまでは上記のようなワークアラウンドを採用してみてください。
Discussion