🔍

SwiftPM上のSwiftUI - PreviewでStringをlocalizeする

2025/01/15に公開

はじめに

SwiftUI で Text を使っている場合には、.environment(\.locale, .init(identifier: "ja")) を指定すれば Preview 上でも問題なくローカライズを確認できます。しかし、Stringinit(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 ディレクトリを直接読み込み、ローカライズを実現する方法があります。

ワークアラウンドの概要

  1. .xcstrings などで管理しているローカライズファイルは、ビルド後に [locale].lproj 配下の .strings ファイルとして出力される
  2. Locale に応じて適切なバンドルを動的に読み込み、String(localized:…bundle:…locale:…) に渡す
  3. SwiftUI の Preview で実行しているかどうかを環境変数 XCODE_RUNNING_FOR_PREVIEWS で判定
  4. 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