
Android — Localize text in ViewModel
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 UiTranferableText
s 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.