How to get a RecyclerView Item to update itself from a LiveData value in it's binding object using BindingAdapter? - kotlin

I have a recyclerview.widget.ListAdapter to display some cards in a grid. When I click on a card, it comes to the front of the grid, flips, and when you see the front, you are supposed to be able to like it. I have an ImageView on the corner of the front of the card, that is supposed to toggle between liking or not, displaying a different drawable that I have already defined.
My ViewHolder looks like this:
class GameCardViewHolder(private val binding : ItemGameCardBinding) :
RecyclerView.ViewHolder(binding.root){
companion object {
fun from(parent: ViewGroup): GameCardViewHolder {
val layoutInflater = LayoutInflater.from(parent.context)
val binding = ItemGameCardBinding.inflate(layoutInflater, parent, false)
return GameCardViewHolder(binding)
}
...
}
fun bind(productCard: ProductCard, listener: OnClickListener){
binding.productCard = productCard
binding.executePendingBindings()
...
}
}
My item_game_card.xml looks like this:
<layout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<data>
<variable
name="productCard"
type="company.app.model.ProductCard" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout>
...
<ImageView
android:id="#+id/gameItemLikeImageView"
android:layout_width="0dp"
android:layout_height="0dp"
...
app:imageDrawable="#{productCard.liked}"
android:onClick="#{() -> productCard.toggleLike()}"
/>
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
My BindingAdapters.kt:
#BindingAdapter("imageDrawable")
fun bindImageDrawable(imgView: ImageView, boolean: LiveData<Boolean>?) {
Timber.d("Binding liked: $boolean")
when(boolean?.value){
true -> imgView.setImageResource(R.drawable.ic_card_like)
false -> imgView.setImageResource(R.drawable.ic_card_dislike)
}
}
And my ProductCard.kt:
data class ProductCard(
val position: Int,
val product: Product
){
...
private val _liked = MutableLiveData(false)
val liked : LiveData<Boolean>
get() = _liked
fun toggleLikeProduct(){
Timber.d("Toggle like before: ${_liked.value}")
val oldValue = _liked.value!!
_liked.value = !oldValue
Timber.d("Toggle like after: ${_liked.value}")
}
}
When I click on the ImageView, the console prints out the before and after values every time correctly, so I know the value of the liked LiveData is actually changing, but the View is not updating accordingly, that is because the binding adapter is only being called once (in the binding.executePendingBindings() I guess, when the RecyclerView binds the ViewHolder, and not every single time the LiveData changes).
I want the BindingAdapter code to be run every time the liked LiveData changes.
When I do this with ViewModel and Fragment layout, every time a LiveData changes in the ViewModel, the View changes because the xml is referencing that LiveData, but I don't know why, when it comes to ProductCard and recyclerview item layout, even if the xml is referencing that LiveData, the View doesn't change, the xml is not actually binding to the LiveData, which is the whole point of having LiveData.
I have already acheived this behaviour with a very brute, unelegant sort of way, setting an onClickListener on that view, and forcing it to change it's resource drawable, on the OnBindViewHolder like this:
fun bind(productCard: ProductCard, listener: OnClickListener){
binding.productCard = productCard
binding.executePendingBindings()
binding.gameItemLikeImageView.setOnClickListener {
it?.let {
val card = binding.productCard
Timber.d("Tried changing liked status: ${card!!.liked}")
val old = card.liked
card.liked = !old
binding.productCard = card
val card2 = binding.productCard
Timber.d("Tried changing liked status: ${card2!!.liked}")
if (!old){
it.setBackgroundResource(R.drawable.ic_card_like)
}
else {
it.setBackgroundResource(R.drawable.ic_card_dislike)
}
}
}
...
}
But I would really like that the behaviour of each card be controlled by the state of the ProductCard object that it is bind to.
I have also tried to force call binding.executePendingBindings() every time I click, but also didn't work.
How can I make the item_game_card.xml to observe the changes in the LiveData?

app:imageDrawable won't accept any LiveData<Boolean> and I'd wonder why it isn't LiveData<Product>? Try to bind the initial value alike this:
app:imageDrawable="#{product.liked ? R.drawable_liked : R.drawable_neutral}"
For LiveData the LifecycleOwner needs to be set for the binding:
class ProductsActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
// Inflate view and obtain an instance of the binding class.
val binding: ProductsBinding = DataBindingUtil.setContentView(this, R.layout.products)
// Specify the current activity as the lifecycle owner.
binding.setLifecycleOwner(this)
}
}
see the documentation.

Related

Update value in an activity from an adapter (Kotlin)

New to Kotlin and to OOP in general
I have a TextView in my MainActivity which is linked to a var
var int = 0
findViewById<TextView>(R.id.textView).setText("$var")
The thing is I want to modify this var inside and adapter.
class Adapter(
var myContext: Context,
var resource: Int,
var values: ArrayList<List>
) : ArrayAdapter<List>(myContext, resource, values) {
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
val element = values[position]
val view = LayoutInflater.from(myContext).inflate(resource, parent, false)
findViewByID<Button>(R.id.button).setOnClickListener{
int ++
View(myContext).findViewByID<TextView>(R.id.textView).setText("$var")
//here I get an error
}
}
}
I don’t know if this is clear enough. I saw I can use an interface Class but the thing is I don’t want to pass a data but to update one that already exists.
You are using ArrayAdapter. If you are going to use adapters, you should use RecyvlerView.Adapter. Since you are learning, this is a perfect point to scratch that and learn Compose instead, with that out of the way.
The interface solution is the correct solution:
interface MyAdapterCallback {
fun onButtonClicked(clickCount: Int)
}
Then implement it in your activity
class MyActivity : ..., MyAdapterCallback {
override fun onButtonClicked(clickCount: Int) {
findViewById<TextView>(R.id.textView).setText("$clickCount")
}
}
You have to pass it to your adapter, this should be the same for array adapter or recycler, because is passing it in the constructor and then using it.
class Adapter(
...,
private val callback: MyAdapterCallback
) {
...setOnClickListener{
int ++ //this will work as long as int here is a valid thing
callback.onButtonClicked(int)
}
}
You might see this pattern with the delegate naming instead, I'm using callback just to try to make it clearer.
Another dirty solution would be to, pass the view to the adapter.
class Adapter(
...,
private val myView: TextView
) {
...setOnClickListener{
int ++ //this will work as long as int here is a valid thing
myView.text = "$int"
}
}
That is a very bad solution because it breaks the separations of concern principle, you should use it only for debugging.
Finally, the problem that you are currently having is this:
View(myContext).findViewByID<TextView>(R.id.textView).setText("$var")
That is instantiating a new View and inside that View you are trying to find the R.id.textView, that view is completely new so it has nothing inside. Your R.id.textView is in the activity layout, a completely different view. So the method findViewByID returns null. However you declare that the method should found non null TextView that why it crashes, if you change it to TextView? then it will be handle as nullable and it won't crash, but is pointless because it doesn't exist. The method findViewByID doesn't search in every place, just inside the View you are accessing.

Jetpack Compose correct accessing of data from ViewModel

today I'm trying about Jetpack compose and the ViewModel class.
Everything seems to be logic but I got some problems understanding the encapsulation.
// ViewModel
class MyViewModel: ViewModel() {
var uiStateList = mutableStateListOf(1,2,3)
privat set
private var _uiStateList2 = mutableStateListOf(11,22,33)
val uiState2 List<Int>
get() = _uiStateList2
fun addToUiStateList2() {
_uiStateList2.add(_uiStateList2.last()+11)
}
}
The first var is like in the Compose tutorials.
The construction of the second var looks like using MutableLiveData.
// Activity
#Composable
fun MyComposable(
modifier: Modifier = Modifier,
myViewModel: MyViewModel= viewModel()
) {
val uiStateList = myViewModel.uiStateList
val uiStateList2 = myViewModel.uiStateList2
Column {
uiStateList.forEach {
Text(it.toString())
}
Button(
onclick = {uiStateList.add(uiStateList.last()+1)},
content = {text(+1)}
)
uiStateList2.forEach {
Text(it.toString())
}
Button(
onclick = {
// uiStateList2.add(uiStateList2.last()+11) // not possible because of type List<Int>
myViewModel.addToUiStateList2() // possible, changing indirectly the value of uiStateList2
},
content = {text(+11)}
)
}
}
In the first case it's possible to modify the mutableStateList inside of the ViewModel by running just a simple function in the composable. Consequently it is possible to change the value from outside of the class directly.
In the second case I got no chance to change the data in the viewmodel. The var uiStateList2 is a imutable list which reflects the data from the private val from the viewmodel. If the function addToUiStateList2() is triggered, the original list changes and the composable will be recompositioned and everything is fine.
Why can I change the data of the var uiStateList inside the Composable, although the setter is set to private inside the ViewModel class?
In the documentation I read, that private setters could just be used inside the owning class. Do I think too complicated, or is this the usually aproach how everything is build?
Thanks for help Guys!
Private setter doesn't allow to set the value i.e myViewModel.uiStateList= //something. But you can still modify the list because it is mutable. If you want to restrict changing the state from outside viewModel second approach is preferred.

Viewmodel seems to be scoped to a fragment instead of activity. I am using navigation component as well

I am using navigation component and I have one activity in the app with so many fragments. I am trying to use one view model scoped to the activity in all the fragments but it working only in the 3 fragments nested in my home fragment. The home fragment which is the navigation host fragment is using a view pager to add 3 tabs which are 3 fragments within the home fragment. For instance I have like 3 tabs A, B, C. Between these three I can share data in view model successfully. 'A' has a detail fragment lets call it D. when I try to access shared viewModel in D it has null values which I know I have set already in A. I have a normal view model class.
in each fragment I instantiate view model like this
private val viewModel: MainActivityViewModel by activityViewModels()
it seems the viewModel is only scoped to the home fragment because it not working inside any detail fragment that I navigate to. Thank you. !
just in case there is something unusual about my view model class here is how it looks like
class MainActivityViewModel: ViewModel() {
var itemListLiveData = MutableLiveData<List<Item>>()
fun setItems(itemList: List<Item>) {
itemListLiveData.value = itemList
}
fun getItems() = itemListLiveData.value
}
You have to initialise your viewModel in onCreate method of your fragment.
// declare your viewModel like this
lateinit var viewModel : MainActivityViewModel
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
// other code
// initialise your view model here
viewModel = ViewModelProvider(requireActivity()).get(MainActivityViewModel::class.java)
// other code
}
Also there is one mistake in your MainActivityViewModel class:
fun setItems(itemList: List<Item>) {
// set your live data like this
itemListLiveData.postValue(itemList)
}
Try instantiating your viewmodel like this
lateinit var viewModel: MainActivityViewModel
and in onViewCreated
viewModel = (activity as MainActivity).viewModel
This worked for me in the past when sharing a ViewModel.
Oops ! sorry I found the problem which has nothing to do with the activity view model not working. but I just leave my answer in case by chance some one else has the same issue.
The problem was here
fun setItems(itemList: List<Item>) {
itemListLiveData.value = itemList
}
in the fragment where I set this itemList, I clear this itemList in the fragments onStop() method so because itemListLiveData.value is referencing this itemList object it is also cleared. So I resolved it by instantiating it with a new object of itemList instead of referencing the itemList object. like below
fun setItems(itemList: List<Item>) {
itemListLiveData.value = ArrayList<Item>(itemList)
}

Observe and respond to changes in elements of an array

I'm experimenting with Jetpack Compose and am trying to make a Canvas with a number of rectangles, each of which is filled or not depending on the value of a Bool at a corresponding index of an array. When an element of that array changes, the Canvas should redraw.
I've discovered I can't simply make a LiveData array of Booleans, or a list, since for that to work the entire object needs to be recreated each time for setValue to trigger and be observed. So I've made an array of LiveData booleans in a view model;
class StripeModel : ViewModel() {
private val _values = Array<MutableLiveData<Boolean>>(50) { MutableLiveData(false) }
val values = Array<LiveData<Boolean>>(50) {i->_values[i]}
fun onValueChanged(index: Int, newVal: Boolean)
{
_values[index].value = newVal;
}
}
If I pass that view model to my test function, I can look at a particular member of it in a way that causes the canvas to recompose on change using something like
val state by model.values[5].observeAsState();
This would be fine if I had a different canvas for each element, but I don't. So I want my single canvas to be looking at all of them, and refresh if any change. The sensible way to do this without explicitly declaring a state variable for each member seemed to be to create an array of states, and the way I came up with to do that was
val states = Array<State<Boolean?>>(20){ i->model.values[i].observeAsState()}
However, this flags an error because observeAsState needs to be in a function marked #Composable. The outer function itself is, but it seems that's not inherited by the lambda. And if I try and mark the lambda as #Composable then it makes Android Studio very unhappy and tells me to report it as a bug. Doesn't crash the environment but I can't compile it.
The reason I have a strong desire to do this in a single canvas is because I want to be able to click a single item to change its value, or drag across a number of items to change a number of them all at once. That seems like it should be a lot more efficient by handling all the coordinates within one widget rather than having 50 separate widgets and trying to figure out which is at the present location during the drag.
So, how can I make my composable function observe n array elements without explicitly writing n lines that create n variables?
Following a few days away I've worked through some of the suggestions people have given.
#cactustictacs suggested the simple approach of making the LiveData array of Booleans. I hadn't actually tried this. Something I'd read made me think it wouldn't work so I tried going more complicated. However, I can't get it to work.
I've simplified the code so it's postable, minus imports.
class StripeModel : ViewModel() {
private val _values = MutableLiveData<Array<Boolean>>(Array<Boolean>(20) {false});
val values: LiveData<Array<Boolean>> = _values;
fun onValueChanged(index: Int, newVal: Boolean)
{
_values.value?.set(index, newVal);
_values.value=_values.value;
}
}
class MainActivity : ComponentActivity() {
private val stripeModel by viewModels<StripeModel>();
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
Surface{
MaterialTheme{
TestCanvas(stripeModel);
}
}
}
}
}
#Composable
fun TestCanvas(model : StripeModel)
{
val state by model.values.observeAsState();
Canvas(
Modifier
.fillMaxSize()
.pointerInput(Unit) {
detectTapGestures(
onPress = { it -> model.onValueChanged(5, !state?.get(5)!!) }
)
}
.background(Color.LightGray),
onDraw={
val offset = Offset(100f,100f);
val size = Size(200f,200f);
if (state?.get(5) == true) {
drawRect(
brush = SolidColor(Color.Blue),
size = size,
topLeft = offset
)
}
drawRect(
brush = SolidColor(Color.White),
size = size,
topLeft = offset,
style = Stroke(width = 10f)
)
}
)
}
So there's an arbitrary 20 elements of which I'm just looking at index 5. By changing the initialiser I can see that the value is being read on draw. In the debugger I can see that a tap on the screen fires onValueChanged which changes the stored value. However that doesn't cause TestCanvas to recompose.
#chuckj suggested using a mutableStateListOf<MutableState>. If I change my view model to
class StripeModel : ViewModel() {
val values = mutableStateListOf<MutableState<Boolean>>()
init {
for (i in 0..20)
{
var s = mutableStateOf<Boolean>(false);
values.add(s);
}
}
fun onValueChanged(index: Int, newVal: Boolean)
{
values[index] = mutableStateOf<Boolean>(newVal);
}
}
and I look at it using
val state = model.values;
the behaviour is the same- no display update on tap.
#Robert Nagy suggested a LiveData<List>. So I created the ViewModel as
class StripeModel : ViewModel() {
private val _values : MutableList<Boolean> = Array<Boolean>(20) {false}.toMutableList();
val values = mutableStateOf(_values);
fun onValueChanged(index: Int, newVal: Boolean)
{
_values.set(index,newVal);
values = values;
}
}
and look at it using
val state by model.values;
Here, it won't build if I include the line values = values. Otherwise, though it builds and runs, it still doesn't cause a recompose.
I've not pasted the whole of the code each time, but it's my understanding that by setting that 'state' value at the start of the composable, any change will trigger a re-run of that function from the start, so only that line is relevant?
So, thanks to those who've commented. Is there something I'm doing wrong that this edit's made apparent?
LiveData<List<T>> Should definitely work.
An example, Screens with lists are commonly modified with:
fun removeLastItem(){
_items.value = _items.value.dropLast(1)
}
I suspect something is off at the subscriber/observer.

Viewmodel SavedStateHandle data lost after process death

I am trying to use savedStateHandle in my ViewModel and test it. My simple case is that I'm just storing an Int value in the SavedStateHandle and trying to get it again after the process death. But its not working at all. I am using the following dependency
implementation 'androidx.activity:activity-ktx:1.2.0-alpha08'
implementation "androidx.fragment:fragment-ktx:1.3.0-alpha08"
Below is my Fragment which is having one button and one TextView. When I click the button, number 5 is stored in the savedStateHandle of the ViewModel and then the same was observed via the getLiveData method of the savedStateHandle and the number is displayed in the TextView. So, after process death, it should correctly restore the value of 5 and display it in the text view. Following is my fragment code
class FirstFragment : Fragment() {
private val viewModel by lazy{
ViewModelProvider(this).get(FirstFragmentVM::class.java)
}
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
// Inflate the layout for this fragment
return inflater.inflate(R.layout.fragment_first, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
button_first.setOnClickListener {
viewModel.rememberNumber(5)
}
observeViewModel()
}
private fun observeViewModel(){
viewModel.rememberedNumber.observe(viewLifecycleOwner){
textview_first.text = it.toString()
}
}
}
and the following is my ViewModel code
#ExperimentalCoroutinesApi
#FlowPreview
class FirstFragmentVM(savedStateHandle: SavedStateHandle) : ViewModel() {
companion object{
private const val KEY_NUMBER_TO_REMEMBER="number:to:remember"
}
private val savedState=savedStateHandle
val rememberedNumber=savedState.getLiveData<Int>(KEY_NUMBER_TO_REMEMBER)
fun rememberNumber(number:Int){
savedState.set(KEY_NUMBER_TO_REMEMBER,number)
}
}
When run this app and click the button, the number 5 is stored in the savedStateHandle and its displaying "5" correcly in the text view. But when I put the app in the background and kill the process using adb and then restart the process from the recent screen, the entire app is restarted and its not showing the remembered number in the textView. Instead, its showing "Hello" which I set in the layout xml. I am killing the process as follows
adb shell
am kill <package name>
Anyone kindly help me why its not at all working for me? What am I doing wrong here?
Finally, I found the reason. The app restarts if we kill the app process after launching it directly from the IDE. Instead, If I close the app by swiping it from recents, launch it from the app drawer and then killing it via adb works and restores the state of the app perfectly.