SwiftUI Localization Guide - Change Language Without Restarting the App

June 6, 2026

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 Goal

The requirements were straightforward:

  • Allow users to change the app language from within the app.
  • Persist the selected language.
  • Update the UI immediately.
  • Support both left-to-right (LTR) and right-to-left (RTL) languages.
  • Avoid application restarts.
  • Work naturally with SwiftUI's localization system.

The end result looks simple from the user's perspective:

  1. Open Settings.
  2. Select a language.
  3. Watch the entire application update instantly.

No restart required.


Understanding the Challenge

By default, SwiftUI localizes content based on the current system locale.

Locale.current

This 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:

  • Persisting the selected language.
  • Updating SwiftUI's locale environment.
  • Refreshing existing views.
  • Supporting RTL layouts.
  • Ensuring localized string resources use the latest language.

Creating a Centralized Localization Layer

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:

  • Type-safe localization keys.
  • Centralized management.
  • Better discoverability.
  • Easier maintenance.
  • Built-in documentation through comments.

Automatic String Catalog Generation

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.


A Subtle but Important Detail: Dynamic Locale Resolution

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.


Why Not Use a Static Constant?

At first glance it may seem reasonable to write:

static let localizationLocale = Locale.current

or:

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 → Hindi

the 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.


Why a Computed Property Works

Using a computed property:

public static var localizationLocale: Locale

ensures 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.


Tradeoffs of Using a Computed Property

There are some tradeoffs worth mentioning.

Additional UserDefaults Reads

Every access performs a UserDefaults lookup.

UserDefaults.standard.string(
    forKey: "appLanguage"
)

In practice this overhead is negligible because:

  • UserDefaults is highly optimized.
  • Language changes are infrequent.
  • Localization resources are lightweight.

For most applications, the performance impact is effectively zero.

Dependency on Global State

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.

Correctness Over Optimization

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.


Defining Supported Languages

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.

Locale Support

Each language exposes its locale.

var locale: Locale {
    Locale(identifier: self.rawValue)
}

This locale will later be injected into SwiftUI's environment.

Localized Language Names

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.

Supporting Right-to-Left Languages

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.


Persisting the Selected Language

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.

Determining the Initial Language

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:

  1. Read the current system language.
  2. Check if the app supports it.
  3. Use it if available.
  4. Otherwise fallback to English.

This ensures predictable behavior for every user.


Updating the Language

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.


Injecting Locale into SwiftUI Views

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:

  • Locale updates.
  • Layout direction updates.
  • SwiftUI reevaluates localized content.

This is what enables live language switching.

SwiftUI Environment Automatically Flows Through the View Hierarchy

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:

\.locale

and

\.layoutDirection

from 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:

  • Navigation destinations
  • Child views
  • Nested containers
  • Lists
  • Forms
  • Sections
  • Custom reusable views

In most cases, setting the locale at the root of the application is sufficient for the entire UI.

An Interesting Exception: Sheets

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.

My Rule of Thumb

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:

  • Sheets
  • Full-screen covers
  • Some custom presentation containers

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.


Forcing a Complete Refresh

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:

  • Navigation titles
  • Toolbars
  • Sheets
  • Forms
  • Cached layouts

and guarantees a clean transition.


Localizing Native SwiftUI Components

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.

Handling Dynamic Strings

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:

  • Alerts
  • Confirmation dialogs
  • Reducer state
  • View models
  • Custom UI components

Whenever a plain String is required, prefer explicit localization.


What Updates Automatically?

After locale injection, SwiftUI automatically updates:

  • Text
  • Button
  • Label
  • Picker
  • Menu
  • Form
  • List
  • Navigation title
  • Section header
  • Toolbar content

The framework does most of the heavy lifting for you.


RTL Support Comes Almost for Free

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.


Performance Considerations

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.


Why I Chose This Approach

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:

  • Uses Apple's localization infrastructure.
  • Works naturally with String Catalogs.
  • Supports RTL languages.
  • Integrates cleanly with TCA.
  • Requires minimal code inside feature views.
  • Avoids application restarts.

By combining LocalizedStringResource, SwiftUI environments, and a centralized language state, the implementation remains simple while still supporting instant language switching across the entire application.


Final Architecture

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 instantly

No relaunch.

No hacks.

No private APIs.

Just SwiftUI environments working exactly as intended.


Conclusion

Implementing runtime language switching in SwiftUI turned out to be much simpler than I originally expected.

The key ingredients are:

  • A centralized localization layer.
  • Dynamic locale resolution through a computed property.
  • A language enum that acts as the source of truth.
  • Persisted user preferences.
  • Locale environment injection.
  • Layout direction injection.
  • Root view recreation using .id().
  • Explicit localization for dynamic strings.

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.