Skip to main content

2 posts tagged with "Kotlin"

Posts related to Kotlin programming language

View All Tags

Localization in Compose - The pragmatic way

· 5 min read

I have been thinking for a while regarding localization-as-code approach for Compose. Currently, either in Jetpack Compose or Compose Multiplatform, we usually place the localized strings in resource files. But I felt this approach has some limitations because of these limitations:

  • String resource files can hold plain strings or strings with placeholder notations, but it has limitations to define annotated strings.

  • It has limitations to conditionally rephrase a string based on placeholder values. Eg: plural expressions

  • Since the resource files are XML, you might need to use <![CDATA[]]> during some instances to escape some special characters.

The motivation

I like the way how we access the Material theme properties in Compose code. It is seamless, independent, simple and concise.

Column {
Text(
text = "Hello, World!",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
)
}

The Material theme properties are provided by MaterialTheme composable which is usually placed in the top of the composable tree. It internally uses CompositionLocalProvider to propogate the theme properties down through the hierarchy. Also when the provided object changes, it is very smart to recompose those specific parts of the tree where the properties are used.

Inspired by the MaterialTheme in Compose, we can use the same approach for localization. Propogate the data structure of the localized objects through a CompositionLocalProvider placed on the root of the composable tree, and use it seamlessly deep down the hierarchy. When the user wants to update the application locale, provide the updated localized objects to the CompositionLocalProvider and let it recompose the parts of the hierarchy affected by the locale change.

The implementation

Instead of keeping strings in separate resource files, we can keep them in the Kotlin code itself. We can define an interface, provide properties or methods which return localized strings.

interface DefaultStrings {
companion object : DefaultStrings
val greeting: String
get() = "Hello, World!"

fun apples(count: Int): String {
return when (count) {
1 -> "1 Apple"
else -> "$count Apples"
}
}
}

Kotlin allows to create a companion object which we can craft it to be a default instance that implements the same interface accessible by the name of the interface itself. (This is inspired from the Modifier in Compose).

For providing localization, we can override the DefaultStrings interface to a localized version.

// Caution: These translations are provided by Google Translate. 
interface CzechStrings : DefaultStrings {
companion object : CzechStrings

override val greeting: String
get() = "Ahoj světe!"

override fun apples(count: Int): String {
return when (count) {
in 1..4 -> "$count jablka"
else -> "$count jablek"
}
}
}

Czech language is used in this sample code because the plural expression is different than English language, and well demonstrates how to tackle language specific plurals.

Once we have all the localized objects, we can define a CompositionLocal to propogate the localization object through the hierarchy, and a global MutableStateFlow to update the locale when needed. Also, we define a composable LocaleProvider to encapsulate the logic, and place it in the top of the hierarchy.

val LocalStrings = compositionLocalOf<DefaultStrings> { DefaultStrings }
val AppLocale = MutableStateFlow("en")

@Composable
fun LocaleProvider(
localeOverride: String? = null,
content: @Composable () -> Unit,
) {
val localeState: State<String>? = if (localeOverride == null) {
AppLocale.collectAsState()
} else {
null
}
val locale = localeOverride ?: localeState?.value
val strings = when (locale) {
"cz" -> CzechStrings
"ar" -> ArabicStrings
"es" -> SpanishStrings
"de" -> GermanStrings
else -> DefaultStrings
}
val layoutDirection = when (locale) {
"ar" -> LayoutDirection.Rtl
else -> LayoutDirection.Ltr
}
CompositionLocalProvider(
LocalStrings provides strings,
LocalLayoutDirection provides layoutDirection,
content = content
)
}

In the above example, we consider the layout directionality along with the localization. This is good when you localize the app to RTL languages like Arabic. It is also notable that localeOverride parameter in LocaleProvider composable is provided to help with generating previews on other locales.

The demo

For the demonstration, let's build a simple composable UI with some texts and buttons. This example will be demonstrating the Czech localization of the UI because Czech language has different plural expressions than English language.

@Composable
fun AppleCounter(
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(text = LocalStrings.current.greeting)
var count by rememberSaveable { mutableIntStateOf(0) }
Text(text = LocalStrings.current.apples(count))
Row {
Button(onClick = { count++ }) {
Text(text = "+")
}
Button(onClick = { count-- }) {
Text(text = "-")
}
}
Row {
Button(onClick = { AppLocale.value = "en" }) {
Text(text = "en")
}
Button(onClick = { AppLocale.value = "cz" }) {
Text(text = "cz")
}
}
}
}

@Preview(showBackground = true)
@Composable
private fun AppleCounterEnglishPreview() {
MaterialTheme {
LocaleProvider {
AppleCounter(modifier = Modifier.size(300.dp))
}
}
}

@Preview(showBackground = true)
@Composable
private fun AppleCounterCzechPreview() {
MaterialTheme {
LocaleProvider(localeOverride = "cz") {
AppleCounter(modifier = Modifier.size(300.dp))
}
}
}

With the localeOverride parameter, we can generate localized previews in Android Studio.

Android Studio rendering of AppleCounter composable in English and Czech languages

Also, the interactive preview feature of Android Studio allows to click on the buttons and watch how the localization and plural expressions works perfectly!

Android Studio rendering AppleCounter composable live, even with locale switching!

Summary

In the demo code, you can

  • Localize strings
  • Better placeholders and conditional substitutions
  • Better plural expressions
  • Supports annotated strings

However, this is not just limited to strings. You can localize images or even composables as well. Feel free to adapt this structure to better fit your needs!

Deep dive into the secrets of Compose

· 5 min read

Jetpack Compose is the modern technology built upon the solid foundation of Kotlin, which enables us to express the UI in a declarative way. The compose compiler abstracts and keeps us away from the heavy and complicated state management. In short, with Compose, we can express UI as a function of the underlying state and Compose compiler will do the rest for us.

The following questions came to my mind when I started learning Compose.

  1. How Compose do the state management under the hood?
  2. Why @Composable functions are not callable from outside of the compose world?
  3. How Compose remembers the state even if it has a functional approach?

The documentation somewhat answers these questions, however I took a reverse engineering approach to have a deep understanding of these points. For achieving that, I made a simple counter app in Compose.

@Composable
fun CounterApp() {
var count by remember {
mutableStateOf(0)
}
Column(modifier = Modifier.padding(8.dp)) {
Text(
text = count.toString(),
fontSize = 56.sp,
modifier = Modifier.align(Alignment.CenterHorizontally)
)
Row(modifier = Modifier.align(Alignment.CenterHorizontally)) {
Button(onClick = { count += 1 }) {
Text(text = "Increment")
}
Spacer(modifier = Modifier.width(8.dp))
Button(onClick = { count -= 1 }) {
Text(text = "Decrement")
}
}
}
}

This is how the app looks like:

For the sake of simplicity, let's keep this functional model in the mind:

CounterApp():
Column(...)
Text(...)
Row(...)
Button(...)
Spacer(...)
Button(...)

Reverse Engineering Compose app

To decompile the app, I used dex2jar for decompiling APK file, and jd-gui for decompiling JAR to human-readable source.

user@host:~$ ./dex-tools-2.1/d2j-dex2jar.sh app-debug.apk 
dex2jar app-debug.apk -> ./app-debug-dex2jar.jar

user@host:~$ java -jar ./jd-gui-1.6.6-min.jar app-debug-dex2jar.jar

In the JD-GUI window, navigate to the MainActivityKt.class file and look for the CounterApp() method. There we found that strangers in the party! -- the Composer paramComposer and int paramInt.

Decompiled Java code in JD-GUI app

These parameters injected by the compose compiler needs more explaination. The compose framework encourages the developer to have functional approach to the UI, instead of the object oriented "View" like approach. But the functional approach have some drawbacks, in the context of building UI:

  • The functions are called exactly in the order in the call stack.
  • They are called on the same thread of the caller, unless caller spawns a new thread for executing them.
  • The executed functions will not be executed later, unless they are called again.

These limitations needs to be resolved to develop a UI framework with the functional approach. That's why the Compose compiler injects these parameters. The compose compiler wraps the @Composable tree within restart groups, replaceable groups, movable groups, determined reusable nodes, and add checks to detect change in state values (aka "remembered" values). Also, the composer will take a reference to the @Composable function before returning to call it again when the runtime detects state changes (changes to the "remembered" values). This is how the compose compiler organises the @Composable tree with restart groups and replaceable groups:

CounterApp(..., composer):
composer.startRestartGroup(...)
if changed or not composer.skipping:
mutableState = composer.getRememberedValue()
if mutableState is empty:
mutableState = composer.getSnapshot()
composer.updateRememberedValue(mutableState)
composer.startReplaceableGroup(...)
Column(..., composer)
composer.startReplaceableGroup(...)
Text(..., mutableState.value.toString(), composer)
Row(..., composer)
composer.startReplaceableGroup(...)
Button(..., composer)
Spacer(..., composer)
Button(..., composer)
composer.endReplaceableGroup(...)
composer.endReplaceableGroup(...)
composer.endReplaceableGroup(...)
composer.endRestartGroup(...)
updateScope(&CounterApp)

This answers question #2. In the compiled code, the @Composable functions require the instance of Composer interface. That's why it cannot be called from outside of the @Composable context.

Restart groups vs Replaceable groups

To put this in the most simplest manner, restart groups are a unit of composable chunk which will be called again (aka "recomposed") when the state (aka "remembered" value) changes. The replaceable groups are @Composable tree which is replaced with a new tree when the state changes, because the change of state results in changes to the existing nodes, new-born nodes and deleted nodes. The compose will keep the remembered values scoped to the restart groups internally. Also, it will identify the state changes, compute the changes in the @Composable tree, determine the affected and unaffected nodes, and replace/reuse the affected nodes in the most efficient manner.

Optimizations

The compose compiler optimizes the recomposition in the most efficient way. From the reverse engineered code, we can find the compose compiler applies several optimizations in the code. Here are a few examples:

The compiler add code for checking changes in state, and fetch the most up-to-date state values.

Code for checking change in state

Also, the compiler tries to reuse the existing nodes to achieve maximum performance.

Code for reusing the nodes if possible

The most notable fact is, the compose runtime will collect a callable reference to the function and keeps it internally. The compose runtime will call it regardless of the order, or from any thread, whenever a recomposition is required.

Code for obtaining reference to the @Composable function for recomposing

This is how the @Composable function is executed regardless of the order of call or the state of call-stack.

It is so wonderful to see how Compose adheres to the functional way of doing things, even keeping a clear separation between the declarative UI and state. The higher degree of code reusability, simplicity and the powerful optimizations and state management under the hood makes the Jetpack Compose a productive, next generation UI framework.