Switching App Language at Runtime in SwiftUI
From half-broken to first-class: making runtime language switch work
Localization is a fantastic feature that allows for a lot of flexibility in SwiftUI, but it can seem... mystical, when it comes to changing your app's language at runtime.
In standard scenarios, SwiftUI takes the language from the app (which follows the system) and for most people, that is fine.
Trouble happens when you want to have a selectable language switch for your users.
There isn't much documentation on this, so I will share what is working, and what doesn't work, as well as why.
I will explain why the "obvious" approach is a partial failure, and then I will walk through minimal changes to provide fixes, and to keep your architecture clean.
The “obvious” approach — and why it only half works
So you might get off on the right track:
@AppStorage("language") private var language: String = "en"
// Somewhere in your root view:
someView
.environment(\.locale, Locale(identifier: language))
This definitely seems like the correct way to go. The SwiftUI locale is an environment value, so changing that should propagate and refresh the localized text. (Apple holds that EnvironmentValues.locale is read by SwiftUI for view localization.)
And it does - for some SwiftUI views. Then you will hit cases where some text will not change no matter what you do. Here is why.
1) Only Text backed by LocalizedStringKey auto-localizes
A string literal like Text("hello_key") becomes a LocalizedStringKey automatically, so it looks up translations and responds to .locale changes.
A runtime string like let s = "hello_key"; Text(s) calls the StringProtocol initializer — SwiftUI assumes you aren’t localizing it and shows the raw value.
To make the runtime key localizable explicitly:
Text(LocalizedStringKey(s)) // now responds to locale changes
Apple has documented this behavior for LocalizedStringKey, and it has been a topic of discussion in the community.
2) .environment(\.locale, …) only affects the SwiftUI tree
SwiftUI environment values are specific to SwiftUI, and propagate down the SwiftUI view tree. UIKit or third party views are not reading the SwiftUI environment and will not update the language when you change the locale; you need to manage those labels yourself.
3) Keys must exist in your resources
If the key doesn't exist in your String Catalog or Localizable.strings, it will display as given from your variable. With Xcode 15+, String Catalogs are the best way to manage this.
What to do practically
The good news is you can keep your "set a language code + update the environment" pattern, and make up for what can not be done in SwiftUI.
Ensure your strings are actually localizable
Put user-facing text in your String Catalog (or Localizable.strings).
Try to use sentence keys, and allow parameters for pluralization and grammar.
Example key: "greeting_user %@" = "Hello, %@!"
(Apple's String Catalog documentation, and WWDC), have helpful references.
When you are using Text, wrap any non literal text in LocalizedStringKey
let key = "hello_key" // from server, config, etc.
Text(LocalizedStringKey(key)) // will localize and react to \.locale
This is the minimal change, when the source is not a literal.
For non-SwiftUI text (UIKit/third-party), localize via NSLocalizedString with a specific bundle
When you exit SwiftUI (UILabel for example), you want to localize by needing to specifically select the bundle for the user-chosen language.
Step 1: persist the langcode
enum Language: String { case en, zhHans = "zh-Hans" /* ... */ }
@AppStorage("language") private var languageCode: String = Language.en.rawValue
Step 2: resolve a bundle for that code
private func bundle(for languageCode: String) -> Bundle {
// Try "<code>.lproj" inside main bundle; fall back to main if missing.
if let path = Bundle.main.path(forResource: languageCode, ofType: "lproj"),
let bundle = Bundle(path: path) {
return bundle
}
return .main
}
Step 3: add a little String helper
extension String {
/// Localizes the receiver in the bundle for the currently selected language.
/// Usage: "Food".localized()
func localized(_ arguments: CVarArg...) -> String {
let fmt = NSLocalizedString(self,
tableName: nil,
bundle: bundle(for: UserDefaults.standard.string(forKey: "language") ?? "en"),
value: "",
comment: "")
return String(format: fmt, arguments: arguments)
}
}
Now your UIKit code can do:
titleLabel.text = "Food".localized()
This uses NSLocalizedString(_:tableName:bundle:value:comment:) with a custom bundle (not a language string), which is the key to looking up translations in a specific .lproj.
Note: Some of the older "change AppleLanguages in UserDefaults" tricks do not take effect until app restarts, and are not needed when you pass an explicit bundle.
Putting it all together
Persist the user's choice:
@AppStorage("language") private var languageCode: String = "en"
Apply it to SwiftUI:
RootView()
.environment(\.locale, Locale(identifier: languageCode))
Audit your SwiftUI text:
Literals (Text("key")) are fine.
Variables: use Text(LocalizedStringKey(key)).
Bridge any non-SwiftUI text with the String.localized(...) helper which selects the correct bundle automatically.
This covers all three of the "why didn't it update?" buckets: SwiftUI literals vs. runtime strings, environment scoping, and missing resources.
Final thoughts
Changing languages at runtime for SwiftUI is not magic. It just works when your text is localizable, your views are SwiftUI-aware, and you provide UIKit the correct bundle. If you miss any of those requirements, things break in confusing and non-intuitive ways.
That is the entire game.
Apple will continue to improve localization APIs, particularly with String Catalogs - so remain nimble. Don't spend hours chasing a solution for why half your text is updating and half is not - you now know the rules and can make your language switcher feel first class.
And don't forget: good localization is not just about checking things off in the App Store. It's a signal of care. For your users, being addressed in their own language is a key concrete indicator of what makes an app feel polished, and human.

