← Back to list

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…

R.R · 2025-11-21 10:14 · 4 claps · 3.1 min read
#kotlin-multiplatform #kotlin #programming #localization #locale
Open on Medium ↗
Wiki topics: 💻 · Programming 📱 · Mobile Development

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