Table of Contents
Context
Sometimes there are projects where Android’s native text management system falls short. For example, if we wanted to fix a translation error, adjust an explanation or add a new language without having to deploy a new version of the application, the Android system would not allow us to do so.
In our case, we have an application with this problem. The client has a portal from which it manages translations and can modify texts or add new languages without having to deploy new versions of the application.
In this article we will focus on the Android part of the solution and how to adapt it to integrate with Jetpack Compose.
Preparation
Although the idea is not to use the Android system for translations, we are going to take advantage of it for a couple of things:
- Define the text keys to be translated.
- Provide fallback values in case of error.
So we will create a strings.xml file as normal, but we are not going to create one for each language, but we will focus on just one, the one we want to be shown in case a translation is not found. In our case, for example, it is English:
<resources>
<string name="app_name">My Application</string>
<string name="home_title">Home</string>
...
</resources>
The strings we define in this file must have the same name as those defined in the backend (as we will use it later) and the text to be displayed in case of error when getting a translation.
Translator
The translator is the class in charge of managing translations and providing other pieces of software with access to them.
class Translator(
private val context: Context,
private val api: TranslationApi,
private val database: TranslationDao,
private val cache: TranslationCache
)
The translator has 4 dependencies:
- context: to have access to the strings defined in the text file.
- api: to be able to download new translations
- database: for offline access to translations
- cache: for faster access to translations without the need to access the database.
When we open the application and start the translator it will take care of certain things
- Ask if there are new translations of the application language.
1.a) Update the database if there are any.
2. Load the database into the cache.
By default the translator will ask for the translations of the language the application is in, and in turn the application will be set as the language of the phone. In case it is not one of the available languages, the default language will be English. (For our case, other applications may have other requirements).
The call to the backend will not only answer with the translations, but will also return the latest version number. This same value is the one we should send to the backend the next time we open the application to check if there are any changes. If there are changes, the backend will send us only the new or modified texts, so we can update the database without having to download all the translations each time. In case of not being able to connect to the translation api, the last ones in the database will be used.
class Translator(
...
) {
suspend fun startup(language: String) {
...
}
fun translate(@StringRes resourceId: Int, vararg params: String) =
translate(getKey(resourceId), *params)
fun translate(key: String, vararg params: String) {
...
}
private fun getKey(@StringRes resourceId: Int) = context.resources.getResourceEntryName(resourceId)
}
(although not specified in the example you can add methods for translating plurals)
When a translation is requested the translator will work by default with the cache, using the other layers as fallback:
- We look for the key in the cache, if it fails:
- We look for the key in the database:
a. If found, we update the cache
b. If it fails:
3. We get the translation from the strings.xml file.
This flow ensures that we always go to the least expensive and truthful solution, making fallback to slower solutions only in case of failure and using the translation from the xml file only in case we don’t find that key.
Thanks to the getKey method we can get the name of a text from the strings.xml file using its identifier. This way we can make calls like:
translator.translate(R.string.home_title)
This ensures that we avoid typos when requesting translations and avoid requesting texts for which we do not have a viable fallback strategy.
When the user changes the language of the application, we can call the startup method with the new language selected, which will reproduce the startup cycle of the translator, but for the new language. This flow ensures that languages that the user will not use are never downloaded and stored in the database, reducing the data and memory consumption of the application.
The translate methods have a second parameter: params. Params is used for texts that require additional information, for example: “Hello, {0}” the translate method will replace all {0}, with the first parameter passed to params, {1} with the second, and so on.
Translating
When using the translator it is as simple as getting an instance and calling the appropriate translation method for each situation. The main problem is how to get that instance. We can use our favorite dependency injection framework to get it in a fragment or activity, but things get complicated when we want to translate the text inside an adapter, a custom view, or in a function that is not linked to a class. In our case koin allows us to implement the KoinComponent interface to have access to dependency injection, but it is not always possible or desirable to inject it at the point in the code where we are going to use it, and in this case, we have no choice but to pass the translator as a parameter.
...
val translator by inject<Translator>()
...
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
...
binding.homeScreenTitle.text = translator.translate(R.string.home_title)
...
}
Translating into Jetpack Compose
What was discussed above is a solution that works and actually in most cases looks pretty good. But Jetpack Compose offers another solution for managing translation: CompositionLocals.
We won’t go into too much detail on what a CompositionLocal is, but rather we’ll see what they are through their use.
First we will define the composition local:
val LocalTranslator = staticCompositionLocalOf<Translator> { error("CompositionLocal Translator not present") }
We have two ways to create a composition locals, static and normal. With static compositions there is no tracking of where it is used and a change in the value will cause all content to be repainted and not just where it is used. The advantage is that if the local composition will undergo little or no change in the life of the component, this lack of tracking makes it more efficient. In this case, the translator will never change and this allows us to make it static.
The lambda passed to the staticCompositionLocalOf function is in charge of providing a default value. In our case, as this is a complex component that requires other instances, we can’t provide one, so if we try to use it without a value provided, it will give an error.
So how do we provide a value?
CompositionLocalProvider(LocalTranslator provides translator) {
App()
}
This method is a composable that allows us to pass a lambda with the content we want the translator to have available. All compostables that are nested within this call will have access to the provided translator by accessing it as follows:
LocalTranslator.current
In case any part of the application requires a different translator (for example we want the language selection screen to work in a different way) we only need to provide a different translator in the root of that screen and all the compostables called inside it will have access to the new translator.
Finally, to simplify usage and not having to call LocalTranslator.current everywhere, we can create a new composable that refers everything but the translation to the text composable of compose:
@Composable
fun TranslatedText(
id: Int,
vararg params: String,
modifier: Modifier = Modifier,
color: Color = Color.Unspecified,
fontSize: TextUnit = TextUnit.Unspecified,
fontStyle: FontStyle? = null,
fontWeight: FontWeight? = null,
fontFamily: FontFamily? = null,
letterSpacing: TextUnit = TextUnit.Unspecified,
textDecoration: TextDecoration? = null,
textAlign: TextAlign? = null,
lineHeight: TextUnit = TextUnit.Unspecified,
overflow: TextOverflow = TextOverflow.Clip,
softWrap: Boolean = true,
maxLines: Int = Int.MAX_VALUE,
onTextLayout: (TextLayoutResult) -> Unit = {},
style: TextStyle = LocalTextStyle.current,
) {
Text(
LocalTranslator.current.translate(id, *params),
modifier,
color,
fontSize,
fontStyle,
fontWeight,
fontFamily,
letterSpacing,
textDecoration,
textAlign,
lineHeight,
overflow,
softWrap,
maxLines,
onTextLayout,
style,
)
}
With this and a few more like for the string cases noted above, we can use this composable to get translated text without any worries or support from our dependency injection framework.
@Composable
fun App() {
Scaffold(topBar = {
TranslatedText(R.string.home_title)
}) {
//TODO
}
}
Author
-
Software engineer with over 6 years of experience working as an Android Developer. Passionate about clean architecture, clean code, and design patterns, pragmatism is used to find the best technical solution to deliver the best mobile app with maintainable, secure and valuable code and architecture. Able to lead small android teams and helps as Android Tech Lead.
View all posts