Compose Multiplatform App Localization: A Complete Guide

Creating applications that cater to a global audience requires robust localization support. Compose Multiplatform simplifies the process of building applications for multiple platforms, including Android, iOS, desktop, and web, from a single codebase. Localization, also known as i18n (internationalization) and l10n (localization), is essential to provide a seamless user experience across different languages and regions. In this comprehensive guide, we will explore how to implement effective app localization in Compose Multiplatform using Jetpack Compose.

Why is Localization Important in Compose Multiplatform?

  • Wider Audience Reach: Localizing your app allows you to target a broader international user base.
  • Improved User Experience: Users prefer apps in their native language, leading to higher engagement and satisfaction.
  • Increased Downloads and Revenue: Localized apps often experience increased downloads and revenue due to enhanced user experience and market penetration.

Strategies for Localization in Compose Multiplatform

There are several strategies for localizing your Compose Multiplatform application, each with its benefits and considerations. Here are a few popular approaches:

  • String Resources: The most common approach, involving storing text in resource files for each supported language.
  • ICU MessageFormat: Using ICU MessageFormat for more complex formatting, including plurals and gender-specific text.
  • Community Libraries: Leveraging existing Kotlin Multiplatform libraries designed for localization.

Implementing Localization Using String Resources

Let’s explore how to implement localization using string resources, a widely adopted and straightforward approach.

Step 1: Set Up Resource Files

Create resource directories for each language you want to support. A common structure might look like this:

src/commonMain/resources/values/strings.xml          # Default language (e.g., English)
src/commonMain/resources/values-fr/strings.xml       # French
src/commonMain/resources/values-de/strings.xml       # German

Each strings.xml file will contain key-value pairs for your translated strings. For example, in src/commonMain/resources/values/strings.xml:

<resources>
    <string name="app_name">My Multiplatform App</string>
    <string name="greeting">Hello, %s!</string>
</resources>

In src/commonMain/resources/values-fr/strings.xml:

<resources>
    <string name="app_name">Mon Application Multiplatforme</string>
    <string name="greeting">Bonjour, %s!</string>
</resources>

Step 2: Accessing String Resources in Compose Multiplatform

Accessing string resources in Compose Multiplatform involves creating a mechanism to load and retrieve these strings based on the current locale.

Define a Resource Loader

First, you need to create an interface or class to handle the loading of resources. Since file access varies by platform, we will use an expect/actual pattern for platform-specific implementations.

In commonMain, define the interface:

interface ResourceLoader {
    fun loadString(key: String): String
}

expect val resourceLoader: ResourceLoader

Create actual implementations for each platform.

For Android (in androidMain):

import android.content.Context
import android.content.res.Resources
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat
import dev.icerock.moko.resources.StringResource
import java.util.Locale

actual val resourceLoader: ResourceLoader = object : ResourceLoader {
    private lateinit var context: Context
    
    fun setContext(context: Context) {
        this.context = context
    }
    
    override fun loadString(key: String): String {
        val resourceId = context.resources.getIdentifier(key, "string", context.packageName)
        return if (resourceId != 0) {
            context.resources.getString(resourceId)
        } else {
            "String not found: $key"
        }
    }
}

Initialize the context in your Android activity or application class:

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import your.package.name.resourceLoader

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        resourceLoader.setContext(this)
        setContent {
            // Your Compose UI
        }
    }
}

For iOS (in iosMain):

import platform.Foundation.NSBundle
import platform.Foundation.NSString
import platform.Foundation.stringWithFormat
import platform.Foundation.NSLocale
import platform.Foundation.localizedStringWithFormat

actual val resourceLoader: ResourceLoader = object : ResourceLoader {
    override fun loadString(key: String): String {
        val bundle = NSBundle.mainBundle()
        val resource = bundle.localizedStringForKey(key, null, null)
        return resource as String
    }
}

For Desktop (JVM) (in jvmMain):

import java.util.Locale
import java.util.ResourceBundle

actual val resourceLoader: ResourceLoader = object : ResourceLoader {
    override fun loadString(key: String): String {
        val bundle = ResourceBundle.getBundle("strings", Locale.getDefault())
        return try {
            bundle.getString(key)
        } catch (e: Exception) {
            "String not found: $key"
        }
    }
}

Step 3: Using String Resources in Compose UI

Now you can use the resourceLoader in your Compose UI components.

import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import your.package.name.resourceLoader

@Composable
fun Greeting(name: String) {
    val greeting = resourceLoader.loadString("greeting").format(name)
    Text(text = greeting)
}

@Composable
fun AppName() {
    val appName = resourceLoader.loadString("app_name")
    Text(text = appName)
}

Leveraging ICU MessageFormat for Advanced Localization

ICU MessageFormat provides powerful features for handling complex localization scenarios, such as pluralization and gender agreement.

Step 1: Add Dependencies

First, add the necessary dependency to your build.gradle.kts file:

dependencies {
    implementation("com.ibm.icu:icu4j:70.1") // Or the latest version
}

Step 2: Implement ICU Message Handling

Update your resource loader to use ICU MessageFormat:

Common code:

import com.ibm.icu.text.MessageFormat
import java.util.Locale

// Define a function to format the string using ICU MessageFormat
fun formatString(pattern: String, vararg args: Any): String {
    val messageFormat = MessageFormat(pattern, Locale.getDefault())
    return messageFormat.format(args)
}

Android (Update androidMain resourceLoader):

import android.content.Context

actual val resourceLoader: ResourceLoader = object : ResourceLoader {
    private lateinit var context: Context
    
    fun setContext(context: Context) {
        this.context = context
    }

    override fun loadString(key: String): String {
        val resourceId = context.resources.getIdentifier(key, "string", context.packageName)
        return if (resourceId != 0) {
            val pattern = context.resources.getString(resourceId)
            formatString(pattern)
        } else {
            "String not found: $key"
        }
    }

    fun formatString(pattern: String, vararg args: Any): String {
        val messageFormat = MessageFormat(pattern, Locale.getDefault())
        return messageFormat.format(args)
    }
}

Step 3: Create ICU MessageFormat Resources

Define more complex string resources using ICU MessageFormat. For example, handle pluralization in strings.xml:

<string name="unread_messages">You have {count, plural,
    =0 {no unread messages}
    one {1 unread message}
    other {# unread messages}
}.</string>

Step 4: Using ICU MessageFormat in Compose UI

Use the updated resourceLoader to display correctly formatted text:

import androidx.compose.material.Text
import androidx.compose.runtime.Composable

@Composable
fun UnreadMessages(count: Int) {
    val message = resourceLoader.loadString("unread_messages").format(count)
    Text(text = message)
}

Managing Locale Dynamically

Dynamically changing the locale requires a mechanism to update the application’s configuration and reload resources.

Step 1: Define a Locale Manager

Create a LocaleManager to handle changing the locale at runtime.

In Common code:

import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import java.util.Locale

object LocaleManager {
    private val _currentLocale = MutableStateFlow(Locale.getDefault())
    val currentLocale: StateFlow<Locale> = _currentLocale

    fun setLocale(locale: Locale) {
        _currentLocale.value = locale
        // You might need to trigger a resource reload here depending on your platform setup.
    }
}

On Android, you need to update the Configuration when the locale changes:

import android.content.Context
import android.content.res.Configuration
import java.util.Locale

fun updateLocale(context: Context, locale: Locale) {
    Locale.setDefault(locale)
    val resources = context.resources
    val configuration = Configuration(resources.configuration)
    configuration.setLocale(locale)
    context.createConfigurationContext(configuration)
    resources.updateConfiguration(configuration, resources.displayMetrics)
}

Step 2: Update the UI

Whenever the currentLocale changes, you need to recompose your UI to reflect the new locale settings.

import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue

@Composable
fun LocalizedUI() {
    val currentLocale by LocaleManager.currentLocale.collectAsState()

    // UI components that need to be recomposed when locale changes
    Text(text = resourceLoader.loadString("app_name"))
}

Using Community Libraries

Consider leveraging community libraries that provide multiplatform localization support to simplify your implementation.

moko-resources

moko-resources is a popular Kotlin Multiplatform library that offers comprehensive resource management, including localization. To use moko-resources, add the following dependencies to your build.gradle.kts file:

plugins {
    id("dev.icerock.mobile.multiplatform-resources") version "0.20.1"
    kotlin("multiplatform") version "1.9.21" // Kotlin version used
}

kotlin {
    sourceSets {
        commonMain {
            dependencies {
                implementation(dev.icerock.moko.resources.compose) // Compose support
            }
            resources {
                srcDirs("src/commonMain/resources")
            }
        }
        androidMain {
           dependencies {
                implementation(dev.icerock.moko.resources.compose)
           }
        }
        iosMain {
            dependencies {
               implementation(dev.icerock.moko.resources.compose)
            }
        }
    }
}
multiplatformResources {
    multiplatformResourcesPackage = "your.package.name"
    // Add your MR generated file name
}

Define resources in your src/commonMain/resources directory. Create different strings.xml files for each locale:

src/commonMain/resources/MR/strings.xml          # Default language (e.g., English)
src/commonMain/resources/MR/strings_fr.xml       # French
src/commonMain/resources/MR/strings_de.xml       # German

Create XML resources like this:

<resources>
    <string name="app_name">My Multiplatform App</string>
    <string name="greeting">Hello, {name}!</string>
</resources>

In your Compose UI, access these resources using generated MR class:

import MR
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import dev.icerock.moko.resources.StringResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.tooling.preview.Preview

@Composable
fun Greeting(name: String) {
    val greeting = stringResource(MR.strings.greeting, name)
    Text(text = greeting)
}

@Composable
fun AppName() {
    val appName = stringResource(MR.strings.app_name)
    Text(text = appName)
}
@Preview
@Composable
fun PreviewText() {
   AppName()
}

Use the library to change locale if desired

import dev.icerock.moko.resources.Language
// in code
fun changeLocale(language: Language){
    // this will trigger resource refresh
    org.example.application.SharedFactory().resources.language = language
}

Testing Your Localization

Testing your localization is a critical step to ensure that your app functions correctly in different languages. Effective testing strategies include:

  • Manual Testing: Have native speakers use your app and provide feedback on translations.
  • Automated Testing: Use UI testing frameworks to verify that UI elements display correctly in different locales.
  • Pseudo-Localization: Introduce artificial changes to your strings to identify layout and encoding issues.

Conclusion

Localization is crucial for Compose Multiplatform applications that aim to reach a global audience. By using string resources, ICU MessageFormat, community libraries, and dynamic locale management, you can create apps that offer a truly localized and user-friendly experience across multiple platforms. Thorough testing ensures that your localization efforts are effective and your app is well-received in different markets.