SwiftUI 应用内切换 app 语言

Published on
Authors
Table of Contents

Xcode 14.3 iOS 16.4 macOS 13.3

源码:swiftui-change-language

示例效果:

保存和更新语言选项

App 语言的国际化是由系统设置的语言决定的,SwiftUI 中凡是用到字符串的地方,基本都支持 LocalizedStringKey,我们无需额外设置,就能很好地实现国际化。

如果要实现应用内语言切换,首先需要保存用户当前选择的语言。使用 @AppStorage 是一个不错的选择,它不仅能够保存用户的偏好设置,又能够驱动视图更新。

AppState.swift
final class AppState: ObservableObject {

  @AppStorage("language") var language = "en"
}

AppState 用于存储 app 的全局状态,这里我们只保存了用户选择的语言。在 app 的根视图注入它就能在任意视图上读写 app 的状态,这非常方便:

swiftui-change-language.swift
@main
struct swiftui_change_languageApp: App {
  @StateObject private var appState = AppState()

  var body: some Scene {
    WindowGroup {
      rootView
        .environmentObject(appState)
    }
  }

  private var rootView: some View {
    TabView {
    	// ...
    }
  }
}

在切换语言的视图中,通过 AppState 获取当前的语言设置的语言并高亮该选项,在选择不同的语言是,更新 appState 中的 language 变量时,会同时保存该值。

ChangeLanguageView
struct ChangeLanguageView: View {
  @EnvironmentObject private var appState: AppState

  private let languages = [
    "English": "en",
    "中文": "zh-Hans"
  ]

  var body: some View {
    Form {
      ForEach(Array(languages.keys), id: \.self) { v in
        LabeledContent(v) {
          Image(systemName: "checkmark.circle.fill")
            .foregroundColor(.accentColor)
            .opacity(isSelected(v) ? 1 : 0)
        }
        .contentShape(Rectangle())
        .onTapGesture {
          if isSelected(v) { return }
          appState.language = languages[v]!
        }
      }
    }
    .navigationTitle("Language".localized)
    .navigationBarTitleDisplayMode(.inline)
  }

  private func isSelected(_ language: String) -> Bool {
    appState.language == languages[language]
  }
}

如上代码中的 "en" 和 "zh-Hans" 对应的是国际化语言文件夹的名称前缀,后面会用到。

关于语言文件名称的查看,可以选中工程中的 Localizable.strings 文件右键 Show in Finder 查看,也可以直接选中相应的文件,在 Xcode 中查看。

更新本地化内容

我们已经在 appState 中保存了当前选择的语言("en" 或 "zh-Hans"),接下来就是找到对应的文件并获取相应的文本内容。为了便于调用,我们可以为 String 添加一个扩展属性:

Extensions
extension String {

  var localized: String {
    let res = UserDefaults.standard.string(forKey: "language")
    let path = Bundle.main.path(forResource: res, ofType: "lproj")
    let bundle: Bundle
    if let path = path {
      bundle = Bundle(path: path) ?? .main
    } else {
      bundle = .main
    }
    return NSLocalizedString(self, bundle: bundle, value: "", comment: "")
  }
}

上面的 ChangeLanguageView 文件中已经展示了其用法:"Language".localized

当切换语言时,ChangeLanguageView 的导航栏确实立即更新了,可是两个 tab 视图并没有更新。因为 .localized 扩展并不具备驱动视图更新的能力。

一种很容易想到的思路是封装一个 LocalizedText 视图,获取环境变量 appState 并更新相应的文本内容。但使用起来较为繁琐,而且 SwiftUI 中有些视图只支持 String 类型的参数,LocalizedText 无能为力。

还有一种更简单的办法,那就是在 app 中已经存在于视图树中却无法监听到语言改变的视图内作如下声明:

HomeView
struct HomeView: View {
  // AppState holds global application settings and triggers view refreshes upon changes.
  @EnvironmentObject private var appState: AppState

  var body: some View {
    VStack {
      Text("Home".localized)
    }
  }
}

虽然我们并没有使用 @EnvironmentObject 属性 appState,但 HomeView 可以监听到它的状态改变从而更新视图。

其它

虽然 SwiftUI 有着高效的 diffing 算法,视图的频繁刷新一般不会产生很大的性能开销。如果确实需要优化性能,建议将上面的 AppState 中关于语言偏好设置的属性拆分到另一个类中,减少不必要的视图更新。

此外,虽然通过声明 @EnvironmentObject 属性 appState 能够解决问题,但仍然稍显繁琐,笔者期待有更好的方案实现应用内语言切换。

twitterDiscuss on Twitter