2
0

i18n.md 8.3 KB

Internationalization (i18n) — KMP + Compose Multiplatform

References: Compose Multiplatform Resources | Android Localization


String Resources

Define all user-facing strings in commonMain/composeResources/values/strings.xml. Never hardcode strings in Composables:

<!-- commonMain/composeResources/values/strings.xml -->
<resources>
    <string name="app_name">My App</string>
    <string name="home_title">Welcome, %1$s</string>
    <string name="items_count">%1$d items found</string>
    <string name="error_no_connection">No internet connection. Please check your network.</string>
    <string name="action_retry">Retry</string>
    <string name="action_cancel">Cancel</string>
    <string name="content_desc_loading">Loading</string>
    <string name="content_desc_close">Close</string>
</resources>

Add locale-specific overrides in separate directories:

commonMain/composeResources/
├── values/
│   └── strings.xml          ← default (English)
├── values-es/
│   └── strings.xml          ← Spanish
├── values-fr/
│   └── strings.xml          ← French
├── values-ar/
│   └── strings.xml          ← Arabic (RTL)
└── values-ja/
    └── strings.xml          ← Japanese

Use stringResource() in Composables — never access raw string IDs:

import org.jetbrains.compose.resources.stringResource
import com.example.shared.generated.resources.Res
import com.example.shared.generated.resources.*

@Composable
fun WelcomeHeader(userName: String) {
    Text(text = stringResource(Res.string.home_title, userName))
}

@Composable
fun ErrorMessage(onRetry: () -> Unit) {
    Column {
        Text(text = stringResource(Res.string.error_no_connection))
        Button(onClick = onRetry) {
            Text(text = stringResource(Res.string.action_retry))
        }
    }
}

Plurals

Use pluralStringResource for quantities that change grammatically:

<!-- commonMain/composeResources/values/strings.xml -->
<resources>
    <plurals name="items_count">
        <item quantity="one">%1$d item</item>
        <item quantity="other">%1$d items</item>
    </plurals>

    <plurals name="messages_unread">
        <item quantity="zero">No unread messages</item>
        <item quantity="one">%1$d unread message</item>
        <item quantity="other">%1$d unread messages</item>
    </plurals>
</resources>
import org.jetbrains.compose.resources.pluralStringResource

@Composable
fun ItemCount(count: Int) {
    Text(text = pluralStringResource(Res.plurals.items_count, count, count))
}

String Formatting

Use positional arguments (%1$s, %2$d) — never concatenate strings because word order differs by language:

// GOOD — position-based, translatable
Text(text = stringResource(Res.string.home_title, userName))  // "Welcome, Alice"

// BAD — order is wrong in many languages
Text(text = "Welcome, $userName")
Text(text = stringResource(Res.string.welcome) + userName)

For complex formatting (dates, numbers, currency), use kotlinx-datetime + platform formatters:

// commonMain — locale-aware date formatting
fun formatDate(date: LocalDate, locale: String = "en"): String {
    // Format using ISO-8601 as a safe default
    return "${date.month.name.lowercase().replaceFirstChar { it.uppercase() }} ${date.dayOfMonth}, ${date.year}"
}
// androidMain — use Android's DateFormat for locale-aware formatting
actual fun formatDateLocalized(date: LocalDate): String {
    val javaDate = java.util.Date.from(
        date.atStartOfDayIn(TimeZone.currentSystemDefault()).toJavaInstant()
    )
    return android.text.format.DateFormat.getDateFormat(context).format(javaDate)
}

RTL (Right-to-Left) Support

Compose handles RTL automatically when the system locale is RTL (Arabic, Hebrew, Farsi). Key rules:

Use start/end instead of left/right

// GOOD — mirrors correctly in RTL
Text(modifier = Modifier.padding(start = 16.dp, end = 8.dp))
Row(horizontalArrangement = Arrangement.Start)
Alignment.TopStart

// BAD — does not mirror in RTL
Text(modifier = Modifier.padding(left = 16.dp, right = 8.dp))
Row(horizontalArrangement = Arrangement.AbsoluteLeft)
Alignment.TopLeft

Force LTR for non-localizable content (code, phone numbers, URLs)

Text(
    text = phoneNumber,
    modifier = Modifier.semantics { this.layoutDirection = LayoutDirection.Ltr }
)

Test RTL in Compose Preview

@Preview(locale = "ar")
@Composable
private fun HomeScreenPreview_RTL() {
    AppTheme {
        CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
            HomeContent(uiState = HomeUiState(), onAction = {})
        }
    }
}

BiDi-Aware Icons

Use AutoMirrored icons for directional icons (arrows, back, forward) that should flip in RTL:

// GOOD — mirrors automatically in RTL
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
Icon(Icons.AutoMirrored.Filled.Send, contentDescription = null)

// BAD — same direction in both LTR and RTL
Icon(Icons.Default.ArrowBack, contentDescription = null)

Dynamic Locale Change

Support changing the app language at runtime without restarting:

// androidMain — update locale via AppCompatDelegate
actual fun setAppLocale(languageCode: String) {
    AppCompatDelegate.setApplicationLocales(
        LocaleListCompat.forLanguageTags(languageCode)
    )
    // Activity will be recreated automatically — Compose re-renders with new locale
}
<!-- AndroidManifest.xml — declare per-app language support -->
<application>
    <service
        android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
        android:enabled="false"
        android:exported="false">
        <meta-data
            android:name="autoStoreLocales"
            android:value="true" />
    </service>
</application>

Store the user's language preference in DataStore and apply on app startup:

class LocaleRepository(private val dataStore: DataStore<Preferences>) {
    private val KEY_LANGUAGE = stringPreferencesKey("language_code")

    val languageCode: Flow<String> = dataStore.data
        .map { prefs -> prefs[KEY_LANGUAGE] ?: "system" }

    suspend fun setLanguage(code: String) {
        dataStore.edit { prefs -> prefs[KEY_LANGUAGE] = code }
    }
}

Non-Translatable Strings

Mark strings that must not be translated (product names, trademarks) with translatable="false":

<resources>
    <string name="app_name" translatable="false">MyApp</string>
    <string name="company_name" translatable="false">Acme Corp</string>
</resources>

Locale-Aware Number and Currency Formatting

Never format numbers or currencies by hand — use locale-aware formatters:

// androidMain
actual fun formatCurrency(amount: Double, currencyCode: String): String {
    val format = java.text.NumberFormat.getCurrencyInstance()
    format.currency = java.util.Currency.getInstance(currencyCode)
    return format.format(amount)
}

actual fun formatNumber(number: Double): String {
    return java.text.NumberFormat.getNumberInstance().format(number)
}
// iosMain
import platform.Foundation.*

actual fun formatCurrency(amount: Double, currencyCode: String): String {
    val formatter = NSNumberFormatter()
    formatter.numberStyle = NSNumberFormatterCurrencyStyle
    formatter.currencyCode = currencyCode
    return formatter.stringFromNumber(NSNumber(double = amount)) ?: amount.toString()
}

Key Rules

  • Always use stringResource() — never hardcode user-facing strings in Composables
  • Use positional args (%1$s) — never string concatenation for translatable content
  • Use start/end padding and alignment — never left/right for locale-mirroring
  • Use AutoMirrored icons for directional icons
  • Test with RTL locale (ar, he) and font scale 1.5x in every screen preview
  • Mark non-translatable strings with translatable="false" to prevent translator confusion
  • Plurals for quantities — never if (count == 1) "item" else "items"