Dynamic localization in SwiftUI on the fly
In this article we explore SwiftUI friendly solution for setting the default language during an application’s first-time launch and for changing the localization on the fly, without the need to restart the app or the navigation stack.
As more iOS developers shift towards adopting SwiftUI, they occasionally encounter setbacks due to the framework limitations in implementing complex solutions, particularly in production applications. And this is one of the issues that may arise at different points in the lifecycle of developing a complex application.
The problem
Typically, a multi-language iOS application automatically force adopts the language settings of the user’s iPhone, configurable via the iPhone’s Language & Region settings. That would be okay, but what if our application supports multiple languages and we still want some specific language to be displayed on the first launch (regardless the region settings)? And Xcode not providing native options for that? 🙁 Later on, the user decides to change the language — what we do? Transfer the user to settings and restart the application? Or provide an in-app dropdown with languages and reinstate / reset the navigation stack? What a mess and we are on SwiftUI now! 😩 There must be an easier way!
With hours of browsing various Stack Overflow, Reddit threads and Apple Developer forums, I have found multiple solutions. Some of them worked, some of them not. And no, force setting a specific language as “Default” (or as a “development language”) in Xcode won’t work in this case — it serves a different purpose during the development and testing of an iOS app. In other words, your production app won’t be affected by this setting.
We need to find some clever solutions.
With that said, I want to share a lightweight SwiftUI approach for default language force and easy language change — a solution making whole localization flow easily customizable and smooth as butter.
Creating localizations
When you add multiple localizations in your project, Xcode creates variations of existing Strings files under your project tree accordingly.
For example, if you were to add an additional Lithuanian language localization, Xcode would offer to add another Strings file specifically for that language. That’s what we want to start with.
Newly added localizations would show up under your project’s settings.
Strings file would also have different languages variations (different .lproj folders if we take a look onto it via Finder).
Adding texts
Next, we add appropriate keys and values for different languages texts we want to include in our application. Use the following format:
"Key" = "Value";
With the localizations setup there’s nothing much else to do.
So let’s dig into how to build a bridge between the plain key-value pairs and your code.
Enumerating all the languages
We create an enumeration of all available languages with a raw value of String type. Raw values should be the language codes for each case (e.g. Lithuanian – “lt”, English – “en”, English (United Kingdom) – “en-GB”).
This will determine what .lproj localization bundle path your app will be using for plain key-value pairs extraction.
public enum SelectedLanguage: String {
case lithuanian = "lt"
case english = "en"
}
Language selection manager
Time to write a manager class that would have all the logic for managing the language selection.
It would provide options for saving and fetching the selected language.
public class LocalizationManager {
// MARK: - Variables
public static let shared = LocalizationManager()
public var language: SelectedLanguage {
get {
guard let languageString = UserDefaults.standard.string(forKey: "selectedLanguage") else {
saveLanguage(.lithuanian)
return .lithuanian // First-time users would see Lithuanian language
}
// In other cases, a language saved in UserDefaults would be returned instead
return SelectedLanguage(rawValue: languageString) ?? .lithuanian
} set {
if newValue != language {
saveLanguage(newValue) // Here we update the language
}
}
}
// MARK: - Init
public init() { }
// MARK: - Methods
private func saveLanguage(_ language: SelectedLanguage) {
UserDefaults.standard.setValue(language.rawValue, forKey: "selectedLanguage")
UserDefaults.standard.set([language.rawValue], forKey: "AppleLanguages") // Set the device's app language
UserDefaults.standard.synchronize() // Optional as Apple does not recommend doing this
}
}
For the first-time app users, in getter, UserDefaults would not have any idea about the value for the key selectedLanguage. The expression in guard statement will fail — so it would execute what’s inside of the guard statement — set the default language. In our case — Lithuanian language would be the first for user to encounter when we hadn’t set any language before.
In all other cases, when we have already set (via setter) the application’s language before — it would use what’s been saved under the selectedLanguage key via UserDefaults. For example, if we had set French as application’s language (via setter) later on, it would always return (via getter) French until we change to a new one.
Also, here a singleton creates one instance while providing a shared global point of access to localization manager instance. But still.. do not abuse singletons 🙃
Key-value pairs extraction
A globally accessible String extension method is needed for plain key-value pairs extraction straight into the code.
extension String {
public func localized(_ language: SelectedLanguage) -> String {
guard let path = Bundle.main.path(forResource: language.rawValue, ofType: "lproj"),
let bundle = Bundle(path: path) else {
print("Failed to find path for language: \(language.rawValue)")
return self
}
return NSLocalizedString(self, bundle: bundle, comment: "")
}
}
It would request .lproj type bundle path with the name of the bundle as a language code (e.g. “lt.lproj” or “en-GB.lproj”), and pass the bundle for conversion via NSLocalizedString.
NSLocalizedString returns a localized string from a table that Xcode generates for you when exporting localizations. “self” in the snippet is the key of what value to look for in the Strings file.
We will call this method in a similar way like the following:
keyToValueInStringsFile.localized(language)
That’s how real-life example would look like:
var body: some View {
Text("Common.close".localized(language))
}
..when our Strings file has this as a key-value pair (out of many other pairs):
...
"Common.close" = "Close";
...
We are not finished here as language variable is not declared yet.
(optional suggestion — you can skip this part but would be a great addition for your project) It becomes even more efficient if you automate the process of incorporating your keys into a structure using some custom Python code, and integrate this code into the Run Script phase for automatic execution with each incremental build.
Below I provide a working sample of Python code that would generate structures of keys from plain key-values pairs but adjust it to your needs.
import os
def parse_input(input_path):
with open(input_path, 'r') as f:
lines = f.readlines()
translations = {}
for line in lines:
line = line.strip()
if not line.startswith('"') or '=' not in line:
continue
key, value = line.split('=', 1)
key = key.strip('" ')
value = value.strip(' ";')
translations[key] = value
return translations
def build_translation_tree(translations):
tree = {}
for key, value in translations.items():
parts = key.split('.')
node = tree
for part in parts:
if part not in node:
node[part] = {}
node = node[part]
node['value'] = key
return tree
def to_struct_name(name):
parts = name.split('.')
if len(parts) > 1:
return parts[-2]
return parts[0]
def generate_swift_code(t, translations, depth=1):
code = ""
indentation = ' ' * depth
for key, subtree in t.items():
if key == 'value':
continue
if 'value' in subtree:
code += f'{indentation}public static var {key}: String {{\n'
code += f'{indentation} return "{subtree["value"]}"\n'
code += f'{indentation}}}\n'
else:
struct_name = to_struct_name(key)
code += f'{indentation}public struct {struct_name} {{\n'
code += generate_swift_code(subtree, translations, depth + 1)
code += f'{indentation}}}\n'
return code
def main():
input_file_path = './pathTo/xx.lproj/Localizable.strings'
output_file_path = './pathTo/Strings.swift' # Create an empty file at first, Python will auto-generate the contents
translations = parse_input(input_file_path)
translations_tree = build_translation_tree(translations)
output = 'public struct Strings {\n'
for key, subtree in translations_tree.items():
struct_name = to_struct_name(key)
output += f' public struct {struct_name} {{\n'
output += generate_swift_code(subtree, translations, 2)
output += ' }\n'
output += '}\n'
with open(output_file_path, 'w') as f:
f.write(output)
if __name__ == "__main__":
main()
The generated Strings.swift file:
For our Python script to be executed, we need to add a new Run Script phase with the command to call the .py
file:
/usr/bin/python3 "${SRCROOT}/generate_strings.py"
${SRCROOT}
means that the python script is in the root folder of the project, and the script file itself is named generate_strings.py
.
For this Run Script phase to execute Python script, make sure it is moved to the top of the build phases, and that the option Based on dependency analysis is unchecked.
In case you are experiencing an “Operation not permitted” error, please set User Script Sandboxing
to NO
under your project’s Build Settings.
Then, after the successful build, we would use the key not as a whole string but access it within an auto-generated structure instead:
var body: some View {
Text(Strings.Common.close.localized(language))
}
Monitoring the language changes: First method
This is the first method of possible implementation of monitoring the language changes and injecting the language into localization extraction.
While we need to monitor the language changes, we also need to perform SwiftUI views redraw with the new texts after the change. This is a vital part of whole customizable localizations flow.
That’s where a SwiftUI friendly storage access with AppStorage comes — a property wrapper type that reflects a value from UserDefaults and invalidates the views on a change in value in that user default.
In each view that would have texts with the localization feature, we need to declare the following:
@AppStorage("selectedLanguage") private var language = LocalizationManager.shared.language
As soon as the language changes anywhere in the app under language key in AppStorage, the change would be reflected within the whole app where we have implemented localized texts logic.
Short code snippet for displaying how it would look like as a result:
import SwiftUI
struct SettingsView: View {
// MARK: - Variables
@AppStorage("selectedLanguage") private var language = LocalizationManager.shared.language
// MARK: - View
var body: some View {
Button {
print(language) // Would print "lithuanian" as our app first / pre-determined language was set to Lithuanian
// The following changes the language of all localizable strings to English across the app immediately
LocalizationManager.shared.language = .english
} label: {
Text("Change language to English")
}
}
}
struct DashboardView: View {
// MARK: - Variables
@AppStorage("selectedLanguage") private var language = LocalizationManager.shared.language
// MARK: - View
var body: some View {
// Lithuanian string value "Pagrindinis" would be changed to English equivalent "Home" immediately as soon as we tap the button in Settings
Text("Strings.Dashboard.home".localized(language))
}
}
Monitoring the language changes: Second method
This is the second method (or improvement) that was suggested by @lyahovchuk.s
An another possible implementation of monitoring the language changes and injecting the language into localization extraction.
Instead of using @AppStorage property wrapper in each view, we can fulfill the capabilities of the @EnvironmentObject property wrapper.
Firstly, let’s make some changes to the previously created localization manager class LocalizationManager:
- Mark the class as ObservableObject.
- Remove the existing methods in this class.
- Update init method to include selected language initialization.
- Add (or move in — if coming from the first method) the languageString variable marked with SwiftUI-friendly storage access property wrapper @AppStorage. Set its default value to the language you want to be seen within the first app launch.
- Mark language variable as @Published and update its setter (didSet) with languageString assignation of a newly changed language. In other words, assigning a new value inside setter method would save the new language code into the storage. Set its default value to the language you want to be seen within the first app launch too.
import SwiftUI
public class LocalizationManager: ObservableObject { // Mark the class as ObservableObject
// MARK: - Variables
public static let shared = LocalizationManager()
@AppStorage("selectedLanguage") private var languageString: String = SelectedLanguage.lithuanian.rawValue // First-time users would see Lithuanian language
@Published public var language: SelectedLanguage = .lithuanian { // Match the initial language with languageString
didSet {
languageString = language.rawValue // We save the updated language's code into storage
UserDefaults.standard.set([language.rawValue], forKey: "AppleLanguages") // Set the device's app language
UserDefaults.standard.synchronize() // Optional as Apple does not recommend doing this
}
}
// MARK: - Init
public init() {
// Added selected language initialization
if let selectedLanguage = SelectedLanguage(rawValue: languageString) {
language = selectedLanguage
}
}
}
Going further, we would need to register the observable object (our localization manager class) via the environmentObject() view modifier within the parent view (e.g. in the snippet below we’ll use an app’s entry point) for sharing model data anywhere it’s needed, while also ensuring that our views automatically stay updated when that data changes. The object becomes available in SwiftUI’s environment for that view plus any other child views inside it.
import SwiftUI
@main
struct ContentView: App {
var body: some Scene {
WindowGroup {
ZStack {
SettingsView()
DashboardView()
}
.environmentObject(LocalizationManager.shared) // Making the object available in SwiftUI's environment for all child views
}
}
}
Lastly, for localized strings extraction (a previously declared localized method which needs the current language), we add localizationManager variable and mark it as @EnvironmentObject in each of our views.
You don’t assign a value to an @EnvironmentObject property. Instead, you inject it into the environment of a view hierarchy like we did just a moment ago.
Now, we can view, use, and change the selected language by accessing the language variable via localizationManager.
Short code snippet for displaying how it would look like as a result:
import SwiftUI
struct SettingsView: View {
// MARK: - Variables
@EnvironmentObject var localizationManager: LocalizationManager
// MARK: - View
var body: some View {
Button {
print(localizationManager.language) // Would print "lithuanian" as our app first / pre-determined language was set to Lithuanian
localizationManager.language = .english // This changes the language of all localizable strings to English across the app immediately
// LocalizationManager would take care of the smooth change
} label: {
Text("Change language to English")
}
}
}
struct DashboardView: View {
// MARK: - Variables
@EnvironmentObject var localizationManager: LocalizationManager
// MARK: - View
var body: some View {
// Lithuanian string value "Pagrindinis" would be changed to English equivalent "Home" immediately as soon as we tap the button in Settings
Text("Strings.Dashboard.home".localized(localizationManager.language))
}
}
The finale
If the GIF image is not playing, please tap on it:
That’s it. You can also combine it with the some of the provided code snippets solutions may require your own adjustments or improvements, but it could be a great starting point to continue tweaking your SwiftUI application with the text localization features!
Thank you for reading and happy coding! :)
The solution was inspired by this article and another one. Feel free to read those articles for further learning and broader understanding.
More about me and my experience here.