Switching App Language on the Fly in Swift

Dynamically switching languages in views and using snapshot testing for multilingual validation it addresses a common challenge in global app development: ensuring seamless localization. Developers often struggle with updating UI text instantly when users change languages, and validating UI consistency across multiple locales can be tedious.

We will cover dynamic language switching for streamlining the user experience, on the other side snapshot testing ensures visual accuracy without manual verification.

Multilanguage iOS app

After creating an iOS sample we are going to include the language catalog:

Next step is adding a new language:

For this example we have chosen Spanish:

Next step we start to fill in string catalog for all languages defined:

Language controller

For implementing languange controller we will make use of Observer pattern, that means that this class state changes then all views subscribed to it will get notified and will be refreshed automatically:

class LocalizationManager: ObservableObject {
    @Published var locale: Locale = .current {
        didSet {
            UserDefaults.standard.set([locale.identifier], forKey: "AppleLanguages")
            UserDefaults.standard.synchronize()
        }
    }
    
    init() {
        if let preferredLanguage = UserDefaults.standard.array(forKey: "AppleLanguages")?.first as? String {
            locale = Locale(identifier: preferredLanguage)
        }
    }
    
    func toggleLanguage() {
        if locale.identifier == "en" {
            locale = Locale(identifier: "es")
        } else {
            locale = Locale(identifier: "en")
        }
    }
}

This Swift class, LocalizationManager, is an ObservableObject that manages the app’s language settings. It stores the current locale in UserDefaults under the key "AppleLanguages", ensuring that the language preference persists across app restarts. The locale property is @Published, so any UI elements observing it will update when the language changes. The initializer retrieves the stored language preference from UserDefaults or defaults to the system’s current locale. The toggleLanguage() method switches between English ("en") and Spanish ("es") by updating locale, which in turn updates UserDefaults. However, changing this value does not dynamically update the app’s language in real-time without restarting.

Finally build and run application:

Multilanguage view

View implementation is following:

struct ContentView: View {
    @StateObject private var localizationManager = LocalizationManager()
    
    var body: some View {
        VStack {
            Text("welcome_message".localized(with: localizationManager.locale))
                .padding()
            
            Button(action: {
                localizationManager.toggleLanguage()
            }) {
                Text("change_language".localized(with: localizationManager.locale))
                    .padding()
                    .background(Color.blue)
                    .foregroundColor(.white)
                    .cornerRadius(8)
            }
        }
        .environmentObject(localizationManager)
    }
}

This SwiftUI ContentView uses a @StateObject called LocalizationManager to manage language localization. It displays a localized welcome message and a button that allows users to toggle the app’s language. The Text elements use a .localized(with:) function to fetch translated strings based on the current locale from LocalizationManager. When the button is tapped, it calls toggleLanguage(), which presumably switches between different languages. The localizationManager is also injected into the SwiftUI environment using .environmentObject(), making it accessible throughout the app.

Finally, cleaner code on view, and it is a practice that I have seen in many projects is to create an extension for finally  retrieve string translated, but this time with the add on that is parametrized with location language code:

extension String {
    func localized(with locale: Locale) -> String {
        let language = locale.identifier
        guard let path = Bundle.main.path(forResource: language, ofType: "lproj"),
              let bundle = Bundle(path: path) else {
            return NSLocalizedString(self, comment: "")
        }
        return bundle.localizedString(forKey: self, value: nil, table: nil)
    }
}

The extension adds a localized(with:) function to the String type, allowing it to return a localized version of the string based on the specified Locale. It first retrieves the locale’s language identifier and attempts to find the corresponding .lproj resource bundle in the app’s main bundle. If the bundle exists, it fetches the localized string from it; otherwise, it falls back to NSLocalizedString, which returns the string from the app’s default localization. This enables dynamic localization based on different languages.

Snapshot testing

Last but not least, apart from implementing a user interface that allows user to switch language when something is not propery understood on app regular usage. It opens up a new tesging scenario, we are possible to validate the same view presented in different languages, this will you make easier to detect texts that were not presented properly. 

In Visual Regression Testing: Implementing Snapshots test on iOS post I present how to setup and implement snapshoptesting, is very easy setup. So basically we will bypass snapshot setup library and we will focus just only on sntapshots tests:

@Suite("Snapshot tests")
struct DynamicLanguageTests {

    let record = true

    @Test func testContentViewInEnglish() {
            let localizationManager = LocalizationManager()
            localizationManager.locale = Locale(identifier: "en")
            
            let contentView = ContentView()
                .environmentObject(localizationManager)
                .frame(width: 300, height: 200)
            
            let viewController = UIHostingController(rootView: contentView)
            
            assertSnapshot(
                of: viewController,
                as: .image(on: .iPhoneSe),
                named: "ContentView-English",
                record: record
            )
        }

    @Test  func testContentViewInSpanish() {
            let localizationManager = LocalizationManager()
            localizationManager.locale = Locale(identifier: "es")
            
            let contentView = ContentView()
                .environmentObject(localizationManager)
                .frame(width: 300, height: 200)
            
            let viewController = UIHostingController(rootView: contentView)
            
            assertSnapshot(
                of: viewController,
                as: .image(on: .iPhoneSe),
                named: "ContentView-Spanish",
                record: record
            )
        }
}

The DynamicLanguageTests struct, annotated with @Suite("Snapshot tests"), contains two test functions: testContentViewInEnglish() and testContentViewInSpanish(). Each function sets up a LocalizationManager with a specific locale (en for English, es for Spanish), creates a ContentView instance, embeds it in a UIHostingController, and captures a snapshot of the rendered UI on an iPhone SE device. The snapshots are named accordingly («ContentView-English» and «ContentView-Spanish») and compared against previously recorded images to detect unintended visual changes.

Conclusions

Dynamic Language Switch it could be an interesting implementation for allowing user swap view language for beter understaning., but also is more useful when we have to validate that a view is properly presente in all app languages, you can find the source code used for this post in the following repository

Copyright © 2024-2025 JaviOS. All rights reserved