Android — Localize text in ViewModel

Dorian Pavetić
7 min readAug 25, 2024

When adopting MVVM pattern in Android, it is very often required to create complex texts and labels. General practice is to avoid using Context in ViewModels, so for simple cases, ViewModel can return String resource ID to the presentation layer which can then be resolved using Context — e.g. in Fragment or Activity. For more advanced cases, when concatination of several String resources or additional operations on those strings are required, this would not be an ideal option.

To solve this problem, I developed Kotlin interface that supports creating complex texts in ViewModel (or any other component) without depending on actual Context. Text is created in desired component, and is then resolved properly when needed in any Android UI component, using actual Context instance. I have called this interfaceUiTransferableText and is implemented by various use cases. This ensures that no processing and business logic is performed in the UI layer and improves separation of concerns.

Lets being building our UiTransferableText. Interface has a single getText method that is used to resolve/localize containerized data:

interface UiTransferableText : Serializable {
/**
* Resolve/translate given text.
*
* @param context Required context to resolve texts from resources.
* @return translated and localized text.
*/

fun getText(context: Context): CharSequence
}

To support arguments for multiple cases (e.g. plurals and strings), lets create additional interface:

/**
* Transferable text that accepts arguments and enables resolving them recursively
*/

interface Argumentable {
val args: List<Any>

fun resolveArgs(context: Context) =
args.map {
if (it is UiTransferableText)
it.getText(context)
else
it
}
}

This interface contains common fun resolveArgs which enables resolving nested arguments, in case other UiTransferableText is passed as argument to another UiTransferableText.

In some cases, additional case transformations on translated (resolved) text might be needed. To support that, let’s create CaseTranformationType as enum. As code is not actually necessary for this article, you can check it out here.

Note: This step is optional and can be skipped, but in later snippets caseTransformType argument shall be removed and adjusted.

Now, let’s start implementing use cases.

String resource with optional arguments

/**
* Container for [StringRes] with optional arguments and case transformation.
*
* @property stringResId ID of the String resource.
* @property caseTransformType Optional case transformation type of
* the translated text.
* @property args Arguments to supply to given [stringResId].
*/

data class StringResId(
@JvmField
@StringRes
val stringResId: Int,
val caseTransformType: CaseTransformType?,
override val args: List<Any>
) : UiTransferableText, Argumentable {
override fun getText(context: Context) : String {
// Resolves arguments texts if argument is UiTransferableText
val resolvedArgs = resolveArgs(context)

val text = if (args.isEmpty())
context.getString(this.stringResId)
else
context.getString(this.stringResId, *resolvedArgs.toTypedArray())
return caseTransformType?.transform(text) ?: text
}
}

This class implements both UiTransferableText and Argumentable and implements a single fun getText(context: Context) which is used to resolve this text. Underlying Argumentable.resolveArgs() is used to resolve potentially nested UiTransferableText arguments. After that, when arguments are resolved as actual localized text, additional case transformation can be performed using caseTransformType if it is provided. That final text is then return.

For simplified usage, lets add companion object with static functions to our UiTransferableText interface:

companion object {
fun stringResId(@StringRes value: Int, vararg args: Any) =
stringResId(value, null, *args)
fun stringResId(@StringRes value: Int, caseTransformType: CaseTransformType?, vararg args: Any) =
StringResId(value, caseTransformType, args.toList())
...
}

Usage examples:

val textNoArgs = UiTransferableText.stringResId(R.string.formatted_heated_pool)
val textWithArgs = UiTransferableText.stringResId(R.string.formatted_pool_temp, "32 F")
// Argument name 'caseTransformType' is required to prevent ambiguity
val textWithArgsAndCaseTransform = UiTransferableText.stringResId(
R.string.formatted_pool_temperature,
caseTransformType = UiTransferableText.CaseTransformType.CAPITALIZE,
"32 F"
)

Plural resource with optional arguments

/**
* Container for [PluralsRes] with optional arguments and case transformation.
*
* @property pluralsResId ID of the Plural resource.
* @property caseTransformType Optional case transformation type of
* the translated text.
* @property quantity Quantity to use for [pluralsResId].
* @property args Arguments to supply to given [pluralsResId].
*/

data class PluralsResId(
@JvmField
@PluralsRes
val pluralsResId: Int,
val caseTransformType: CaseTransformType?,
val quantity: Int,
override val args: List<Any>
) : UiTransferableText, Argumentable {
override fun getText(context: Context) : String {
// Resolves arguments texts if argument is UiTransferableText
val resolvedArgs = resolveArgs(context)

val text = if (args.isEmpty())
context.resources.getQuantityString(this.pluralsResId, quantity)
else
context.resources.getQuantityString(this.pluralsResId, quantity, *resolvedArgs.toTypedArray())
return caseTransformType?.transform(text) ?: text
}
}

This implementation uses basically the same logic as StringResId, with a small difference that resource is not resolved using Context.getString but rather using Resources.getQuantityString which is used specifically for plurals. To support pluralization, this implementation has additional quantity argument which is used to resolve proper pluralization instance based on that argument.

We are also gonna create static instance initializing functions in same companion object mentioned above:

companion object {
fun pluralsResId(@PluralsRes value: Int, quantity: Int, vararg args: Any) =
pluralsResId(value, null, quantity, *args)
fun pluralsResId(@PluralsRes value: Int, caseTransformType: CaseTransformType?, quantity: Int, vararg args: Any) =
PluralsResId(value, caseTransformType, quantity, args.toList())
}

Usage examples:

// Resolves as "Item" for 1 quantity and "Items" for more
val textNoArgs = UiTransferableText.pluralsResId(R.plurals.plural_item, 3)
// Resolves as "1 item" for 1 quantity and "n items" for more (in this case n=3)
val textWithArgs = UiTransferableText.pluralsResId(R.plurals.formatted_plural_item, 3, 3)
// Resolves as "item count: one" for 1 quantity and "items count: n" for more (in this case n=3)
val textWithArgsAndCaseTransform = UiTransferableText.pluralsResId(
R.plurals.formatted_plural_item_counts,
UiTransferableText.CaseTransformType.DECAPITALIZE,
3,
3
)

Plain non-translatable text

For some cases, it is enough to pass text that is not localized but in the given UiTransferableText container. This can also be used for text that has already been localized/translated, for example translation is provided by the API call or from database.

/**
* Container for plain text as is - [CharSequence] used to support
* passing spannable texts or other [CharSequence] subtypes.
*
* - Used for general purpose and non-translatable texts.
*
* @property text Text to resolve.
*/

@JvmInline
value class Text(
@JvmField
val text: CharSequence
) : UiTransferableText {
override fun getText(context: Context) = text
}

As you see, text is resolved right away and in this fun context is not required, but must be supplied anyway to conform to the given interface protocol.

Combined UiTransferableTexts

Some cases also require to combine multiple UiTranferableTexts without using them as arguments, and also joining/concating them with specific separator.

/**
* Generic container for combining multiple [UiTransferableText]
* by given [separator].
*
* @property separator Separator used to join [texts].
* @property texts List of [UiTransferableText] to join in a single text.
*/

data class CombinedText(
val separator: CharSequence,
val texts: List<UiTransferableText>
) : UiTransferableText {
override fun getText(context: Context): String =
texts.joinToString(separator) { it.getText(context) }
}

...


companion object {
...

fun combined(vararg texts: UiTransferableText) = combined(texts.toList())
fun combined(separator: CharSequence, vararg texts: UiTransferableText) =
combined(texts.toList(), separator)
fun combined(texts: List<UiTransferableText>, separator: CharSequence = ", ") =
CombinedText(separator, texts)

...
}

This implementation allows combining multiple UiTranferableText in single text where each instance is single sentance, or by new lines. Examples:

// Resolves to "First text. Second text"
val singleLineText = UiTransferableText.combined(
". ",
UiTranferableText.text("First text"), UiTranferableText.text("Second text")
)
// Resolves to "First text, second text"
val singleLineTextComma = UiTransferableText.combined(
", ",
UiTranferableText.text("First text"), UiTranferableText.text("second text")
)
/* Resolves to "
First line.
Second line"
*/

val multiLineText = UiTransferableText.combined(
".\n",
UiTranferableText.text("First line"), UiTranferableText.text("Second line")
)

For previous implementations, few simple examples were given. Now, let’s try using it in a real-world scenario.

For example, lets say we want to determine text to show when pets are allowed, and there is multiple cases with multiple restrictions. In some cases, stay of pets might be restricted based on weight, or based on pets number. Additionally, only 1 pet might be allowed, or multiple pets might be allowed. Therefore, different texts might be used, either StringResId or PluralResId. Below example shows how multiple implementations can be used, while common UiTranferableText interface is returned.

private fun getAllowedPetsTypesWithRestrictionText(
petPolicy: PetPolicy,
/** "Dog"/"Cat"/"Pet"... */
allowedTypeAppendableText: UiTransferableText.StringResId
)
: UiTransferableText {
val maxPetQuantity = petPolicy.maxPetQuantity
val maxTotalPetWeightKg = petPolicy.maxTotalPetWeightKg

return if (maxPetQuantity != null && maxTotalPetWeightKg != null) {
// a) maxPetQuantity > 1 --> Restricted to more than 1 pet and N kg
// - "Stay of up to X pets per room of total weight N kg is allowed"
// b) maxPetQuantity == 1 --> Restricted to 1 pet and N kg
// - "Stay of one pet of total weight of N kg per room is allowed"
UiTransferableText.pluralsResId(
value = R.plurals.formatted_pets_allowed_quantity_and_weight,
caseTransformType = UiTransferableText.CaseTransformType.CAPITALIZE,
maxPetQuantity,
allowedTypeAppendableText,
getAllowedPetsNameQuantified(petPolicy, maxPetQuantity),
maxTotalPetWeightKg.formattedShort()
)
} else if (maxPetQuantity != null) {
// a) maxPetQuantity > 1 --> Restricted to more than 1 pet (without weight)
// - "Stay of up to X pets per room is allowed"
// b) maxPetQuantity == 1 --> Restricted to 1 pet (without weight)
// - "Stay of one pet per room is allowed"
UiTransferableText.pluralsResId(
value = R.plurals.formatted_pets_allowed_quantity_only,
caseTransformType = UiTransferableText.CaseTransformType.CAPITALIZE,
maxPetQuantity,
allowedTypeAppendableText,
getAllowedPetsNameQuantified(petPolicy, maxPetQuantity)
)
} else if (maxTotalPetWeightKg != null) {
// Restricted to N kg (without quantity)
// - "Stay of pets with total weight of N kg per room is allowed"
UiTransferableText.stringResId(
value = R.string.formatted_pets_allowed_weight_only,
caseTransformType = UiTransferableText.CaseTransformType.CAPITALIZE,
allowedTypeAppendableText,
getAllowedPetsName(petPolicy),
maxTotalPetWeightKg.formattedShort()
)
} else {
// No quantity nor weight restrictions
// - "Stay of pets is allowed"
UiTransferableText.stringResId(
value = R.string.formatted_pets_allowed_no_restrictions,
caseTransformType = UiTransferableText.CaseTransformType.CAPITALIZE,
allowedTypeAppendableText,
getAllowedPetsName(petPolicy)
)
}
}

To show given text in UI it is enough to use it like this:

val dogTypeText = UiTransferableText.stringResId(R.string.dog)
binding.textView.text = mViewModel.getAllowedPetsTypesWithRestrictionText(petPolicy, dogType).getText(requireContext())

As you see, no additional processing is required on the presentation/UI layer, as everything is performed in ViewModel. This approach utilizes MVVM approach and improves separation of concerns.

Thank you for your time and reading, these were some simple use cases. Additional use cases and implementations could be found in library which also contains UI extension functions, TranslatableMap for user-input translatable content and more. Check it out here:

https://github.com/dorianpavetic/ui-transferable-text

Feel free to contribute.

Sign up to discover human stories that deepen your understanding of the world.

Dorian Pavetić
Dorian Pavetić

Written by Dorian Pavetić

Electrical engineering student and development enthusiast.

Responses (1)

Write a response

I like the idea, apparently I like it so much that I also have written an article on it recently :)
https://proandroiddev.com/how-to-properly-handle-android-localization-a43355f2fb32
I just wanted to mention, that if you have a value class and you…