Resolving string resources too early can bring trouble. I will show you a proper way to handle localization in your app, reusable in all of your app layers. Let’s answer the question: How do we feed localised texts from our business logic to our UI?
Avoid using platform dependencies
We usually want to avoid using platform dependencies in our business logic. Our domain layer usually doesn’t need to know anything about the platform: It should be platform agnostic, making it easier to test and even reusable on different platforms. You will find out during testing that injecting a Context everywhere to resolve some strings is a nightmare, as it can cause testing a simple object to become much more complex. A ViewModel also shouldn’t rely on any platform specific code (obviously other than extending from the Jetpack Compose Architecture ViewModel component), but often we do wish to calculate string values somehow in this layer.
Localization and configuration changes
A ViewModel will survive configuration changes, including the change of language. That means that if you resolve a string from the ViewModel, the text on the UI will not be updated if you change the language of your app. This can be demonstrated with the following:
class MyViewModel : ViewModel(context: Context) {
private val _viewState = MutableStateFlow(
ViewState(text = context.getString(R.string.example_text))
)
val viewState = _viewState.asStateFlow()
}
@Composable
fun ExampleComposable(modifier: Modifier = Modifier) {
val context = LocalContext.current
val localeOptions = remember {
mapOf(
"en" to R.string.language_en,
"nl" to R.string.language_nl,
)
}
val viewModel = viewModel<ExampleViewModel> {
ExampleViewModel(context.applicationContext)
}
val viewState by viewModel.viewState.collectAsState()
val currentLanguageTag by rememberUpdatedState(
context.resources.configuration.locales[0].toLanguageTag()
)
val newLanguageTag = remember(currentLanguageTag, localeOptions) {
localeOptions.keys.let { countryCodes ->
val currentIndex = countryCodes.indexOfFirst { countryCode ->
countryCode.equals(currentLanguageTag, ignoreCase = true)
}
countryCodes.toList()[currentIndex.plus(1) % countryCodes.size]
}
}
Column(
modifier = modifier
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = spacedBy(16.dp, CenterVertically)
) {
Row {
Text("ViewModel: ")
Text(viewState.text)
}
Row {
Text("Composable: ")
Text(stringResource(R.string.example_text))
}
Button(
onClick = {
AppCompatDelegate.setApplicationLocales(
LocaleListCompat.forLanguageTags(newLanguageTag)
)
}
) {
Text(
stringResource(
R.string.choose_next_language,
stringResource(localeOptions.getValue(newLanguageTag)),
)
)
}
}
}
This will result in the following amazing screen where you can see that the text we resolved in the ViewModel is not changing when the app language changes. However, the language resolved inside the Composable is changed:
In my project, we often have to display the name of a backend-received piece of data, or use a string resource as a fallback. We also need to provide strings with placeholders for formatted dates, times, currencies and others. On top of that, we need to be able to construct texts based on certain logic and we obviously love to be able to test everything. Pretend we do a slightly fancier way of keeping the data in our ViewModel and map the UI with a mapper:
class ExampleViewModel(
mapper: ExampleMapper,
user: User = User(userName = null, email = null),
) : ViewModel() {
private val _viewState = MutableStateFlow(mapper.map(user))
val viewState = _viewState.asStateFlow()
}
class ExampleMapper(
private val context: Context
) {
fun map(user: User) = ExampleViewState(
text = user.userName?.let { userName ->
context.getString(R.string.user, userName)
} ?: context.getString(R.string.unknown_user),
email = user.email ?: context.getString(R.string.unknown_email),
)
}
You can see that we have a fake user: In real life you may want to load or pass the user into your ViewModel: maybe this article can help you in deciding how to load data. If we want, we can test it. In order to test this using Mockk and JUnit5 we can do the following:
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class ExampleMapperTest {
private val mockContext = mockk<Context>()
private val mapper = ExampleMapper(mockContext)
@BeforeEach
fun init() {
clearAllMocks()
}
@Test
fun `Check we get the user and email string`() {
every { mockContext.getString(R.string.user, USER_NAME) } returns STRING_WITH_USER
assertEquals(
ExampleViewState(STRING_WITH_USER, EMAIL),
mapper.map(User(USER_NAME, EMAIL)),
)
}
@Test
fun `Check we get unknown if the user has no username or email`() {
every { mockContext.getString(R.string.unknown_user) } returns UNKNOWN_USER
every { mockContext.getString(R.string.unknown_email) } returns UNKNOWN_EMAIL
assertEquals(
ExampleViewState(UNKNOWN_USER, UNKOWN_EMAIL),
mapper.map(User(null, null)),
)
}
companion object {
private const val USER_NAME = "Joost"
private const val EMAIL = "joost@email.com"
private const val STRING_WITH_USER = "STRING_WITH_USER"
private const val UNKNOWN_USER = "UNKNOWN_USER"
private const val UNKNOWN_EMAIL= "UNKNOWN_EMAIL"
}
}
You can see we have to handle the mock: we take care that we only instantiate one mock and clear any configuration on it before every run, as it would be more expensive to recreate the mock for every time a test case runs. Then we have to set up the context to return some crappy values and verify that we used the right R.string key: We anyway will not resolve the actual string, I think we can do better!
Introducing Translatable
We can also simply wrap the resources we use inside another class! We can then either pass a string, a string resource, plural, whatnot and resolve it at runtime. We can call this amazing class Translatable: Because we can translate it! Without further ado, here is a simple implementation, tailored around my projects’ needs:
@Immutable
sealed interface Translatable {
data class Resource(
@StringRes val id: Int,
val params: List<Any> = emptyList(),
) : Translatable {
constructor(@StringRes id: Int, vararg params: Any) : this(id, params.toList())
}
data class Plural(
@PluralsRes val id: Int,
val count: Int,
val params: List<Any> = emptyList(),
) : Translatable {
constructor(
@PluralsRes id: Int,
count: Int,
vararg params: Any,
) : this(id, count, params.toList())
}
data class StringValue(
val value: String,
) : Translatable
data class Multiple(
val translations: List<Translatable>,
val separator: String = "",
) : Translatable {
constructor(
vararg resources: Translatable,
separator: String = "",
) : this(resources.toList(), separator)
}
}
You can see I have implemented it around the Android’s normal String resource and the Plural resource. On top of that I have added the ability to add raw strings, as well as a list of multiple Translatables which can be joined with a separator.
Usage
The API is simple to use and is it created to mimic the resolution of a String using either Context.getString(resId, ...params)
or the Composable’s stringResource(resId, ...params)
. On top of that, we provide some simple extension functions to turn a normal String into a Translatable, or to flatten a list of Translatables into a Translatable.Multple:
fun String.toTranslatable() = Translatable.StringValue(this)
fun List<Translatable>.flatten(separator: String = ""): Translatable
= Translatable.Multiple(this, separator)
Let’s update our ExampleViewState and ExampleMapper with the new Translatable:
data class ExampleViewState(
val text: Translatable,
val email: Translatble,
)
class ExampleMapper {
fun map(user: User): ExampleViewState {
val text = user.userName?.let { userName ->
Translatable.Resource(R.string.user, userName)
} ?: Translatable.Resource(R.string.unknown_user)
val email = user.email.toTranslatable()
?: Translatable.Resource(R.string.unknown_email)
return ExampleViewState( text = text, email = email)
}
}
Job Offers
We have successfully gotten rid of the strings and the context! Also: we can easily switch between hard-coded strings or translatable resources.
class ExampleMapperTest {
@Test
fun `Check we get the user and email string`() {
assertEquals(
ExampleViewState(
text = Translatable.Resource(R.string.user, USER_NAME),
email = EMAIL.toTranslatable(),
),
ExampleMapper().map(User(USER_NAME, EMAIL)),
)
}
@Test
fun `Check we get unknown if the user has no username or email`() {
assertEquals(
ExampleViewState(
text = Translatable.Resource(R.string.unknown_user),
email = Translatable.Resource(R.string.unknown_email),
),
ExampleMapper().map(User(null, null)),
)
}
companion object {
private const val USER_NAME = "Joost"
private const val EMAIL = "joost@email.com"
}
}
No more context mocking, no more managing of mocks, setting up fake answers: yet our logic is still fully tested! So lets see how to resolve it.
Resolving a Translatable
We of course can translate any Translatable if we have a resources object. We will write the following extension functions that will help us to translate a Translatable when we use it from regular code, but also an extension function that will help us when we are inside a Composable:
fun Translatable?.translate(
resources: Resources,
): String = when (this) {
null -> ""
is Translatable.Resource -> resources.getString(
id,
*params.map {
if (it is Translatable) {
it.translate(resources)
} else {
it
}
}.toTypedArray(),
)
is Translatable.Plural -> resources.getQuantityString(id, count, *params.toTypedArray())
is Translatable.StringValue -> value
is Translatable.Multiple -> translations.joinToString(
separator = separator,
) {
it.translate(resources)
}
}
/**
* Get resources from the current composable.
* Directly inspired by stringResource(..)
*/
@Composable
@ReadOnlyComposable
internal fun resources(): Resources {
LocalConfiguration.current
return LocalContext.current.resources
}
@Composable
@ReadOnlyComposable
fun Translatable?.translate() = translate(resources())
So with all this, we can simply write the following Composable where we use the new Translatable! There is also the extremely helpful way how we resolve the extra parameters: If they are a Translatable, we will resolve them as well! This is very helpful if you for example have a formatter that returns a Translatable that you want to pass as a parameter to a string resource, like <string name="remaining_duration">Remaining: %s</string>
. In this case we can use it like the following:
fun map(data: Data) = Translatable.Resource(
R.string.remaining_duration,
durationFormatter.format(data), // <- durationFormatter returns Translatable
)
Cherry on top: As we marked the sealed interface with @Immutable, we can pass it anywhere we want and not have to worry about unwanted re-compositions. We can now update our earlier Composable example:
@Composable
fun ExampleComposable(modifier: Modifier = Modifier) {
// Setup the composable like the first example
// viewModel, langauge stuff
// etc...
Column(
modifier = modifier
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = spacedBy(16.dp, CenterVertically)
) {
Text(viewState.text.translate()) // <-- translate()
Text(viewState.email.translate()) // <-- translate()
TextButton(
text = Translatable.Resource(
R.string.choose_next_language,
stringResource(localeOptions.getValue(newLanguageTag)),
),
onClick = {
AppCompatDelegate.setApplicationLocales(
LocaleListCompat.forLanguageTags(newLanguageTag)
)
}
)
}
}
@Composable
fun TextButton(
text: Translatable, // Translatable is @Immutable
onClick: () -> Unit,
) {
Button(
onClick = onClick,
) {
Text(text.translate())
}
}
And of course, this will give the following:
Final tips and words
On my project we no longer pass string values to our components: we make them reusable by passing a Translatable instead. This makes them very easy to use in any kind of situation and causes us to no longer care where the value is actually coming from. We are still using stringResource(..) when we have a non-reusable component and a static string resource.
And of course there if a GitHub repository containing the example code!
I hope this short article helped to shine some light on optimizing your developers journey. I enjoyed writing it all down, please let me know what you think in the comments. And as always: if you like what you see, please put those digital hands together! Joost out.
This article is previously published on proandroiddev.com