This article demonstrates my favorite approach to referring string and plural resources from a view model - NativeText. Thanks to Alexey Bykov for suggesting me NativeText

Why

I use string and plural resources in a view model because of unit testing. My view layer is as straightforward as possible, there are no conditions nor cycles. I put all logic in View Models and write a fast and stable unit test for them.

Popular but not working solution

A popular solution is to use the Context in a view model directly or to create abstraction around it. You can see examples in the answers on the Stack Overflow. I call this solution ResourceProvider.

The ResourceProvider approach doesn’t fit the system lifecycle. It can’t handle a phone’s language changes. A view model isn’t recreated when a user changes the phone’s language, but a view is recreated. After configuration change, the view model contains text from the previous locale but the view displays text for a new one. You can read more about the issue in the article by Jose Alcérreca

Solution that works

Don’t keep text from resources in a view model. Keep a resource id:

data class Resource(@StringRes val id: Int) : NativeText()

and get the text by resource id on UI:

context.getString(id)

For string resource with arguments keep resource id and arguments:

data class Arguments(@StringRes val id: Int, val args: List<Any>) : NativeText()

and get the text on UI:

context.getString(id, *args.toTypedArray())

For plurals keep plural id, number, and arguments:

data class Plural(@PluralsRes val id: Int, val number: Int, val args: List<Any>) : NativeText()

and get the text on UI:

context.resources.getQuantityString(id, number, *args.toTypedArray())

Did you notice that all classes from example extend NativeText?

Instead of concatenation keep a list of NativeText:

data class Multi(val text: List<NativeText>) : NativeText()

and concatenate strings on UI:

val builder = StringBuilder()
for (t in text) {
    builder.append(t.toCharSequence(context))
}
builder.toString()

Put it all together and you will get NativeText.kt:

sealed class NativeText {
    data class Simple(val text: String) : NativeText()
    data class Resource(@StringRes val id: Int) : NativeText()
    data class Plural(@PluralsRes val id: Int, val number: Int, val args: List<Any>) : NativeText()
    data class Arguments(@StringRes val id: Int, val args: List<Any>) : NativeText()
    data class Multi(val text: List<NativeText>) : NativeText()
}

fun NativeText.toCharSequence(context: Context): CharSequence {
    return when (this) {
        is NativeText.Arguments -> context.getString(id, *args.toTypedArray())
        is NativeText.Multi -> {
            val builder = StringBuilder()
            for (t in text) {
                builder.append(t.toCharSequence(context))
            }
            builder.toString()
        }
        is NativeText.Plural -> context.resources.getQuantityString(id, number, *args.toTypedArray())
        is NativeText.Resource -> context.getString(id)
        is NativeText.Simple -> text
    }
}

Example

View model that always says “Hi”:

class ExampleViewModel: ViewModel(){
  val text = MutableLiveData<NativeText>(NativeText.Resource(R.String.example_hi))
}

Observe a live data in an activity and resolve text there:

viewModel.text.observe(this) { text
  textView.text = text.toCharSequence(this)
}

Unit testing

Unit testing is straightforward because a view model doesn’t interact with an Android Framework. Just compare a view model’s filed with an expected resource.

Here’s example of how I check logic inside a mapper:

@Test
fun `map movie that will be released tomorrow`() {
    val mapper = createMapper()
    val movie = createMovie(releaseDate = LocalDate.of(2021, Month.SEPTEMBER, 30))

    val listItem = mapper.map(movie)

    assertEquals(
        NativeText.Plural(R.plurals.movies_list_days_before_release, 1, listOf(1)),
        listItem.release
    )
}

The test isn’t perfect. I would prefer to see assertEquals("1 day before release", listItem.release) but the localization mechanism isn’t available on JVM that runs unit test, the localization is a part form the platform. All we can test is parameters for a specific case: resource ids, arguments, etc.