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.