Most iOS apps rely entirely on the system language selected by the user. If someone wants to use the application in another language, they typically need to leave the app, open Settings, change the preferred language, and then relaunch the application.
While this approach works, it creates unnecessary friction.
In one of my app Invoice Factory, I wanted users to be able to select their preferred language directly from the app's Settings screen and see the entire interface update immediately without restarting the application.
This article explains the architecture I used, how it works with SwiftUI and PointFree's TCA, and some important implementation details I discovered while building it.
Assume that localization has already been configured in the project, supported languages have been added, and String Catalogs (.xcstrings) are already in place.
The focus of this article is runtime language switching.
The requirements were straightforward:
The end result looks simple from the user's perspective:
No restart required.
By default, SwiftUI localizes content based on the current system locale.
Locale.currentThis works well when the language never changes while the app is running.
However, when users can change the language inside the app, the localization system must react dynamically.
The challenge becomes:
Rather than scattering localization keys throughout the codebase, Invoice Factory uses a centralized constants file.
Example:
public struct LocalizedStrings {
public struct DashboardView {
public static var title: LocalizedStringResource {
LocalizedStringResource(
"dashboard_view.title",
defaultValue: "Dashboard",
table: "Localizable",
locale: localizationLocale,
comment: "Title for dashboard screen"
)
}
}
}Usage becomes:
Text(LocalizedStrings.DashboardView.title)Benefits include:
One feature I particularly like about LocalizedStringResource is that Xcode automatically discovers these localization keys during compilation.
For example:
LocalizedStringResource(
"dashboard_view.title",
defaultValue: "Dashboard"
)Building the application automatically adds the key to the String Catalog.
This significantly reduces manual maintenance and helps keep localization resources synchronized with the codebase.
You may notice that every localized string resource receives a locale parameter:
LocalizedStringResource(
"dashboard_view.title",
defaultValue: "Dashboard",
table: "Localizable",
locale: localizationLocale,
comment: "Title for dashboard screen"
)The locale is defined as:
extension LocalizedStrings {
/// Locale matching the user-selected app language.
public static var localizationLocale: Locale {
if let raw = UserDefaults.standard.string(forKey: "appLanguage") {
return Locale(identifier: raw)
} else if let code = Locale.current.language.languageCode?.identifier {
return Locale(identifier: code)
} else {
return Locale(identifier: "en")
}
}
}This implementation plays a crucial role in supporting runtime language changes.
At first glance it may seem reasonable to write:
static let localizationLocale = Locale.currentor:
static let localizationLocale =
Locale(identifier: "en")The problem is that static let values are initialized only once during the application's lifetime.
After the user changes language:
English → Hindithe locale stored in the constant would still be the original value.
Newly created localization resources could continue using the old locale.
This would break runtime language switching.
Using a computed property:
public static var localizationLocale: Localeensures the locale is recalculated every time it is accessed.
When the user changes language, the selected value is already stored in UserDefaults.
The next time SwiftUI accesses a localized string resource, the latest locale is used automatically.
This keeps localization synchronized with the user's current language selection.
There are some tradeoffs worth mentioning.
Every access performs a UserDefaults lookup.
UserDefaults.standard.string(
forKey: "appLanguage"
)In practice this overhead is negligible because:
UserDefaults is highly optimized.For most applications, the performance impact is effectively zero.
The localization layer now depends on UserDefaults.
This makes the implementation slightly less pure than dependency injection.
However, for localization infrastructure this is usually an acceptable tradeoff and keeps the implementation simple.
A static let would technically be more efficient because the locale is only created once.
Unfortunately, it would also prevent runtime language switching from working correctly.
In this scenario correctness is far more important than a micro-optimization.
Next, I created an enum representing every language supported by the application.
enum AppLanguage: String, CaseIterable, Codable, Equatable {
case arabic = "ar"
case danish = "da"
case german = "de"
case english = "en"
case spanishSpain = "es-ES"
case spanishMexico = "es-MX"
case french = "fr"
case gujarati = "gu"
case hebrew = "he"
case hindi = "hi"
case indonesian = "id"
case italian = "it"
case japanese = "ja"
case korean = "ko"
case portugueseBrazil = "pt-BR"
case russian = "ru"
case thai = "th"
case ukrainian = "uk"
case vietnamese = "vi"
case chinese = "zh"
case simplifiedChineseChina = "zh-CN"
case simplifiedChinese = "zh-Hans"
case traditionalChinese = "zh-Hant"
case traditionalChineseHongKong = "zh-HK"
case traditionalChineseTaiwan = "zh-TW"
var locale: Locale {
return Locale(identifier: self.rawValue)
}
var displayName: String {
let locale = Locale(identifier: self.rawValue)
return locale.localizedString(forIdentifier: self.rawValue) ?? self.rawValue
}
var layoutDirection: LayoutDirection {
isRightToLeft ? .rightToLeft : .leftToRight
}
var isRightToLeft: Bool {
return ["ar", "he", "fa", "ur"].contains(self.rawValue)
}
}This enum becomes the source of truth for all supported languages.
Each language exposes its locale.
var locale: Locale {
Locale(identifier: self.rawValue)
}This locale will later be injected into SwiftUI's environment.
For the language picker I wanted each language to appear in its native form.
var displayName: String {
let locale = Locale(identifier: self.rawValue)
return locale.localizedString(
forIdentifier: self.rawValue
) ?? self.rawValue
}Examples:
English
Deutsch
हिन्दी
العربية
ગુજરાતીThis creates a much better user experience than displaying raw language codes.
Localization isn't only about translated text.
Languages such as Arabic and Hebrew require the interface layout to be mirrored.
var layoutDirection: LayoutDirection {
isRightToLeft
? .rightToLeft
: .leftToRight
}RTL detection:
var isRightToLeft: Bool {
["ar", "he", "fa", "ur"]
.contains(rawValue)
}This allows the UI to automatically adapt to RTL languages.
Invoice Factory uses PointFree's swift-sharing library:
extension SharedKey where Self == AppStorageKey<AppLanguage>.Default {
static var appLanguage: Self {
Self[
.appStorage("appLanguage"),
default: Helpers.getSystemLanguage()
]
}
}This keeps the language synchronized across the application.
If you are not using TCA or swift-sharing, SwiftUI's built-in AppStorage works perfectly well.
@AppStorage("appLanguage")
private var appLanguage = "en"The storage mechanism itself is not important.
The key requirement is persistence.
On first launch, the application attempts to match the user's system language.
static func getSystemLanguage() -> AppLanguage {
let supportedLanguageCodes = Bundle.main.localizations
guard let currentSystemLanguageCode =
Locale.current.language.languageCode?.identifier
else {
return .english
}
if supportedLanguageCodes.contains(currentSystemLanguageCode) {
return AppLanguage(
rawValue: currentSystemLanguageCode
) ?? .english
}
return .english
}The logic is simple:
This ensures predictable behavior for every user.
When a user selects a new language:
state.$appLanguage.withLock {
$0 = selectedLanguage
}The new value is immediately persisted.
The next step is telling SwiftUI about the change.
The most important piece of the architecture happens at the SwiftUI View.
struct DashboardView: View {
var body: some View {
VStack {
Text(LocalizedStrings.DashboardView.title)
}
.environment(\.locale, store.appLanguage.locale)
.environment(\.layoutDirection, store.appLanguage.layoutDirection)
}
}Whenever the selected language changes:
This is what enables live language switching.
One of the biggest advantages of this approach is that the locale only needs to be injected once at the root of the application.
RootView()
.environment(\.locale, store.appLanguage.locale)
.environment(\.layoutDirection, store.appLanguage.layoutDirection)
.id(store.appLanguage)SwiftUI environment values automatically propagate down the view hierarchy.
This means child views automatically inherit:
\.localeand
\.layoutDirectionfrom their parent views.
For example:
NavigationStack {
DashboardView()
}
.environment(\.locale, store.appLanguage.locale)Inside DashboardView:
Text(LocalizedStrings.DashboardView.title)No additional environment injection is required.
The view automatically uses the currently selected language.
The same applies to:
In most cases, setting the locale at the root of the application is sufficient for the entire UI.
During development, I noticed that certain sheet presentations did not always pick up the updated locale automatically after a language change.
For example:
.sheet(isPresented: $store.shouldOpenCreateInvoice.sending(\.openCreateInvoice), content: {
if let store = self.store.scope(
state: \.createInvoiceViewState,
action: \.createInvoiceeViewAction
) {
CreateInvoiceView(store: store)
.environment(\.locale, store.appLanguage.locale)
.environment(\.layoutDirection, store.appLanguage.layoutDirection)
}
})Depending on how the sheet is constructed and when it is presented, the localized content may not refresh as expected.
In these situations, explicitly passing the environment values to the sheet resolved the issue.
If you notice a sheet continuing to display strings in the previous language after a language change, this is one of the first places I would investigate.
For normal navigation and child views:
.environment(\.locale, ...)
.environment(\.layoutDirection, ...)should only be applied once at the application's root view.
For independently presented interfaces such as:
I recommend testing language switching carefully and explicitly forwarding the environment if necessary.
In Invoice Factory, this approach has proven reliable and keeps localization logic centralized rather than scattered throughout the application.
During testing I discovered some UI elements occasionally retained old localized content.
The solution was attaching an identifier to the root view.
.id(store.appLanguage)Example:
RootView()
.environment(\.locale, store.appLanguage.locale)
.environment(\.layoutDirection, store.appLanguage.layoutDirection)
.id(store.appLanguage)Whenever the language changes, SwiftUI recreates the view hierarchy.
This refreshes:
and guarantees a clean transition.
Native SwiftUI controls automatically localize when given a LocalizedStringResource.
Text(LocalizedStrings.DashboardView.title)No additional work is required.
SwiftUI automatically resolves the string using the current locale.
One important edge case involves strings that must become concrete String values.
For example, alerts:
state.alert = .warningAlert(
title: String(
localized: LocalizedStrings.General.delete
),
message: String(
localized:
LocalizedStrings.Common.deleteWarning
)
)Using:
String(localized:)ensures the latest language is used.
This is particularly useful for:
Whenever a plain String is required, prefer explicit localization.
After locale injection, SwiftUI automatically updates:
The framework does most of the heavy lifting for you.
Because layout direction is injected through the environment:
.environment(\.layoutDirection, store.appLanguage.layoutDirection)SwiftUI automatically mirrors many interface elements for RTL languages.
This includes navigation structures, stack layouts, and various built-in controls.
The amount of custom RTL code required becomes surprisingly small.
A common concern is whether rebuilding the hierarchy is expensive.
In practice:
.id(store.appLanguage)only triggers when the user explicitly changes language.
This is an infrequent operation.
The tradeoff strongly favors correctness and consistency over micro-optimizations.
For Invoice Factory, the performance impact is effectively negligible.
There are several ways to implement runtime language switching in iOS applications.
Some solutions rely on method swizzling, custom localization managers, or manually loading bundles at runtime. While these approaches can work, they often introduce additional complexity and move away from SwiftUI's built-in localization system.
For Invoice Factory, I wanted a solution that:
By combining LocalizedStringResource, SwiftUI environments, and a centralized language state, the implementation remains simple while still supporting instant language switching across the entire application.
The complete flow looks like this:
User selects language
↓
Save language to UserDefaults
↓
Locale environment changes
↓
Layout direction changes
↓
Root view ID changes
↓
SwiftUI rebuilds hierarchy
↓
Localized resources read latest locale
↓
Entire UI updates instantlyNo relaunch.
No hacks.
No private APIs.
Just SwiftUI environments working exactly as intended.
Implementing runtime language switching in SwiftUI turned out to be much simpler than I originally expected.
The key ingredients are:
.id().In Invoice Factory, this approach allows users to switch between more than twenty languages and immediately see the entire application update without leaving the current screen or restarting the app.
The resulting experience feels native, responsive, and fully aligned with modern user expectations while remaining built entirely on top of SwiftUI's localization infrastructure.
Thank you for reading. If you have any suggestions or a better approach, feel free to connect with me on X and send me a DM. If you enjoyed this article and would like to support me, Buy me a coffee.