
i18n / l10n (internationalization/localization) library. Enhances string handling in UI development by providing typesafe, parameterized strings, dynamic loading, and flexible pluralization. Supports Jetpack Compose.
The missing I18N and L10N multiplatform library for Jetpack Compose!
Jetpack Compose greatly improved the way we build UIs on Android, but not how we interact with strings. stringResource() works well, but doesn't benefit from the idiomatic Kotlin like Compose.
Lyricist tries to make working with strings as powerful as building UIs with Compose, i.e., working with parameterized string is now typesafe, use of when expression to work with plurals with more flexibility, and even load/update the strings dynamically via an API!
strings.xml
few and many plural values (PRs are welcome)Inspired by accompanist library: music composing is done by a composer, and since this library is about writing lyrics strings, the role of a lyricist felt like a good name.
Take a look at the sample app and sample-multi-module for working examples.
Start by declaring your strings on a data class, class or interface (pick one). The strings can be anything (really, it's up to you): Char, String, AnnotatedString, List<String>, Set<String> or even lambdas!
data class Strings(
val simple: String,
val annotated: AnnotatedString,
val parameter: (locale: String) -> String,
val plural: (count: Int) -> String,
val list: List<String>,
val nestedStrings: NestedStrings(),
)
data class NestedStrings(
...
)Next, create instances for each supported language and annotate with @LyricistStrings. The languageTag must be an IETF BCP47 compliant language tag (docs). You must flag one of them as default.
@LyricistStrings(languageTag = Locales.EN, default = true)
val EnStrings = Strings(
simple = "Hello Compose!",
annotated = buildAnnotatedString {
withStyle(SpanStyle(color = Color.Red)) {
append("Hello ")
}
withStyle(SpanStyle(fontWeight = FontWeight.Light)) {
append("Compose!")
}
},
parameter = { locale ->
"Current locale: $locale"
},
plural = { count ->
val value = when (count) {
0 -> "no"
1, 2 -> "a few"
in 3..10 -> "a bunch of"
else -> "a lot of"
}
"I have $value apples"
},
list = listOf("Avocado", "Pineapple", "Plum")
)
@LyricistStrings(languageTag = Locales.PT)
val PtStrings = Strings(/* pt strings */)
@LyricistStrings(languageTag = Locales.ES)
val EsStrings = Strings(/* es strings */)
@LyricistStrings(languageTag = Locales.RU)
val RuStrings = Strings(/* ru strings */)Lyricist will generate the LocalStrings property, a CompositionLocal that provides the strings of the current locale. It will also generate rememberStrings() and ProvideStrings(), call them to make LocalStrings accessible down the tree.
val lyricist = rememberStrings()
ProvideStrings(lyricist) {
// Content
}
// Or just
ProvideStrings {
// Content
}Optionally, you can specify the current and default (used as fallback) languages.
val lyricist = rememberStrings(
defaultLanguageTag = "es-US", // Default value is the one annotated with @LyricistStrings(default = true)
currentLanguageTag = getCurrentLanguageTagFromLocalStorage(),
)Now you can use LocalStrings to retrieve the current strings.
val strings = LocalStrings.current
Text(text = strings.simple)
// > Hello Compose!
Text(text = strings.annotated)
// > Hello Compose!
Text(text = strings.parameter(lyricist.languageTag))
// > Current locale: en
Text(text = strings.plural(1))
Text(text = strings.plural(5))
Text(text = strings.plural(20))
// > I have a few apples
// > I have a bunch of apples
// > I have a lot of apples
Text(text = strings.list.joinToString())
// > Avocado, Pineapple, PlumUse the Lyricist instance provided by rememberStrings() to change the current locale. This will trigger a recomposition that will update the entire content.
lyricist.languageTag = Locales.PTImportant
Lyricist uses the System locale as current language (on Compose it uses Locale.current). If your app has a mechanism to change the language in-app please set this value on rememberStrings(currentLanguageTag = CURRENT_VALUE_HERE).
If you change the current language at runtime Lyricist won't persist the value on a local storage by itself, this should be done by you. You can save the current language tag on shared preferences, a local database or even through a remote API.
To control the visibility (public or internal) of the generated code, provide the following (optional) argument to KSP in the module's build.gradle.
ksp {
arg("lyricist.internalVisibility", "true")
}Instead of use LocalStrings.current to access your strings, you can simply call strings. Just provide the following (optional) argument to KSP in the module's build.gradle.
ksp {
arg("lyricist.generateStringsProperty", "true")
}After a successfully build you can refactor your code as below.
// Before
Text(text = LocalStrings.current.hello)
// After
Text(text = strings.hello)If you are using Lyricist on a multi module project and the generated declarations (LocalStrings, rememberStrings(), ProvideStrings()) are too generic for you, provide the following (optional) arguments to KSP in the module's build.gradle.
ksp {
arg("lyricist.packageName", "com.my.app")
arg("lyricist.moduleName", project.name)
}Let's say you have a "dashboard" module, the generated declarations will be LocalDashboardStrings, rememberDashboardStrings() and ProvideDashboardStrings().
So you liked Lyricist, but already have a project with thousands of strings spread over multiples files? I have good news for you: Lyricist can extract these existing strings and generate all the code you just saw above.
If you don't want to have the Compose code generated by KSP, you can set the lyricist.xml.generateComposeAccessors arg to "false", and you can write the code manually by following the instructions below.
Similar to the multi module setup, you must provide a few arguments to KSP. Lyricist will search for strings.xml files in the resources path. You can also provide a language tag to be used as default value for the LocalStrings.
ksp {
// Required
arg("lyricist.xml.resourcesPath", android.sourceSets.main.res.srcDirs.first().absolutePath)
// Optional
arg("lyricist.packageName", "com.my.app")
arg("lyricist.xml.moduleName", "xml")
arg("lyricist.xml.defaultLanguageTag", "en")
arg("lyricist.xml.generateComposeAccessors", "false")
}After the first build, the well-known rememberStrings() and ProvideStrings() (naming can vary depending on your KSP settings) will be available for use. Lyricist will also generated a Locales object containing all language tags currently in use in your project.
val lyricist = rememberStrings(strings)
ProvideStrings(lyricist, LocalStrings) {
// Content
}
lyricist.languageTag = Locales.PTYou can easily migrate from strings.xml to Lyricist just by copying the generated files to your project. That way, you can finally say goodbye to strings.xml.
Don't want to enable KSP to generate the code for you? No problem! Follow the steps below to integrate with Lyricist manually.
val strings = mapOf(
Locales.EN to EnStrings,
Locales.PT to PtStrings,
Locales.ES to EsStrings,
Locales.RU to RuStrings
)LocalStrings and choose one translation as default.val LocalStrings = staticCompositionLocalOf { EnStrings }rememberStrings() and ProvideStrings(), to make your LocalStrings accessible down the tree. But this time you need to provide your strings and LocalStrings manually.val lyricist = rememberStrings(strings)
ProvideStrings(lyricist, LocalStrings) {
// Content
}At the moment Lyricist only supports Jetpack Compose and Compose Multiplatform out of the box. If you need to use Lyricist with other UI Toolkit (Android Views, SwiftUI, Swing, GTK...) follow the instructions bellow.
val translations = mapOf(
Locales.EN to EnStrings,
Locales.PT to PtStrings,
Locales.ES to EsStrings,
Locales.RU to RuStrings
)val lyricist = Lyricist(defaultLanguageTag, translations)lyricist.state.collect { (languageTag, strings) ->
refreshUi(strings)
}
// Example for Compose
val state by lyricist.state.collectAsState()
CompositionLocalProvider(
LocalStrings provides state.strings
) {
// Content
}Lyricist uses KSP 2.0 for optimal performance and multiplatform compatibility. The following gradle.properties configuration is recommended for best results:
# Enable KSP 2.0 architecture for K2 compiler compatibility and performance improvements
ksp.useKsp2=true
# Enable incremental compilation for dramatically faster builds (20-50% improvement)
# Only reprocesses changed files instead of regenerating everything from scratch
ksp.incremental=true
# Disable incremental compilation logging for cleaner build output
# Enable temporarily (ksp.incremental.log=true) when debugging KSP issues
ksp.incremental.log=falseYou should set manually the source sets of the generated files, like described here.
buildTypes {
debug {
sourceSets {
main.java.srcDirs += 'build/generated/ksp/debug/kotlin/'
}
}
release {
sourceSets {
main.java.srcDirs += 'build/generated/ksp/release/kotlin/'
}
}
}Verify your KSP 2.0 configuration includes:
ksp.useKsp2=true
ksp.incremental=trueFor debugging incremental compilation issues, temporarily enable:
ksp.incremental.log=true
ksp.verbose=trueRemember to disable these flags after debugging as they increase build output verbosity.
This typically occurs when debug and release variants see each other's generated code. Ensure your Android module uses proper source set isolation:
// In your android module gradle file
sourceSets {
debug {
java.srcDirs += 'build/generated/ksp/debug/kotlin'
}
release {
java.srcDirs += 'build/generated/ksp/release/kotlin'
}
}Avoid configuring source sets inside buildTypes blocks as this can cause variant conflicts with KSP 2.0.
build.gradle then apply to your module's build.gradle
plugins {
id("com.google.devtools.ksp") version "${ksp-latest-version}"
}build.gradle
// Required
implementation("cafe.adriel.lyricist:lyricist:${latest-version}")
// If you want to use @LyricistStrings to generate code for you
ksp("cafe.adriel.lyricist:lyricist-processor:${latest-version}")
// If you want to migrate from strings.xml
ksp("cafe.adriel.lyricist:lyricist-processor-xml:${latest-version}")[versions]
lyricist = {latest-version}
[libraries]
lyricist = { module = "cafe.adriel.lyricist:lyricist", version.ref = "lyricist" }
lyricist-processor = { module = "cafe.adriel.lyricist:lyricist-processor", version.ref = "lyricist" }
lyricist-processorXml = { module = "cafe.adriel.lyricist:lyricist-processor-xml", version.ref = "lyricist" }Doing code generation only at commonMain. Currently workaround, for more information see KSP Issue 567
dependencies {
add("kspCommonMainMetadata", "cafe.adriel.lyricist:lyricist-processor:${latest-version}")
}
tasks.withType<org.jetbrains.kotlin.gradle.dsl.KotlinCompile<*>>().all {
if(name != "kspCommonMainKotlinMetadata") {
dependsOn("kspCommonMainKotlinMetadata")
}
}
kotlin.sourceSets.commonMain {
kotlin.srcDir("build/generated/ksp/metadata/commonMain/kotlin")
}The missing I18N and L10N multiplatform library for Jetpack Compose!
Jetpack Compose greatly improved the way we build UIs on Android, but not how we interact with strings. stringResource() works well, but doesn't benefit from the idiomatic Kotlin like Compose.
Lyricist tries to make working with strings as powerful as building UIs with Compose, i.e., working with parameterized string is now typesafe, use of when expression to work with plurals with more flexibility, and even load/update the strings dynamically via an API!
strings.xml
few and many plural values (PRs are welcome)Inspired by accompanist library: music composing is done by a composer, and since this library is about writing lyrics strings, the role of a lyricist felt like a good name.
Take a look at the sample app and sample-multi-module for working examples.
Start by declaring your strings on a data class, class or interface (pick one). The strings can be anything (really, it's up to you): Char, String, AnnotatedString, List<String>, Set<String> or even lambdas!
data class Strings(
val simple: String,
val annotated: AnnotatedString,
val parameter: (locale: String) -> String,
val plural: (count: Int) -> String,
val list: List<String>,
val nestedStrings: NestedStrings(),
)
data class NestedStrings(
...
)Next, create instances for each supported language and annotate with @LyricistStrings. The languageTag must be an IETF BCP47 compliant language tag (docs). You must flag one of them as default.
@LyricistStrings(languageTag = Locales.EN, default = true)
val EnStrings = Strings(
simple = "Hello Compose!",
annotated = buildAnnotatedString {
withStyle(SpanStyle(color = Color.Red)) {
append("Hello ")
}
withStyle(SpanStyle(fontWeight = FontWeight.Light)) {
append("Compose!")
}
},
parameter = { locale ->
"Current locale: $locale"
},
plural = { count ->
val value = when (count) {
0 -> "no"
1, 2 -> "a few"
in 3..10 -> "a bunch of"
else -> "a lot of"
}
"I have $value apples"
},
list = listOf("Avocado", "Pineapple", "Plum")
)
@LyricistStrings(languageTag = Locales.PT)
val PtStrings = Strings(/* pt strings */)
@LyricistStrings(languageTag = Locales.ES)
val EsStrings = Strings(/* es strings */)
@LyricistStrings(languageTag = Locales.RU)
val RuStrings = Strings(/* ru strings */)Lyricist will generate the LocalStrings property, a CompositionLocal that provides the strings of the current locale. It will also generate rememberStrings() and ProvideStrings(), call them to make LocalStrings accessible down the tree.
val lyricist = rememberStrings()
ProvideStrings(lyricist) {
// Content
}
// Or just
ProvideStrings {
// Content
}Optionally, you can specify the current and default (used as fallback) languages.
val lyricist = rememberStrings(
defaultLanguageTag = "es-US", // Default value is the one annotated with @LyricistStrings(default = true)
currentLanguageTag = getCurrentLanguageTagFromLocalStorage(),
)Now you can use LocalStrings to retrieve the current strings.
val strings = LocalStrings.current
Text(text = strings.simple)
// > Hello Compose!
Text(text = strings.annotated)
// > Hello Compose!
Text(text = strings.parameter(lyricist.languageTag))
// > Current locale: en
Text(text = strings.plural(1))
Text(text = strings.plural(5))
Text(text = strings.plural(20))
// > I have a few apples
// > I have a bunch of apples
// > I have a lot of apples
Text(text = strings.list.joinToString())
// > Avocado, Pineapple, PlumUse the Lyricist instance provided by rememberStrings() to change the current locale. This will trigger a recomposition that will update the entire content.
lyricist.languageTag = Locales.PTImportant
Lyricist uses the System locale as current language (on Compose it uses Locale.current). If your app has a mechanism to change the language in-app please set this value on rememberStrings(currentLanguageTag = CURRENT_VALUE_HERE).
If you change the current language at runtime Lyricist won't persist the value on a local storage by itself, this should be done by you. You can save the current language tag on shared preferences, a local database or even through a remote API.
To control the visibility (public or internal) of the generated code, provide the following (optional) argument to KSP in the module's build.gradle.
ksp {
arg("lyricist.internalVisibility", "true")
}Instead of use LocalStrings.current to access your strings, you can simply call strings. Just provide the following (optional) argument to KSP in the module's build.gradle.
ksp {
arg("lyricist.generateStringsProperty", "true")
}After a successfully build you can refactor your code as below.
// Before
Text(text = LocalStrings.current.hello)
// After
Text(text = strings.hello)If you are using Lyricist on a multi module project and the generated declarations (LocalStrings, rememberStrings(), ProvideStrings()) are too generic for you, provide the following (optional) arguments to KSP in the module's build.gradle.
ksp {
arg("lyricist.packageName", "com.my.app")
arg("lyricist.moduleName", project.name)
}Let's say you have a "dashboard" module, the generated declarations will be LocalDashboardStrings, rememberDashboardStrings() and ProvideDashboardStrings().
So you liked Lyricist, but already have a project with thousands of strings spread over multiples files? I have good news for you: Lyricist can extract these existing strings and generate all the code you just saw above.
If you don't want to have the Compose code generated by KSP, you can set the lyricist.xml.generateComposeAccessors arg to "false", and you can write the code manually by following the instructions below.
Similar to the multi module setup, you must provide a few arguments to KSP. Lyricist will search for strings.xml files in the resources path. You can also provide a language tag to be used as default value for the LocalStrings.
ksp {
// Required
arg("lyricist.xml.resourcesPath", android.sourceSets.main.res.srcDirs.first().absolutePath)
// Optional
arg("lyricist.packageName", "com.my.app")
arg("lyricist.xml.moduleName", "xml")
arg("lyricist.xml.defaultLanguageTag", "en")
arg("lyricist.xml.generateComposeAccessors", "false")
}After the first build, the well-known rememberStrings() and ProvideStrings() (naming can vary depending on your KSP settings) will be available for use. Lyricist will also generated a Locales object containing all language tags currently in use in your project.
val lyricist = rememberStrings(strings)
ProvideStrings(lyricist, LocalStrings) {
// Content
}
lyricist.languageTag = Locales.PTYou can easily migrate from strings.xml to Lyricist just by copying the generated files to your project. That way, you can finally say goodbye to strings.xml.
Don't want to enable KSP to generate the code for you? No problem! Follow the steps below to integrate with Lyricist manually.
val strings = mapOf(
Locales.EN to EnStrings,
Locales.PT to PtStrings,
Locales.ES to EsStrings,
Locales.RU to RuStrings
)LocalStrings and choose one translation as default.val LocalStrings = staticCompositionLocalOf { EnStrings }rememberStrings() and ProvideStrings(), to make your LocalStrings accessible down the tree. But this time you need to provide your strings and LocalStrings manually.val lyricist = rememberStrings(strings)
ProvideStrings(lyricist, LocalStrings) {
// Content
}At the moment Lyricist only supports Jetpack Compose and Compose Multiplatform out of the box. If you need to use Lyricist with other UI Toolkit (Android Views, SwiftUI, Swing, GTK...) follow the instructions bellow.
val translations = mapOf(
Locales.EN to EnStrings,
Locales.PT to PtStrings,
Locales.ES to EsStrings,
Locales.RU to RuStrings
)val lyricist = Lyricist(defaultLanguageTag, translations)lyricist.state.collect { (languageTag, strings) ->
refreshUi(strings)
}
// Example for Compose
val state by lyricist.state.collectAsState()
CompositionLocalProvider(
LocalStrings provides state.strings
) {
// Content
}Lyricist uses KSP 2.0 for optimal performance and multiplatform compatibility. The following gradle.properties configuration is recommended for best results:
# Enable KSP 2.0 architecture for K2 compiler compatibility and performance improvements
ksp.useKsp2=true
# Enable incremental compilation for dramatically faster builds (20-50% improvement)
# Only reprocesses changed files instead of regenerating everything from scratch
ksp.incremental=true
# Disable incremental compilation logging for cleaner build output
# Enable temporarily (ksp.incremental.log=true) when debugging KSP issues
ksp.incremental.log=falseYou should set manually the source sets of the generated files, like described here.
buildTypes {
debug {
sourceSets {
main.java.srcDirs += 'build/generated/ksp/debug/kotlin/'
}
}
release {
sourceSets {
main.java.srcDirs += 'build/generated/ksp/release/kotlin/'
}
}
}Verify your KSP 2.0 configuration includes:
ksp.useKsp2=true
ksp.incremental=trueFor debugging incremental compilation issues, temporarily enable:
ksp.incremental.log=true
ksp.verbose=trueRemember to disable these flags after debugging as they increase build output verbosity.
This typically occurs when debug and release variants see each other's generated code. Ensure your Android module uses proper source set isolation:
// In your android module gradle file
sourceSets {
debug {
java.srcDirs += 'build/generated/ksp/debug/kotlin'
}
release {
java.srcDirs += 'build/generated/ksp/release/kotlin'
}
}Avoid configuring source sets inside buildTypes blocks as this can cause variant conflicts with KSP 2.0.
build.gradle then apply to your module's build.gradle
plugins {
id("com.google.devtools.ksp") version "${ksp-latest-version}"
}build.gradle
// Required
implementation("cafe.adriel.lyricist:lyricist:${latest-version}")
// If you want to use @LyricistStrings to generate code for you
ksp("cafe.adriel.lyricist:lyricist-processor:${latest-version}")
// If you want to migrate from strings.xml
ksp("cafe.adriel.lyricist:lyricist-processor-xml:${latest-version}")[versions]
lyricist = {latest-version}
[libraries]
lyricist = { module = "cafe.adriel.lyricist:lyricist", version.ref = "lyricist" }
lyricist-processor = { module = "cafe.adriel.lyricist:lyricist-processor", version.ref = "lyricist" }
lyricist-processorXml = { module = "cafe.adriel.lyricist:lyricist-processor-xml", version.ref = "lyricist" }Doing code generation only at commonMain. Currently workaround, for more information see KSP Issue 567
dependencies {
add("kspCommonMainMetadata", "cafe.adriel.lyricist:lyricist-processor:${latest-version}")
}
tasks.withType<org.jetbrains.kotlin.gradle.dsl.KotlinCompile<*>>().all {
if(name != "kspCommonMainKotlinMetadata") {
dependsOn("kspCommonMainKotlinMetadata")
}
}
kotlin.sourceSets.commonMain {
kotlin.srcDir("build/generated/ksp/metadata/commonMain/kotlin")
}