I'm trying to update TextInputEditText text via data-binding after I get some data from BE API call. My solution works perfectly if code is not executed inside coroutine. If variable is set inside coroutine EditText does not get updated.
My XML code:
<com.google.android.material.textfield.TextInputEditText
android:layout_width="match_parent"
android:layout_height="40dp"
android:text="#={ viewModel.name }" />
My viewModel code:
var name: String = ""
get() = field.trim()
set(value) {
field = value
//some other unrelated code
}
...
fun getName(){
name = "first"
viewModelScope.launch(Dispatchers.Main) {
name = "second"
}
}
TextInputEditText will be updated to "first" but not to "second". I've tried with other dispatchers. I've also verified via debugger that "name" variable setter is being triggered both times. It's just not updating the EditText. Any ideas on what could cause this?
In my case, the problem was solved by setting the value of the lifecycleOwner property in the following code. The data binding is now done as intended.
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
postDetailViewModel = ViewModelProvider(this)[PostDetailViewModel::class.java]
binding.varPostDetailViewModel = postDetailViewModel
binding.lifecycleOwner = this // Add this line
coroutineScope.launch {
arguments?.let {
val args = PostDetailFragmentArgs.fromBundle(it)
postDetailViewModel.getPostDetail(args.postID)
}
}
}
Your name field needs to be observable.
Right now, nothing is telling the EditText that the field was updated and needs to be rebound. You're probably seeing "first" from initially setting the viewModel on the binding.
Review the documentation on obervability.
My answer to another similar question might also be helpful.
I am trying to perform update & delete operation in a recyclerview with ListAdapter. For this example I am using LiveData to get updates as soon as data is updated.
I don't know why list doesn't shows updated data, but when I see logs it shows correct data.
Code:
#AndroidEntryPoint
class DemoActivity : AppCompatActivity() {
var binding: ActivityDemoBinding? = null
private val demoAdapter = DemoAdapter()
private val demoViewModel: DemoViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityDemoBinding.inflate(layoutInflater)
setContentView(binding?.root)
initData()
}
private fun initData() {
binding?.apply {
btnUpdate.setOnClickListener {
demoViewModel.updateData(pos = 2, newName = "This is updated data!")
}
btnDelete.setOnClickListener {
demoViewModel.deleteData(0)
}
rvData.apply {
layoutManager = LinearLayoutManager(this#DemoActivity)
adapter = demoAdapter
}
}
demoViewModel.demoLiveData.observe(this, {
it ?: return#observe
demoAdapter.submitList(it)
Log.d("TAG", "initData: $it")
})
}
}
activity_demo.xml:
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".activities.DemoActivity">
<Button
android:id="#+id/btn_update"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentStart="true"
android:text="Update Data" />
<Button
android:id="#+id/btn_delete"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentEnd="true"
android:text="Delete Data" />
<androidx.recyclerview.widget.RecyclerView
android:id="#+id/rv_data"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_below="#id/btn_update" />
</RelativeLayout>
DemoAdapter:
class DemoAdapter() : ListAdapter<DemoModel, DemoAdapter.DemoViewHolder>(DiffCallback()) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DemoViewHolder {
val binding =
ListItemDeleteBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return DemoViewHolder(binding)
}
override fun onBindViewHolder(holder: DemoViewHolder, position: Int) {
val currentItem = getItem(position)
holder.bind(currentItem)
}
inner class DemoViewHolder(private val binding: ListItemDeleteBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(student: DemoModel) {
binding.apply {
txtData.text = student.name + " " + student.visible
if (student.visible) txtData.visible()
else txtData.inVisible()
}
}
}
class DiffCallback : DiffUtil.ItemCallback<DemoModel>() {
override fun areItemsTheSame(oldItem: DemoModel, newItem: DemoModel) =
oldItem.id == newItem.id
override fun areContentsTheSame(oldItem: DemoModel, newItem: DemoModel) =
(oldItem.id == newItem.id) &&
(oldItem.visible == newItem.visible) &&
(oldItem.name == newItem.name)
}
}
DemoViewModel:
class DemoViewModel : ViewModel() {
var demoListData = listOf(
DemoModel(1, "One", true),
DemoModel(2, "Two", true),
DemoModel(3, "Three", true),
DemoModel(4, "Four", true),
DemoModel(5, "Five", true),
DemoModel(6, "Six", true),
DemoModel(7, "Seven", true),
DemoModel(8, "Eight", true)
)
var demoLiveData = MutableLiveData(demoListData)
fun updateData(pos: Int, newName: String) {
val listData = demoLiveData.value?.toMutableList()!!
listData[pos].name = newName
demoLiveData.postValue(listData)
}
fun deleteData(pos: Int) {
val listData = demoLiveData.value?.toMutableList()!!
listData.removeAt(pos)
demoLiveData.postValue(listData)
}
}
Martin's Solution: https://github.com/Gryzor/TheSimplestRV
I suggest you:
Do yourself a favor and add a proper ViewModel/Sealed Class to encapsulate your state.
Initialize your adapter in the usual order:
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityDeleteBinding.inflate(layoutInflater)
setContentView(binding?.root)
binding.recyclerView.layoutManager = ... (tip: if you won't change the layout manager, I suggest you declare it in the XML directly, skipping this line here. E.g.: app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager")
binding.recyclerView.adapter = yourAdapter
//now observe data which will ultimately lead to `adapter.submitList(...)`
initData()
}
Make sure your DiffUtil.ItemCallback is properly comparing your models. You did old == new in Content, but that's not comparing the content, that's comparing the whole thing. It's the same in this case (I assume, but we haven't seen your Delete model class), but it's best to be explicit about it; the id is not the "content" theoretically speaking for the purposes of this callback thing.
delAdapter.submitList(it.toMutableList()) this is fine, but if you do it (and you do) before the adapter is set, and the LayoutManager is set (as you do), then it's likely possible that the ListAdapter is not magically recomputing it.
Update After Seeing More of Your Code
Let's look at your mutation code (one of the various):
fun updateData(pos: Int, newName: String) {
val listData = demoLiveData.value?.toMutableList()!!
listData[pos].name = newName
demoLiveData.postValue(listData)
}
I see various problems here.
You're grabbing the value from the LiveData. No-Go. LiveData is a value-holder, but I wouldn't "pull it from there" at any time, expect when I receive it via the observation. LiveData is not a repository, it's just holding the value and offering you "guarantees" that it will be managed in conjunction with your lifecycleOwner.
You then use toMutableList() and while this creates a new instance of the List (List<DemoModel> in your case), it does not create a deep copy of the references in the list. Meaning the items in the new (and old) list, are the same, pointing to the exact same spot in memory.
You then perform this operation listData[pos].name = newName in the "new list" but you're effectively modifying the old list as well (you can set a breakpoint there, and inspect the contents of all the lists involved and notice how the same item at pos is now changed to the newName everywhere.
If you want to see even more, put a breakpoint here:
demoViewModel.demoLiveData.observe(this, {
demoAdapter.submitList(it) <--> BREAKPOINT HERE
})
Also put a breakpoint in ListAdapter.java (the android class) in the submitList method:
public void submitList(#Nullable List<T> list) {
mDiffer.submitList(list); ---> BREAKPOINT HERE
}
And when stopped at the 1st breakpoint, observe the value of the list (it) and it's reference. (the first time the breakpoints hit, continue, since we want to observe the list AFTER you mutate the list and not on the "first creation").
Now press your button to change something (update the list) and the breakpoint(s) are going to be hit again, now the submitList call will have a list and it's gonna look like:
notice the Reference: it's (in my example) ArrayList#100073.
Now continue... (the debugger), it will stop again in the mDiffer.submitList(list) line of ListAdapter.
Let's compare.
For the record, this is what I do:
binding.updateButton.setOnClickListener {
viewModel.updateData(0, "Hello World " + 5)
}
So The item at position "0" should be called "Hello World 5" now.
This is already visible here in the debugger:
It's correctly changed in the list, but we're submitting to the adapter... let's see what the adapter has internally (before this is applied), let's jump to the next breakpoint in ListAdapter#submitList():
Notice something strange here?
The item at position 0, is already modified. How?!
Simple, the reference to that object DemoModel is the same. In my example: it's DemoModel#10078.
So how can you prevent this?
Never pass a mutable list to your adapter, always pass a copy (and immutable!)
your Live Data should have been:
var demoLiveData = MutableLiveData(demoList.toList()) //To List creates a new copy of the list, immutable.
This reinforces the concept of a Single Source of Truth. When you mutate data, you need to be sure you know what the scope of the mutation is. The reason why you saw no "change" is because by mutating the data behind the scenes of the adapter, by the time the DiffUtil (Which is async) was called and the change dispatched, the list was already mutated and the Diff Util computed zero changes, which meant the adapter had nothing else to do.
Changing an item in the list, does not (and will never) trigger an adapter to "notify the data was changed", since the adapter is "not observing" the list.
I hope this clarifies your confusion and the importance of not using mutable data all over the place.
Last but not least, I created a super simple project to exercise your problem and pushed it to https://github.com/Gryzor/TheSimplestRV (or if you prefer to see the viewModel alone).
Feel free to look at it (I used one of the default templates so the code is in a Fragment, but... irrelevant of course).
Good luck! :)
Why does NOTIFY DATA SET CHANGED WORK THEN?!
Well, when you do that, you FORCE the adapter to rebind every item, therefore it has to go through the list again (which is changed) and the change is reflected, at the expense of CPU, Battery, flickering, position lost, annoyance to the user(s), etc.
Internally, ListAdapter checks reference of the lists you submit. So you need to create a new list for each update so new one directs another reference different from previous list. Additionally, when you need to update an object in this list, you should create a new object otherwise diff util won't work.
I have a single activity app using the androidx navigation library. For one of the menu destinations I effectively have a fragment as destination with no view whatsoever that depending on the state of the user provided configuration either redirects to the real destination that should be there or to one of currently two different views that tell the user that either he needs to setup a configuration first or that there currently is no active configuration (deleted?) and he needs to select one of the available configurations.
Now, functionally this approach works perfectly fine. However, since androidx navigation ties menu items to destinations by id the menu item that gets you to that view is never selected as it matches the fragment destination with no view in it.
I tried to add a NavController.OnDestinationChangedListener to my Activity and added it to the navController navController.addOnDestinationChangedListener(this). But it seems to get overwritten by the navigation afterwards.
override fun onDestinationChanged(controller: NavController, destination: NavDestination, arguments: Bundle?) {
val destinations = listOf(R.id.destinationA, R.id.destinationB, R.id.destinationC)
if(destinations.contains(destination.id)) {
nav_view.menu.getItem(0).isChecked = true
}
}
It is deffinitely the right menu item. As when I change isChecked = true to isEnabled = false I can no longer click on it.
Also when I do this odd hack it works
GlobalScope.launch(Dispatchers.Main) {
delay(1000)
nav_view.menu.getItem(0).isChecked = true
}
Needless to say this is not a very good solution.
Anyone here knows how to overwride the default behaviour of androidx navigation in this regard?
I´ll come back to this later and report back if I find a proper solution to this.
Adding a listener to the drawer opening and setting the selected menu item then might be a good workaround for this if it is not possible to do currently.
Instead of using setupWithNavController(), as mentioned in the documentation, setup it up yourself.
As mentioned here, onNavDestinationSelected() helper method in NavigationUI is called when the menu item is clicked when you set it up using setupWithNavController(). So you could try something like this:
yourNavigationView.setNavigationItemSelectedListener { item: MenuItem ->
if(item.itemId == R.id.noViewFragmentId) {
val isConfigurationProvided = ...
if(!isConfigurationProvided) {
//Perform your actions (navigate to either of the two alternate views)
return#setNavigationItemSelectedListener true
}
}
val success = NavigationUI.onNavDestinationSelected(item, navController)
if(success) {
drawerLayout.closeDrawer(GravityCompat.START)
item.isChecked = true
}
success
}
I´ll add this as a possible solution and stick with it for the time being. I still feel like there should be a better way to do this, so I will not accept it as an awnswer.
It´s essentially the idea I got at the end of writing the question
Adding a listener to the drawer opening and setting the selected menu item then might be a good workaround for this if it is not possible to do currently.
class SetActiveMenuDrawerListener(
private val navController: NavController,
navigationView: NavigationView) : DrawerLayout.DrawerListener {
private var checked = false
private val destinations = listOf(R.id.destinationA, R.id.destinationB, R.id.destinationC)
private val menu = navigationView.menu.getItem(0)
init {
navController.addOnDestinationChangedListener { _, _, _ -> checked = false }
}
override fun onDrawerSlide(drawerView: View, slideOffset: Float) {
}
override fun onDrawerOpened(drawerView: View) {
}
override fun onDrawerClosed(drawerView: View) {
}
override fun onDrawerStateChanged(newState: Int) {
if(checked) return
val currentDestination = navController.currentDestination ?: return
if(destinations.contains(currentDestination.id)) {
menu.isChecked = true
}
checked = true
}
}
Then add this to the DrawerLayout
drawer_layout.addDrawerListener(SetActiveMenuDrawerListener(navController, nav_view))
I did add the code into the onDrawerStateChanged instead onDrawerOpened, because onDrawerOpened gets called a bit late if clicking the drawer and not at all while dragging it.
It´s not the pretties thing to look at, but it gets the job done.
I am trying to show a popup menu for the items in my RecyclerView:
All the code samples on how to do this that I found online either use Java or when they in rare cases do use Kotlin, it's done without data binding.
Anyway what I'm trying to achieve is this:
<ImageButton
android:id="#+id/options"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
android:layout_marginEnd="16dp"
android:background="?attr/selectableItemBackground"
android:contentDescription="#string/desc_options"
android:onClickListener="#{(v) -> holder.test1.invoke()}"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="#drawable/ic_more_vert" />
And in my RowHolder I have the following code:
class AgendaRowHolder(
private val binding: AgendaRowBinding,
val onRowClick: (AgendaModel) -> Unit
) : RecyclerView.ViewHolder(binding.root) {
fun bind(model: AgendaModel) {
binding.model = model
binding.holder = this
binding.executePendingBindings()
}
fun test1() {
// do something here
}
}
However this will not compile at all and a workaround to solving this problem is to have a function as a property of the AgendaRowHolder class, but this approach doesn't work since I also have to pass the ImageView for the popup menu to know where to show up. So I used this alternative approach, namely adding an onClickListener in the bind() function:
fun bind(model: AgendaModel) {
binding.model = model
binding.holder = this
binding.executePendingBindings()
binding.options.setOnClickListener { showPopup(it) }
}
private fun showPopup(view : View) {
val popup = PopupMenu(view.context, view)
popup.setOnMenuItemClickListener { item ->
when (item.itemId) {
R.id.delete -> {
AgendaRepository.delete(binding.model!!)
true
} else -> false
}
}
val inflater: MenuInflater = popup.menuInflater
inflater.inflate(R.menu.actions_agenda, popup.menu)
popup.show()
}
This approach "works" in that it will show a popup menu and allow me to delete items, but I have a new problem here in that when an item/row gets delete it doesn't show up until the view is recreated, right now I'm not observing my data so that might be the reason as to why, but what I'm wondering is what would be the best approach to achieve this? Note: another thing that I tried is adding an android:onClick attribute in the xml and trying to handle this in the MainActivity, but I'm not sure which approach to take here. Can the first one be achieved? Ideally, I'd have:
android:onClickListener="#{(v) -> holder.showPopup.invoke(model, v)}" and then that showPopup function in the ViewHolder.
I can not update NavDestination's label at runtime.
it reflects but not from the first time i enter the screen, it doesn't reflected instantaneously
My ViewModel
class PrepareOrderDetailsViewModel(
brief: MarketHistoryResponse,
private val ordersRepository: OrdersRepository
) : BaseViewModel() {
private val _briefLiveData = MutableLiveData(brief)
val orderIdLiveData: LiveData<Int?> =
Transformations.distinctUntilChanged(Transformations.map(_briefLiveData) { it.id })
}
LiveData observation in the fragment
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
registerObservers()
}
private fun registerObservers() {
viewModel.orderIdLiveData.observe(viewLifecycleOwner, Observer {
findNavController().currentDestination?.label = getString(R.string.prepare_order_details_title, it)
})
}
As per the Navigation UI documentation, the NavigationUI methods, such as the setupActionBarWithNavController() method rely on an OnDestinationChangedListener, which gets called every time you navigate() to a new destination. That's why the label is not instantly changed - it is only updated when you navigate to a new destination.
The documentation does explain that for the top app bar:
the label you attach to destinations can be automatically populated from the arguments provided to the destination by using the format of {argName} in your label.
This allows you to update your R.string.prepare_order_details_title to be in the form of
<string name="prepare_order_details_title">Prepare order {orderId}</string>
By using that same argument on your destination, your title will automatically be populated with the correct information.
Of course, if you don't have an argument that you can determine ahead of time, then you'd want to avoid setting an android:label on your destination at all and instead manually update your action bar's title, etc. from that destination.
I reach to a workaround for that issue by accessing the SupportActionBar itself and set the title on label behalf
private fun registerObservers() {
viewModel.orderIdLiveData.observe(viewLifecycleOwner, Observer {
(activity as AppCompatActivity).supportActionBar?.title =
getString(R.string.prepare_order_details_title, it)
})
}