How should I get Resources(R.string) in viewModel in Android (MVVM and databinding)
Asked Answered
C

13

115

I am currently using databinding and MVVM architecture for android. What would be the best way to get string resources in ViewModel.

I am not using the new AndroidViewModel component, eventbus or RxJava

I was going through the aproach of interfaces where Activity will be responsible for providing resources. But recently I found a similar question with this answer where a single class using application context is providing all resources.

Which would be the better approach? or is there something else that I can try?

Clustered answered 4/12, 2017 at 7:43 Comment(2)
What is the resource mean here? XML Values used for application like Strings or resources that used in programming like data or etc?Goulette
@EmreAktürk yes the XML values like stringClustered
V
46

You can access the context by implementing AndroidViewModel instead of ViewModel.

class MainViewModel(application: Application) : AndroidViewModel(application) {
    fun getSomeString(): String? {
        return getApplication<Application>().resources.getString(R.string.some_string)
    }
}
Vani answered 6/12, 2017 at 7:7 Comment(4)
Won't this create a bug on configuration change(like a Locale change). Since application's resources is not aware of these configuration changes?Lafollette
Actually google devs just posted a medium article about accessing resources in the viewmodel. medium.com/androiddevelopers/…Lafollette
DON'T DO IT! @11mo you're right it will create bug when user change device language, but ViewModel will have reference to obsolete language resources.Foregather
Prefer ViewModel over AndroidViewModel to avoid resource leaking.Oldfangled
H
30

Not at all.

Resource string manipulation belongs the View layer, not ViewModel layer.

ViewModel layer should be free from dependencies to both Context and resources. Define a data type (a class or enum) that ViewModel will emit. DataBinding has access to both Context and resources and can resolve it there. All you need is a plain static method that takes the enum and Context and returns String :

fun someEnumToString(type: MyEnum?, context: Context): String? {
    return when (type) {
        null -> null
        MyEnum.EENY -> context.getString(R.string.some_label_eeny)
        MyEnum.MEENY -> context.getString(R.string.some_label_meeny)
        MyEnum.MINY -> context.getString(R.string.some_label_miny)
        MyEnum.MOE -> context.getString(R.string.some_label_moe)
    }
}

(File is named MyStaticConverter.kt, so in Java and XML inline Java it's referred as "MyStaticConverterKt").

Usage in XML - context is a synthetic param, available in every binding expression:

<data>
    <import type="com.example.MyStaticConverterKt" />
</data>
...
<TextView
    android:text="@{MyStaticConverterKt.someEnumToString(viewModel.someEnum, context)}".

It may seem like "too much code in XML", but XML and bindings are the View layer. The only place for view logic - if you reject god-objects: Activities and Fragments.

In most cases, String.format is enough to combine resource string format with other data emitted by ViewModel. For more complicated cases (like mixing resource labels with texts from API) instead of enum use sealed class that will carry the dynamic String from ViewModel to the converter that will do the combining.

For the simplest cases, like the question, there is no need to invoke Context explicitly at all. The built-in adapter already interprets binding int to text as string resource id. The tiny inconvenience is that when invoked with null the converter still must return a valid ID, so you need to define some kind of placeholder like <string name="empty" translatable="false"/>.

@StringRes
fun someEnumToString(type: MyEnum?): Int {
    return when (type) {
        MyEnum.EENY -> R.string.some_label_eeny
        MyEnum.MEENY -> R.string.some_label_meeny
        MyEnum.MINY -> R.string.some_label_miny
        MyEnum.MOE -> R.string.some_label_moe
        null -> R.string.empty
    }
}

Yes, technically you could emit a @StringRes Int directly from a ViewModel, but that would make your ViewModel dependent on resources, so I strongly advise against it.

"Converters" (a collection of unrelated, static and stateless functions) is a pattern that I use a lot. It allows to keep all the Android's View-related types away from ViewModel and reuse of small, repetitive parts across entire app (like converting bool or various states to VISIBILITY or formatting numbers, dates, distances, percentages, etc). That removes the need of many overlapping @BindingAdapters and IMHO increases readability of the XML-inline Java.

Hypophyge answered 17/9, 2021 at 15:40 Comment(4)
How would this MyStaticConverter look like?Evalynevan
@Evalynevan added exampleHypophyge
@Hypophyge can you check my post #72049679 I am having issue using string resources in my viewmodelHierodule
@sashabeliy well, my point is precisely to avoid using String because it circumvents all compile-time checks.Hypophyge
A
24

You can also use the Resource Id and ObservableInt to make this work.

ViewModel:

val contentString = ObservableInt()

contentString.set(R.string.YOUR_STRING)

And then your view can get the text like this:

android:text="@{viewModel.contentString}"

This way you can keep the context out of your ViewModel

Abutter answered 16/11, 2018 at 0:5 Comment(4)
@SrishtiRoy sorry that should have said content string!Abutter
This requires DataBinding. Stay away from it because of the noise in XML.Oldfangled
What if the string has some parameters?Janinejanis
That's what I do when the textview only displays string resources as it is simple. It can't unfortunately be done this way when the text can come from both string and strings resources.Pinchbeck
P
18

an updated version of Bozbi's answer using Hilt

ViewModel.kt

@HiltViewModel
class MyViewModel @Inject constructor(
    private val resourcesProvider: ResourcesProvider
) : ViewModel() {
    ...
    fun foo() {
        val helloWorld: String = resourcesProvider.getString(R.string.hello_world)
    }
    ...
}

ResourcesProvider.kt

@Singleton
class ResourcesProvider @Inject constructor(
    @ApplicationContext private val context: Context
) {
    fun getString(@StringRes stringResId: Int): String {
        return context.getString(stringResId)
    }
}
Preciosity answered 27/5, 2021 at 12:27 Comment(5)
If the user changes the language settings of the app, wouldn't this approach return strings values on the basis of the previous user language choice? For e.g If I'm operating my app with the preferred language as English and later decide to change the language preference to Spanish, the ResourceProvider would still return English string literals.Six
instead of Singleton use ViewModelScopedCommination
Same problem even with ViewModelScoped, the ViewModel survives the configuration change (locale change)Cumulonimbus
This will leak the memory , since resourceProvider is property of view model , particular view model reference will be hold until resourceProvider reference gets release , resourceProvider doesn't release since it is single ton, all those view model will simple create memory impactAgra
@Agra I think that is incorrect. ResourcesProvider doesn't hold a reference to MyViewModel, so MyViewModel can be released from memory. I checked, and onCleared on the view model is indeed called.Ardine
I
10

Just create a ResourceProvider class that fetch resources using Application context. In your ViewModelFactory instantiate the resource provider using App context. You're Viewmodel is Context free and can be easily testable by mocking the ResourceProvider.

Application

public class App extends Application {

private static Application sApplication;

@Override
public void onCreate() {
    super.onCreate();
    sApplication = this;

}

public static Application getApplication() {
    return sApplication;
}

ResourcesProvider

public class ResourcesProvider {
private Context mContext;

public ResourcesProvider(Context context){
    mContext = context;
}

public String getString(){
    return mContext.getString(R.string.some_string);
}

ViewModel

public class MyViewModel extends ViewModel {

private ResourcesProvider mResourcesProvider;

public MyViewModel(ResourcesProvider resourcesProvider){
    mResourcesProvider = resourcesProvider; 
}

public String doSomething (){
    return mResourcesProvider.getString();
}

ViewModelFactory

public class ViewModelFactory implements ViewModelProvider.Factory {

private static ViewModelFactory sFactory;

private ViewModelFactory() {
}

public static ViewModelFactory getInstance() {
    if (sFactory == null) {
        synchronized (ViewModelFactory.class) {
            if (sFactory == null) {
                sFactory = new ViewModelFactory();
            }
        }
    }
    return sFactory;
}

@SuppressWarnings("unchecked")
@NonNull
@Override
public <T extends ViewModel> T create(@NonNull Class<T> modelClass) {
    if (modelClass.isAssignableFrom(MainActivityViewModel.class)) {
        return (T) new MainActivityViewModel(
                new ResourcesProvider(App.getApplication())
        );
    }
    throw new IllegalArgumentException("Unknown ViewModel class");
}

}

Incinerator answered 30/4, 2020 at 20:58 Comment(2)
Isn't 'Resources' class mockable?Bosco
Why not just use the Context in ViewModelFactory and just remove the ResourcesProvider class ?Plugugly
H
6

You can use the Resource Id to make this work.

ViewModel

 val messageLiveData= MutableLiveData<Any>()

messageLiveData.value = "your text ..."

or

messageLiveData.value = R.string.text

And then use it in fragment or activity like this:

messageLiveData.observe(this, Observer {
when (it) {
        is Int -> {
            Toast.makeText(context, getString(it), Toast.LENGTH_LONG).show()
        }
        is String -> {
            Toast.makeText(context, it, Toast.LENGTH_LONG).show()
        }
    }
}
Heirdom answered 25/7, 2019 at 11:47 Comment(0)
M
4

Kotlin solution without the usage of Androidviewmodel

sealed class StringValue {

    data class DynamicString(val value: String) : StringValue()

    object Empty : StringValue()

    class StringResource(
        @StringRes val resId: Int,
        vararg val args: Any
    ) : StringValue()

    fun asString(context: Context?): String {
        return when (this) {
            is Empty -> ""
            is DynamicString -> value
            is StringResource -> context?.getString(resId, *args).orEmpty()
        }
    }
}

in the ViewModel

private val _logMessage by lazy { MutableLiveData<StringValue>() }
val logMessage: LiveData<StringValue>
    get() = _logMessage

post resource id to the live data

_logMessage.postValue(StringResource(R.string.invalid_type))

in your fragment/activity, inside live data observer use it like this

   logMessage.observe(this@MainActivity) {
            debug(TAG, it.asString(this@MainActivity))
        }

For more details refer : https://medium.com/@margin555/using-string-resources-in-a-viewmodel-e334611b73da

Monosyllabic answered 14/3, 2023 at 11:13 Comment(0)
G
2

Ideally Data Binding should be used with which this problem can easily be solved by resolving the string inside the xml file. But implementing data binding in an existing project can be too much.

For a case like this I created the following class. It covers all cases of strings with or without arguments and it does NOT require for the viewModel to extend AndroidViewModel and this way also covers the event of Locale change.

class ViewModelString private constructor(private val string: String?,
                                          @StringRes private val stringResId: Int = 0,
                                          private val args: ArrayList<Any>?){

    //simple string constructor
    constructor(string: String): this(string, 0, null)

    //convenience constructor for most common cases with one string or int var arg
    constructor(@StringRes stringResId: Int, stringVar: String): this(null, stringResId, arrayListOf(stringVar))
    constructor(@StringRes stringResId: Int, intVar: Int): this(null, stringResId, arrayListOf(intVar))

    //constructor for multiple var args
    constructor(@StringRes stringResId: Int, args: ArrayList<Any>): this(null, stringResId, args)

    fun resolve(context: Context): String {
        return when {
            string != null -> string
            args != null -> return context.getString(stringResId, *args.toArray())
            else -> context.getString(stringResId)
        }
    }
}

USAGE

for example we have this resource string with two arguments

<string name="resource_with_args">value 1: %d and value 2: %s </string>

In ViewModel class:

myViewModelString.value = ViewModelString(R.string.resource_with_args, arrayListOf(val1, val2))

In Fragment class (or anywhere with available context)

textView.text = viewModel.myViewModelString.value?.resolve(context)

Keep in mind that the * on *args.toArray() is not a typing mistake so do not remove it. It is syntax that denotes the array as Object...objects which is used by Android internaly instead of Objects[] objects which would cause a crash.

Gyrose answered 30/1, 2021 at 11:18 Comment(2)
How can we test a view model that returns ViewModelString?Playbook
I've created a library that follows this model (creates a string representation with a resource id, without context, and provides a resolve method) that also supports formatting and basic styles, it may be helpful to someone: github.com/jvlppm/android-textGuatemala
G
1

I don't use data bindig but I guess you can add an adapter for my solution.

I keep resource ids in the view model

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

and get text on a view layer.

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

You can read more about native text in the article

Gentlemanfarmer answered 2/10, 2021 at 12:52 Comment(0)
L
0

For old code which you don't want to refactor you can create an ad-hoc class as such

private typealias ResCompat = AppCompatResources

@Singleton
class ResourcesDelegate @Inject constructor(
    @ApplicationContext private val context: Context,
) {

    private val i18nContext: Context
        get() = LocaleSetter.createContextAndSetDefaultLocale(context)

    fun string(@StringRes resId: Int): String = i18nContext.getString(resId)

    fun drawable(@DrawableRes resId: Int): Drawable? = ResCompat.getDrawable(i18nContext, resId)

}

and then use it inside your AndroidViewModel.

@HiltViewModel
class MyViewModel @Inject constructor(
    private val resourcesDelegate: ResourcesDelegate
) : AndroidViewModel() {
    
    fun foo() {
        val helloWorld: String = resourcesDelegate.string(R.string.hello_world)
    }
Leonardo answered 17/11, 2021 at 14:48 Comment(1)
it's similar to @Ananthakrishnan K R answer. it wouldn't survive with config changesLarceny
U
0

If you are using Dagger Hilt then @ApplicationContext context: Context in your viewModel constructor will work. Hilt can automatically inject application context with this annotation. If you are using dagger then you should provide context through module class and then inject in viewModel constructor. Finally using that context you can access the string resources. like context.getString(R.strings.name)

Uphroe answered 2/12, 2021 at 9:14 Comment(0)
A
0

You should use something like "UIText" sealed class to mange it all over your project(as Philip Lackner have done).

sealed class UIText {
    data class DynamicString(val value:String):UIText()
    class StringResource(
        @StringRes val resId: Int,
        vararg val args: Any
     ):UIText()

    @Composable
    fun asString():String{
        return when(this){
            is DynamicString -> value
            is StringResource -> stringResource(resId, *args)
        }
    }
}

Then everyWhere in your project in place of String, use UIText.StringResource easily

Ardeha answered 12/2, 2023 at 8:54 Comment(0)
C
-2

Still don't find here this simple solution:

android:text="@{viewModel.location == null ? @string/unknown : viewModel.location}"
Chamkis answered 18/2, 2022 at 20:44 Comment(1)
Don't put code in XML...Cumulonimbus

© 2022 - 2024 — McMap. All rights reserved.