Resources and Localization in Compose Multiplatform
|
This content was generated with the assistance of AI and should be verified against the official Kotlin Multiplatform documentation before being relied on in production. See the section bibliography for the reference material consulted while preparing these pages. |
Android has res/ and the generated R class; iOS has asset catalogs, .strings files and Bundle.main; desktop and
web have neither. Compose Multiplatform (CMP) replaces all of them with one resource system that lives in common
code: a composeResources directory in a source set, a Gradle plugin that turns it into a typed, generated Res
class, and a small runtime library that reads the files on every target. The same stringResource(Res.string.app_name)
call works on Android, iOS, desktop and web, and the same qualifier folders (values-es, drawable-dark,
drawable-xxhdpi) select the right variant on each.
The baseline is the one used across this section: Kotlin 2.4.20, Compose Multiplatform 1.12.1, AGP 9.4
and Xcode 26.4. Stability levels used below: the resource library, the Res class, qualifiers and the compose.resources
Gradle block are Stable; the web preloading helpers and a few helper APIs behind @ExperimentalResourceApi are
Experimental; Kotlin/Wasm and CMP for web are Beta. Every example is a slice of TrailMate Everywhere, the
multiplatform version of the Android section’s TrailMate app (package
com.example.trailmate): the shared screens live in the Gradle module sharedUI, so that is where composeResources
lives. The Android model this page generalises is on
Resources, Configuration, Localization and Themes; Compose basics
are not re-taught — see Compose Fundamentals — and the shared UI stack is
introduced on Compose Multiplatform Fundamentals.
Why a Separate Resource System
Kotlin common code cannot see R.string.x (Android-only) or NSLocalizedString (Apple-only). Before CMP shipped
resources, teams used a third-party library or duplicated the files per platform. The official system exists to
remove that duplication and keep three promises:
-
One source of truth. A string translated once is translated on every platform.
-
Type safety. A missing or misspelt resource is a compile error against
Res, not a runtime crash. -
The same selection rules everywhere. Language, theme and density qualifiers behave like Android’s, even on iOS and the web.
Android res/ |
Apple asset catalogs and .strings |
Compose Multiplatform resources | |
|---|---|---|---|
Location |
|
|
|
Typed access |
|
|
|
Language qualifier |
|
|
|
Dark theme |
|
Appearance slots in the asset catalog |
|
Density |
|
|
|
Runs in common code |
No |
No |
Yes |
The trade-off is explicit: CMP resources are read by Compose. A SwiftUI screen over the shared module (the native-UI path described on Integrating Shared Code into iOS) keeps using Apple’s own localization — see Localization — and the legacy library at the end of this page.
Project Setup
Resources belong to the module that renders UI — sharedUI. Add the library dependency and the Gradle plugin
(already applied by org.jetbrains.compose):
// sharedUI/build.gradle.kts
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidKotlinMultiplatformLibrary) // com.android.kotlin.multiplatform.library
alias(libs.plugins.composeMultiplatform) // org.jetbrains.compose
alias(libs.plugins.composeCompiler) // org.jetbrains.kotlin.plugin.compose
}
kotlin {
androidLibrary {
namespace = "com.example.trailmate.shared.ui"
compileSdk = 37
minSdk = 26
}
jvm("desktop")
iosArm64()
iosSimulatorArm64()
wasmJs { browser() }
sourceSets {
commonMain.dependencies {
implementation(libs.compose.runtime)
implementation(libs.compose.foundation)
implementation(libs.compose.material3)
implementation(libs.compose.components.resources) // org.jetbrains.compose.components:components-resources
implementation(project(":sharedLogic"))
}
}
}
compose.resources {
packageOfResClass = "com.example.trailmate.shared.ui.resources"
publicResClass = false
generateResClass = auto
}
The compose.resources block controls how the Res class is generated:
| Property | Meaning | Default |
|---|---|---|
|
The Kotlin package of |
Derived from the module |
|
Make |
|
|
|
|
|
Rename the generated class (default |
|
The common mistake here is leaving publicResClass = false and then trying to call Res.drawable.logo from
androidApp. The accessor is internal to sharedUI; either expose a public @Composable wrapper from sharedUI
(the cleaner design — the app module should only call App()) or flip the flag.
Android needs no androidResources configuration for these files: the Gradle plugin packages composeResources as
assets, so the multiplatform Android library plugin from
The Android Target and AGP 9 works unchanged. The generated
accessors appear only after the first Gradle sync or build (generateComposeResClass), so a red Res in the IDE
usually means the project has not been synced yet.
The composeResources Directory
Create the directory next to your Kotlin source: sharedUI/src/commonMain/composeResources. Its subfolders are not
free-form; each name is a resource type:
| Folder | Contents | Generated accessor |
|---|---|---|
|
Raster images ( |
|
|
|
|
|
|
|
|
Anything else, any subfolders — JSON, Lottie, text, a bundled |
Read by path: |
TrailMate’s tree after the first few screens:
# sharedUI/src/commonMain/composeResources (layout of the directory)
composeResources/
drawable/
ic_favorite.xml # Android vector drawable, converted at build time
ic_trail.svg
trail_placeholder.png
drawable-dark/
trail_placeholder.png # used when the system theme is dark
drawable-xxhdpi/
hero_banner.png # 3x density only; other densities fall back
font/
Inter-Regular.ttf
Inter-Bold.ttf
values/
strings.xml # default language (English)
values-es/
strings.xml
values-fr-rCA/
strings.xml
values-ar/
strings.xml
files/
onboarding.json
elevation_samples.csv
Names become identifiers. trail_placeholder.png is Res.drawable.trail_placeholder, so file names must be valid
lowercase Kotlin identifiers made of letters, digits and underscores, with no dashes or spaces. Two files with the same
name and different extensions in one folder are a build error. Every strings.xml key becomes a property of
Res.string; keep keys identical across languages or the generated class (built from the default values folder) will
not match.
Images with painterResource
painterResource returns a Painter for a DrawableResource. It decodes png/jpg/webp as bitmaps and svg or
Android vector xml as vectors:
// sharedUI/src/commonMain/kotlin/com/example/trailmate/ui/list/TrailRow.kt
package com.example.trailmate.ui.list
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.example.trailmate.domain.Trail
import com.example.trailmate.shared.ui.resources.Res
import com.example.trailmate.shared.ui.resources.ic_favorite
import com.example.trailmate.shared.ui.resources.trail_placeholder
import com.example.trailmate.shared.ui.resources.trail_row_photo_description
import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.resources.stringResource
@Composable
fun TrailRow(trail: Trail, modifier: Modifier = Modifier) {
Row(modifier) {
Image(
painter = painterResource(Res.drawable.trail_placeholder),
contentDescription = stringResource(Res.string.trail_row_photo_description, trail.name),
modifier = Modifier.size(64.dp),
)
Text(trail.name)
if (trail.isFavorite) {
Icon(painterResource(Res.drawable.ic_favorite), contentDescription = null)
}
}
}
Notes that save debugging time:
-
Imports are per resource. Accessors are top-level extension properties in the
Respackage, so eachRes.drawable.xneedsimport …resources.x. The IDE adds them; a wildcard import of the package also works. -
Variants of the call.
painterResourcepicks the right painter type automatically.vectorResource(…)returns anImageVectorandimageResource(…)anImageBitmapwhen you need those exact types, for example forIcon(imageVector = …)orCanvasdrawing. -
Loading is asynchronous on some targets. On the web, a resource that is not yet fetched renders as an empty painter for a frame or more; see the web section below for preloading.
-
SVG support is a subset. Gradients, clip paths and simple paths work; filters, masks and CSS-styled SVGs do not. Convert complex artwork to
pngor to an Android vector.
For remote photos (TrailMate’s photoUrl) use an image-loading library rather than a resource, as discussed on
Essential Multiplatform Libraries; the bundled
trail_placeholder is the loading and error image.
Strings, Plurals and String Arrays
values/strings.xml uses the same schema as Android, so existing files copy straight over:
<!-- sharedUI/src/commonMain/composeResources/values/strings.xml -->
<resources>
<string name="app_name">TrailMate</string>
<string name="trail_list_title">Trails near you</string>
<string name="trail_row_photo_description">Photo of %1$s</string>
<string name="trail_length">%1$s km</string>
<string name="settings_title">Settings</string>
<string name="disclaimer" translatable="false">Not for navigation.</string>
<plurals name="trail_count">
<item quantity="one">%1$d trail found</item>
<item quantity="other">%1$d trails found</item>
</plurals>
<string-array name="difficulty_labels">
<item>Easy</item>
<item>Moderate</item>
<item>Hard</item>
</string-array>
</resources>
| API | Context | Returns |
|---|---|---|
|
|
The |
|
|
The |
|
|
The plural form chosen for |
|
|
The plural form |
|
|
|
|
|
|
// sharedUI/src/commonMain/kotlin/com/example/trailmate/ui/list/TrailListScreen.kt
package com.example.trailmate.ui.list
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import com.example.trailmate.shared.ui.resources.Res
import com.example.trailmate.shared.ui.resources.difficulty_labels
import com.example.trailmate.shared.ui.resources.trail_count
import com.example.trailmate.shared.ui.resources.trail_length
import org.jetbrains.compose.resources.pluralStringResource
import org.jetbrains.compose.resources.stringArrayResource
import org.jetbrains.compose.resources.stringResource
@Composable
fun TrailSummary(count: Int, lengthKm: Double, difficultyOrdinal: Int) {
// quantity picks the form, the trailing argument fills %1$d
Text(pluralStringResource(Res.plurals.trail_count, count, count))
Text(stringResource(Res.string.trail_length, lengthKm.toString()))
Text(stringArrayResource(Res.array.difficulty_labels)[difficultyOrdinal])
}
Points that surprise newcomers:
-
Format arguments are positional. Use
%1$s,%2$d(never a bare%swhen a translation may reorder words).getStringandstringResourcepass the arguments in order; a count mismatch fails at runtime. -
Plural arguments repeat the number.
pluralStringResource(plural, quantity, args)usesquantityonly to choose the form; if the text shows the number you pass it again as an argument. -
Quantity categories are language-specific. English needs
oneandother; Arabic useszero,one,two,few,manyandother; French treats 0 asone. Only the locale’s CLDR categories are consulted, soothermust always exist. -
getStringbelongs in non-UI code. A shared ViewModel should expose a state or aStringResourcekey, not a pre-translatedString, so the UI re-resolves it when the language changes. Resolving at the UI edge also keeps the ViewModel testable without resources; see Architecture and Shared ViewModels. -
Special characters follow Android escaping. Escape an apostrophe as
\'or wrap the text in quotes, escape@and?at the start, and write&and<in XML.
Passing a resource key instead of text looks like this:
// sharedLogic/src/commonMain/kotlin/com/example/trailmate/presentation/TrailListUiState.kt
// sharedLogic has no Compose dependency, so the UI layer maps an enum to a StringResource.
package com.example.trailmate.presentation
sealed interface TrailListMessage {
data object Empty : TrailListMessage
data class Count(val value: Int) : TrailListMessage
data class NetworkError(val retryInSeconds: Int) : TrailListMessage
}
// sharedUI/src/commonMain/kotlin/com/example/trailmate/ui/list/TrailListMessageText.kt
package com.example.trailmate.ui.list
import androidx.compose.runtime.Composable
import com.example.trailmate.presentation.TrailListMessage
import com.example.trailmate.shared.ui.resources.*
import org.jetbrains.compose.resources.pluralStringResource
import org.jetbrains.compose.resources.stringResource
@Composable
fun TrailListMessage.asText(): String = when (this) {
TrailListMessage.Empty -> stringResource(Res.string.trail_list_empty)
is TrailListMessage.Count -> pluralStringResource(Res.plurals.trail_count, value, value)
is TrailListMessage.NetworkError -> stringResource(Res.string.network_error_retry, retryInSeconds)
}
This design keeps the strongly typed domain in sharedLogic and the presentation in sharedUI, and it is also what
lets the SwiftUI path of the same app map TrailListMessage to its own String(localized:) values.
Fonts
Fonts are the one resource that cannot be a plain Painter: a Font in CMP is resolved inside composition, because it
depends on the weight and style at the call site and may need loading. Put ttf/otf files in font/ and build a
FontFamily with the composable Font function:
// sharedUI/src/commonMain/kotlin/com/example/trailmate/ui/theme/Type.kt
package com.example.trailmate.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.runtime.Composable
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.sp
import com.example.trailmate.shared.ui.resources.Inter_Bold
import com.example.trailmate.shared.ui.resources.Inter_Regular
import com.example.trailmate.shared.ui.resources.Res
import org.jetbrains.compose.resources.Font
@Composable
fun trailMateTypography(): Typography {
val inter = FontFamily(
Font(Res.font.Inter_Regular, weight = FontWeight.Normal),
Font(Res.font.Inter_Bold, weight = FontWeight.Bold),
)
return Typography(
bodyLarge = TextStyle(fontFamily = inter, fontSize = 16.sp),
titleLarge = TextStyle(fontFamily = inter, fontWeight = FontWeight.Bold, fontSize = 22.sp),
)
}
Because trailMateTypography() is @Composable, it must be called from inside TrailMateTheme; building it in a
top-level val does not compile. Glyph fallback differs by platform: Android, iOS and desktop fall back to system
fonts for missing glyphs (emoji, CJK), but a browser build does not ship a system emoji or CJK font to Skia, so bundle
a fallback font or you will see empty boxes (see the web section). Theming itself — MaterialTheme, colour schemes — is covered on Compose Material 3 and Theming and applies unchanged.
Raw Files with Res.readBytes and Res.getUri
Everything under files/ is opaque to the generator. Read it by its path relative to composeResources:
// sharedUI/src/commonMain/kotlin/com/example/trailmate/ui/onboarding/OnboardingLoader.kt
package com.example.trailmate.ui.onboarding
import com.example.trailmate.shared.ui.resources.Res
import kotlinx.serialization.json.Json
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.Serializable
@Serializable
data class OnboardingPage(val title: String, val body: String)
class OnboardingLoader(private val json: Json = Json) {
// suspend: reading may hit disk, the APK, the app bundle or the network (web)
suspend fun load(): List<OnboardingPage> {
val bytes = Res.readBytes("files/onboarding.json")
return json.decodeFromString(bytes.decodeToString())
}
}
// elsewhere: a URI string that a platform component (video player, WebView) can open
fun elevationCsvUri(): String = Res.getUri("files/elevation_samples.csv")
-
Res.readBytes(path)is asuspendfunction returning aByteArray. In a composable, wrap it inLaunchedEffectorproduceState; do not call it on the main thread viarunBlocking, which blocks the browser entirely. -
Res.getUri(path)returns a platform URI: afile:orjar:URI on desktop,file:///android_asset/…;on Android and a URL relative to the page on the web. Pass it to components that take a URI (an audio player, aWebView), not tojava.io.File. -
Paths are case-sensitive and use forward slashes on every platform; a typo is a runtime exception, not a compile error (unlike typed accessors for drawables and strings). Wrap the constants in one object.
-
Res.allDrawableResources,Res.allStringResourcesand similar maps expose all generated resources by name, for data-driven UIs (an icon chosen from a server response). They exist for the same reason asResources.getIdentifieron Android, and carry the same caveat: names are not refactor-safe.
Qualifiers and How They Are Matched
Any resource folder may carry qualifiers after a dash: values-es, drawable-dark, drawable-xxhdpi,
drawable-es-dark-xhdpi. The runtime reads the resource environment — the current language and region, the system
theme and the screen density — and picks the best folder.
| Qualifier kind | Syntax | Examples |
|---|---|---|
Language |
ISO 639-1 code |
|
Region (needs language) |
|
|
Script (BCP 47) |
|
|
Theme |
|
|
Density |
|
|
The priority is fixed and differs from Android’s longer list: language and region first, then theme, then density.
That means a Spanish dark-theme drawable (drawable-es-dark) beats a plain English dark one, which beats a plain
drawable-es, and so on. Density is the last tiebreaker, which is why a missing density still works: the runtime picks
the closest available bucket and scales.
# sharedUI/src/commonMain/composeResources (matching for the file hero_banner.png)
drawable/hero_banner.png # fallback for everything
drawable-dark/hero_banner.png # dark theme, any language
drawable-es/hero_banner.png # Spanish, any theme
drawable-es-dark/hero_banner.png # Spanish AND dark: wins for Spanish + dark
drawable-xxhdpi/hero_banner.png # last tiebreaker among otherwise equal matches
Common mistakes with qualifiers:
-
A qualified resource without a default. The generator builds
Resfrom the default folders; an image that exists only indrawable-darkis invisible to the generated class. Always provide the unqualified base file. -
-pt-BRinstead of-pt-rBR. The region is written-rBR. The BCP 47 form-b+pt+BRis the unambiguous alternative. -
Expecting runtime theme change from
isSystemInDarkTheme(). Resource theme matching follows the system theme. If TrailMate has an in-app theme switch (aUserPreferencesRepositoryvalue), adrawable-darkimage will not follow it. Choose the image in code from your own theme state, as the preferences setup on Local Storage with SQLDelight, Room and DataStore shows, or use a tintable vector. -
Treating density as Android-only. iOS and the web report a density too; supply
drawable-xxhdpirather than relying on one large file everywhere to avoid scaling cost and memory.
Resources in Libraries and Non-Common Source Sets
Resource directories are attached to source sets, not only to commonMain:
-
src/commonMain/composeResourcesis merged into every target. -
src/androidMain/composeResources,src/iosMain/composeResources,src/desktopMain/composeResources(the jvm target nameddesktop) andsrc/wasmJsMain/composeResourcescontribute only to that target and are merged on top of common resources: a file with the same path replaces the common one.
That is how TrailMate ships a smaller launcher artwork on desktop or a platform-specific files/mapStyle.json without
touching common code.
// sharedUI/build.gradle.kts (extra resource directory for one source set)
compose.resources {
customDirectory(
sourceSetName = "desktopMain",
directoryProvider = provider { layout.projectDirectory.dir("desktopResources") },
)
}
Libraries that publish composables with bundled images or strings (a design system, a map-attribution component)
publish their composeResources inside their artifact, and CMP merges them into the consuming app. Each library has its own
Res class in its own package, so there is no clash; that is why packageOfResClass must be unique and stable.
A consumer reads a library’s resource only if that library set publicResClass = true; otherwise the library keeps the
resource internal and exposes a composable that uses it.
| Situation | Where the resource lives | Access |
|---|---|---|
App-private image used by one module |
That module’s |
|
Image shared by |
|
|
Branding of a published design-system library |
The library’s |
Consumer imports the library’s |
One platform needs different artwork |
|
Same |
Do not put resources that Compose never reads (an Android NotificationChannel icon, the launcher icon, iOS
LaunchScreen) here: those remain platform resources, in androidApp/src/main/res and the Xcode asset catalog of
iosApp, because the OS reads them before Kotlin runs.
Localization
Adding a language is adding a folder: copy values/strings.xml to values-es/strings.xml and translate it.
<!-- sharedUI/src/commonMain/composeResources/values-es/strings.xml -->
<resources>
<string name="app_name">TrailMate</string>
<string name="trail_list_title">Rutas cerca de ti</string>
<string name="trail_row_photo_description">Foto de %1$s</string>
<plurals name="trail_count">
<item quantity="one">%1$d ruta encontrada</item>
<item quantity="other">%1$d rutas encontradas</item>
</plurals>
</resources>
A key missing from values-es falls back to values, so a partial translation never crashes. Which language is
chosen is decided by the platform, and that is where the per-platform setup lives:
| Platform | How the system language reaches Compose |
|---|---|
Android |
The app locale (per-app language settings on Android 13+ and the system list); recreated on change. |
iOS |
The preferred languages of the app. Declare the languages you ship in |
Desktop (JVM) |
|
Web |
|
<!-- iosApp/iosApp/Info.plist (declare the languages the app supports) -->
<key>CFBundleLocalizations</key>
<array>
<string>en</string>
<string>es</string>
<string>fr-CA</string>
<string>ar</string>
</array>
This is the Apple rule described on Localization and the Android rule on Resources, Configuration, Localization and Themes; CMP reads their result, it does not replace it.
Changing the App Language at Runtime with LocalAppLocale
By default the app follows the system language. A "Language" row in TrailMate’s SettingsScreen needs the opposite: an
in-app override. The official guidance is a small expect/actual LocalAppLocale composition local that sets the
platform’s default locale and forces recomposition:
// sharedUI/src/commonMain/kotlin/com/example/trailmate/ui/locale/LocalAppLocale.kt
package com.example.trailmate.ui.locale
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ProvidedValue
expect object LocalAppLocale {
/** The current language tag, for example "es" or "fr-CA". */
val current: String @Composable get
/** Override the locale; pass null to follow the system again. */
@Composable
infix fun provides(value: String?): ProvidedValue<*>
}
// sharedUI/src/androidMain/kotlin/com/example/trailmate/ui/locale/LocalAppLocale.android.kt
package com.example.trailmate.ui.locale
import android.content.res.Configuration
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ProvidedValue
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import java.util.Locale
actual object LocalAppLocale {
private var default: Locale? = null
private val LocalAppLocaleImpl = staticCompositionLocalOf { Locale.getDefault().toLanguageTag() }
actual val current: String
@Composable get() = LocalAppLocaleImpl.current
@Composable
actual infix fun provides(value: String?): ProvidedValue<*> {
val configuration = LocalConfiguration.current
if (default == null) default = Locale.getDefault()
val new = value?.let(Locale::forLanguageTag) ?: default!!
Locale.setDefault(new)
configuration.setLocale(new)
val resources = LocalContext.current.resources
resources.updateConfiguration(configuration, resources.displayMetrics)
return LocalAppLocaleImpl.provides(new.toLanguageTag())
}
}
// sharedUI/src/iosMain/kotlin/com/example/trailmate/ui/locale/LocalAppLocale.ios.kt
package com.example.trailmate.ui.locale
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ProvidedValue
import androidx.compose.runtime.staticCompositionLocalOf
import platform.Foundation.NSLocale
import platform.Foundation.NSUserDefaults
import platform.Foundation.preferredLanguages
actual object LocalAppLocale {
private val default = NSLocale.preferredLanguages.firstOrNull() as? String
private val LocalAppLocaleImpl = staticCompositionLocalOf { default ?: "en" }
actual val current: String
@Composable get() = LocalAppLocaleImpl.current
@Composable
actual infix fun provides(value: String?): ProvidedValue<*> {
val new = value ?: default
if (value == null) {
NSUserDefaults.standardUserDefaults.removeObjectForKey("AppleLanguages")
} else {
NSUserDefaults.standardUserDefaults.setObject(listOf(new), forKey = "AppleLanguages")
}
return LocalAppLocaleImpl.provides(new ?: "en")
}
}
The desktop actual calls Locale.setDefault and the web actual stores the tag in localStorage and reads
navigator.language initially; both follow the same shape. Use the local at the root of App() and wrap the content in
key(…) so that every stringResource and every cached text re-reads the new language:
// sharedUI/src/commonMain/kotlin/com/example/trailmate/App.kt
package com.example.trailmate
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import com.example.trailmate.settings.UserPreferencesRepository
import com.example.trailmate.ui.locale.LocalAppLocale
import com.example.trailmate.ui.theme.TrailMateTheme
import org.koin.compose.koinInject
@Composable
fun App(preferences: UserPreferencesRepository = koinInject()) {
val languageTag by preferences.languageTag.collectAsState(initial = null)
CompositionLocalProvider(LocalAppLocale provides languageTag) {
key(LocalAppLocale.current) { // recompose the whole tree on change
TrailMateTheme {
TrailMateNavHost()
}
}
}
}
This is a user-implemented pattern, not a built-in setter, so its behaviour is under your control: on Android it
changes the process-wide Locale.getDefault(), so non-Compose code in the same process sees it too. On iOS the
AppleLanguages override only takes effect on the next launch for UIKit and SwiftUI parts of the app (relevant when
screens are mixed as described on
Compose Multiplatform on iOS); Compose reads it
directly. Android 13+ offers a system per-app language UI (LocaleManager) as a platform alternative, and it works with CMP
because Compose reads the resulting configuration.
Regional Formats
Language and region are separate settings. A user can run English with Germany’s formats (decimal comma, 24-hour
time, metric units, Monday as first day). CMP’s stringResource handles text only; numbers, dates and units are your
code’s job, and they must follow the region, not just the language.
// sharedUI/src/commonMain/kotlin/com/example/trailmate/ui/format/LengthFormatter.kt
package com.example.trailmate.ui.format
import androidx.compose.runtime.Composable
/** Formats a length in the user's regional convention (km or miles, locale digits). */
expect class LengthFormatter() {
fun format(lengthKm: Double): String
}
// sharedUI/src/androidMain/kotlin/com/example/trailmate/ui/format/LengthFormatter.android.kt
// (desktopMain uses the same java.text code)
actual class LengthFormatter actual constructor() {
actual fun format(lengthKm: Double): String {
val locale = java.util.Locale.getDefault()
val miles = locale.country in setOf("US", "GB", "LR", "MM")
val value = if (miles) lengthKm * 0.621371 else lengthKm
val nf = java.text.NumberFormat.getNumberInstance(locale).apply { maximumFractionDigits = 1 }
return nf.format(value) + if (miles) " mi" else " km"
}
}
// sharedUI/src/iosMain/kotlin/com/example/trailmate/ui/format/LengthFormatter.ios.kt
actual class LengthFormatter actual constructor() {
actual fun format(lengthKm: Double): String {
val measurement = platform.Foundation.NSMeasurement(lengthKm, platform.Foundation.NSUnitLength.kilometers)
val formatter = platform.Foundation.NSMeasurementFormatter().apply {
unitOptions = platform.Foundation.NSMeasurementFormatterUnitOptionsNaturalScale
}
return formatter.stringFromMeasurement(measurement)
}
}
Two rules follow. First, never hand-format dates or numbers with toString() or string concatenation in a shared
composable; use kotlinx-datetime for the model and a platform formatter (as above, or the Intl APIs behind a wasmJs
actual) for presentation — the libraries are listed on
Essential Multiplatform Libraries. Second, test the
shared code in a non-English region: the classic bug is a Double printed as 12.5 and parsed back as 12,5.
For the full pattern of platform differences, see
expect/actual and Platform APIs.
Right-to-Left Layouts
Compose derives LocalLayoutDirection from the current locale, so a row that places an icon before text puts it on the
right in Arabic and Hebrew automatically: Row, padding(start=..), Arrangement.Start and Alignment.Start all mirror.
// sharedUI/src/commonMain/kotlin/com/example/trailmate/ui/detail/DifficultyBadge.kt
package com.example.trailmate.ui.detail
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import com.example.trailmate.shared.ui.resources.Res
import com.example.trailmate.shared.ui.resources.ic_arrow_forward
import org.jetbrains.compose.resources.painterResource
@Composable
fun DifficultyBadge(label: String) {
val rtl = LocalLayoutDirection.current == LayoutDirection.Rtl
Row(Modifier.padding(start = 8.dp)) { // "start" mirrors; "left" would not
Text(label)
Icon(
painterResource(Res.drawable.ic_arrow_forward),
contentDescription = null,
modifier = Modifier.scale(scaleX = if (rtl) -1f else 1f, scaleY = 1f), // directional glyph
)
}
}
Rules of thumb:
-
Mirror directional icons only. A back arrow or a forward chevron flips; a play button, a clock or a map pin does not.
-
Use
start/end, neverleft/right, in padding, alignment and absolute offsets. -
Numbers stay left-to-right inside right-to-left text; keep digit grouping to the formatter.
-
Test with a pseudolocale or a real RTL language. Add an
arfolder even if you do not ship it yet; it exposes truncation and alignment bugs early. -
On iOS, make sure
aris listed inCFBundleLocalizations; otherwise iOS reports an LTR environment. -
Combined with text, always pass the translated string through the same
Text, never concatenate fragments, so that word order can change.
Localization Tests
Because Res is plain Kotlin, resource behaviour is testable in common code with the UI test library already used by TrailMate’s
TrailListScreenTest. The test below renders the list in Spanish and asserts the plural form:
// sharedUI/src/commonTest/kotlin/com/example/trailmate/ui/list/TrailListLocalizationTest.kt
package com.example.trailmate.ui.list
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.runComposeUiTest
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.assertIsDisplayed
import com.example.trailmate.ui.locale.LocalAppLocale
import kotlin.test.Test
class TrailListLocalizationTest {
@OptIn(ExperimentalTestApi::class)
@Test
fun countIsPluralisedInSpanish() = runComposeUiTest {
setContent {
CompositionLocalProvider(LocalAppLocale provides "es") {
TrailSummary(count = 3, lengthKm = 12.5, difficultyOrdinal = 1)
}
}
waitForIdle()
onNodeWithText("3 rutas encontradas").assertIsDisplayed()
}
}
runComposeUiTest is Experimental (@ExperimentalTestApi). It runs on the desktop and Android host targets and, with
a simulator, on iOS; the general test layout is on
Essential Multiplatform Libraries and the Android
equivalent on Instrumented and UI Testing. Beyond rendered text,
two cheap checks pay off:
-
Key parity. A unit test or a CI script that compares the keys of every
values-*folder againstvaluescatches an untranslated key before release. The default folder is the contract; the others must have the same keys. -
Format-argument parity. Compare the
%1$splaceholders per key between languages; a translation that drops one crashes at runtime.
Web: Preloading and Caching
On the web (Kotlin/Wasm, Beta) every resource is a separate fetch against the files that
wasmJsBrowserDistribution copies next to the application, under composeResources/. That has two consequences:
-
The first frame can render before the resource arrives. Text from
strings.xmlis requested before it can be drawn, an image shows nothing, and a custom font momentarily falls back to a default font (a flash of unstyled text). -
A missing fallback font means missing glyphs. Skia renders text itself, so emoji and CJK characters need a font that you bundle.
CMP offers preloading helpers (Experimental, @ExperimentalResourceApi) that fetch the resource inside a composable
and return a state, so the app can show a splash screen until they are ready:
// webApp/src/wasmJsMain/kotlin/com/example/trailmate/Main.kt
package com.example.trailmate
import androidx.compose.runtime.getValue
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.window.ComposeViewport
import androidx.compose.material3.CircularProgressIndicator
import com.example.trailmate.shared.ui.resources.Inter_Regular
import com.example.trailmate.shared.ui.resources.NotoColorEmoji
import com.example.trailmate.shared.ui.resources.Res
import org.jetbrains.compose.resources.ExperimentalResourceApi
import org.jetbrains.compose.resources.preloadFont
@OptIn(ExperimentalComposeUiApi::class, ExperimentalResourceApi::class)
fun main() {
ComposeViewport("composeApplication") {
val inter by preloadFont(Res.font.Inter_Regular)
val emoji by preloadFont(Res.font.NotoColorEmoji)
if (inter != null && emoji != null) {
App()
} else {
CircularProgressIndicator() // shown until both fonts are fetched
}
}
}
The same document describes preloadImageBitmap and preloadImageVector for large images. For caching, the resource
files are plain static files: serve them with long-lived Cache-Control headers and content-hashed names or a versioned
path, and set a short cache time on the HTML entry point so a new release is picked up. A service worker can
precache the composeResources folder for offline use. GitHub Pages sets modest cache headers, so a changed resource
may take a while to appear on repeat visits after a release, which is a common surprise. The target-specific details are in
Web Targets: Kotlin/Wasm, Kotlin/JS and Compose for Web.
moko-resources: The Legacy Approach
Before CMP resources, the community library moko-resources (dev.icerock.moko:resources) was the way to share strings,
images, fonts and files from common code: a Gradle plugin generated a MR object with StringResource and ImageResource
and platform actuals read them. Its model is the ancestor of the one above, and it is still maintained.
| moko-resources | CMP resources | |
|---|---|---|
Works with SwiftUI and Android Views |
Yes: resources convert to |
Only inside Compose |
Compose support |
Optional add-on module |
Built in |
Official support |
Community |
JetBrains |
Typed accessors |
|
|
Use CMP resources for every Compose UI. Reach for moko-resources only when you share strings with native UI (a SwiftUI
screen over the shared module) and want one translation file; even then, many teams keep native localization per platform
instead, because each platform’s tooling (Xcode string catalogs, Play Console, translators' tools) already understands it.
Migrating from moko-resources is mostly moving files into composeResources, converting MR.strings.x to Res.string.x,
and replacing stringResource(MR.strings.x) with the CMP overload.
Common Mistakes
-
Using
compose.components.resourcesaccessor in a catalog-less build. The Gradle accessorcompose.components.resourcesis deprecated; use the directorg.jetbrains.compose.components:components-resourcescoordinates with the same version as the Compose plugin. -
Renaming the module without setting
packageOfResClass. Every import changes; set it once, explicitly. -
strings.xmloutsidevalues. Onlyvaluesandvalues-<qualifiers>are read; a file incomposeResources/strings.xmlis ignored. -
Resource names with dashes or capitals that Kotlin rejects. Use
snake_case; fonts such asInter-Bold.ttfmust be renamed toInter_Bold.ttf. -
Forgetting
CFBundleLocalizations. The app runs in English on iOS regardless of the phone’s language. -
Blocking in
Res.readBytes. It issuspendon every target; do not wrap it inrunBlocking. -
Putting the launcher icon or notification icon in
composeResources. The OS reads those before Kotlin starts. -
Caching a translated
Stringin a ViewModel. It will not change when the language does.
Summary
| Need | Use |
|---|---|
One resource system on all targets |
|
Image |
|
Text, plural, list |
|
Custom font |
|
Bundled data file |
|
Variants |
Folder qualifiers; priority is language/region, then theme, then density |
In-app language |
|
Regional formats |
Platform formatters behind |
RTL |
|
Web |
Preload fonts and images (Experimental); bundle fallback fonts |
Native UI strings shared with Swift |
moko-resources (legacy) or per-platform localization |
For what comes next: navigation, lifecycle and ViewModels on Lifecycle, ViewModel and Navigation in Compose Multiplatform; sizing, popups and accessibility on Layouts, Material 3, Adaptive UI, Popups and Accessibility; and the desktop target, with its own packaging of these resources, on Compose Multiplatform for Desktop.