Kotlin Multiplatform: Handling Locale changes in Runtime
Surprisingly, there isn’t much clear or practical information on the internet about implementing dynamic locale changes in Kotlin…

Kotlin Multiplatform: Handling Locale changes in Runtime
Surprisingly, there isn’t much clear or practical information on the internet about implementing dynamic locale changes in Kotlin Multiplatform.
So here’s my small contribution.
The goal of this guide is to show how simple and clean the solution can be. In fact, with the right implementation, handling locale changes in KMP isn’t any harder than doing it natively on Android or iOS.
Step 1: Create the common and platform specific implementations
The foundation of your localization system is a shared CompositionLocal. This allows you to access the currently selected language anywhere in Compose, but we still need access to each platform’s Locale API independently to override the system’s preferences.
Here’s the common (shared) code:
expect object LocalAppLocale {
val current: String @Composable get
@Composable infix fun provides(value: String?): ProvidedValue<*>
}
iOS platform:
actual object LocalAppLocale {
private const val LANG_KEY = "AppleLanguages"
private val default = NSLocale.preferredLanguages.first() as String
private val LocalAppLocale = staticCompositionLocalOf { default }
actual val current: String
@Composable get() = LocalAppLocale.current
@Composable
actual infix fun provides(value: String?): ProvidedValue<*> {
val new = value ?: default
if (value == null) {
NSUserDefaults.standardUserDefaults.removeObjectForKey(LANG_KEY)
} else {
NSUserDefaults.standardUserDefaults.setObject(arrayListOf(new), LANG_KEY)
}
return LocalAppLocale.provides(new)
}
}
Android platform:
actual object LocalAppLocale {
private var default: Locale? = null
actual val current: String
@Composable get() = Locale.getDefault().toString()
@Composable
actual infix fun provides(value: String?): ProvidedValue<*> {
val configuration = LocalConfiguration.current
if (default == null) {
default = Locale.getDefault()
}
val new = when(value) {
null -> default!!
else -> Locale(value)
}
Locale.setDefault(new)
configuration.setLocale(new)
val resources = LocalContext.current.resources
resources.updateConfiguration(configuration, resources.displayMetrics)
return LocalConfiguration.provides(configuration)
}
}
The expect allows each target to provide its own implementation, but your UI logic stays 100% shared.
Step 2: Wrap your entire UI in a LocalizedApp provider
Every screen in your app should run inside a LocalizedApp composable. This ensures your chosen locale flows through the entire composition tree.
@Composable
fun LocalizedApp(lang: String? = null, content: @Composable () -> Unit) {
val effectiveLang = lang ?: getDeviceLocale().let { deviceLocale ->
SupportedLanguage.fromLocale(deviceLocale)?.locale
?: SupportedLanguage.ENGLISH.locale
}
CompositionLocalProvider(
LocalLayoutDirection provides LayoutDirection.Ltr,
LocalAppLocale provides effectiveLang,
content = content
)
}
Note: If you’d like to use the system’s default it requires additional code to get the Locale of each system and setting it when the app starts.
Step 3: Make a list of supported languages
Now once you already have the basic structure you can start adding languages you support in SupportedLanguage enum.
enum class SupportedLanguage(val resource: String, val locale: String) {
ENGLISH("English", "en"),
FRENCH("Français", "fr"),
...
GREEK("Ελληνικά", "el");
companion object {
fun fromLocale(locale: String): SupportedLanguage? {
entries.find { it.locale == locale }?.let { return it }
val languageCode = locale.split("-", "_").first()
return entries.find { it.locale == languageCode }
}
fun getDeviceLanguageOrDefault(): SupportedLanguage {
return fromLocale(getDeviceLocale()) ?: ENGLISH
}
}
}
Best practice: If your app supports many countries but the same base language (e.g., “en-US”, “en-UK”), keep them mapped to the same internal language code unless your content needs to differ.
Step 4: Consuming LocalizedApp in your actual application entry
Your main entry point becomes beautifully clean:
LocalizedApp(language) {
AppTheme() {
// Your screens here
}
}
Whenever language changes, Compose re-renders your UI with the new locale automatically.
Step 5: Listening to locale changes inside composables
Anywhere in your shared UI code:
@Composable
fun WelcomeText() {
val locale = LocalAppLocale.current
Text(text = Strings.welcome(locale))
}
Step 6: Manage your strings in locale folders
To keep your strings typed and safe, use language-specific resource maps:
Example structure:
commonMain/
└ composeResources/
├ values/string.xml
├ values-fr/string.xml
├ values-ja/string.xml
└ ...
Tips
Android:
When releasing your app, ensure that your build.gradle is configured to include all supported languages. This prevents missing translations in the release build:
bundle {
language {
enableSplit = false
}
}
iOS: Make sure all the languages your app supports are included in your App Store Connect release. This ensures that users see the correct localized content and that your app passes App Store localization requirements.
Step 7: Change langauge in runtme
When users pick a new language in Settings, simply update the state:
var language by remember { mutableStateOf("fr") }
LocalizedApp(language) {
AppScreen(onLanguageChange = { newLang ->
language = newLang
})
}
No restart needed. No platform-specific APIs. Just Compose reactivity.
Bonus: Handle RTL layouts correctly
The prvious examples already sets the directio, we just need to modify it:
LocalLayoutDirection provides
if (effectiveLang == "he" || effectiveLang == "ar")
LayoutDirection.Rtl
else
LayoutDirection.Ltr
This code is used in prod in a project with over 30K users. You can check it out here: iOS, Android.
메타데이터
- post_id
- 3f17d3bc605b
- slug
- kmp-handling-locale-changes-in-runtime-3f17d3bc605b
- url
- https://medium.com/@dongas93/kmp-handling-locale-changes-in-runtime-3f17d3bc605b
- canonical_url
- https://medium.com/@dongas93/kmp-handling-locale-changes-in-runtime-3f17d3bc605b
- author_url
- https://medium.com/@dongas93
- status
- ok
- fetched_at
- 2026-07-15 04:26:53