I have a ListView and trying to use two-way data binding to set the selectedItemPosition in a ViewModel using Two-Way Attributes
But the problem is it doesn't work, the selected item doesn't set in the Value of the liveData, I tried to observe it and the value never changes when i select an item in the listView
data binding in XML:
<ListView
android:id="#+id/listView"
android:layout_width="match_parent"
android:layout_height="#dimen/_150sdp"
android:nestedScrollingEnabled="true"
tools:listheader="#tools:sample/lorem"
tools:visibility="visible"
android:choiceMode="singleChoice"
android:selectedItemPosition="#={viewModel.chosenPosition}" />
in the ViewModel:
val chosenPosition = MutableLiveData<Int>()
in the Fragment:
binding.viewModel = viewModel
binding.lifecycleOwner = viewLifecycleOwner
binding.teamsListView.adapter = ArrayAdapter(
context,
R.layout.list_item_choice, teamsNames
)
viewModel.chosenPosition.observe(viewLifecycleOwner) {
Timber.d("chosen position = $it") //never triggers when I select an item in the ListView
}
Problem:
android:selectedItemPosition is triggered whenever an item is selected (this doesn't implicitly include that the item is clicked/checked.
Using android:selectedItemPosition as a two-way data binding in a ListView doesn't actually automatically triggered when an item is selected, and therefore the LiveData doesn't get triggered.
You can see that when you create a normal ListView without any data binding; when you click an item, this won't trigger the selection, notice the below when an item is clicked (nothing get highlighted with a different color):
Solution:
In order to solve that for the sake of data binding, you need to explicitly do select the that item whenever it is clicked by registering OnItemClickListener to the ListView:
binding.listView.setOnItemClickListener { _, _, position, _ ->
if (position >= 0) { // avoid -1 position calls
binding.listview.requestFocusFromTouch()
binding.listview.setItemChecked(position, true)
binding.listview.setSelection(position)
}
}
This way the live data will be set to the current selected position:
Notice when an item is selected, it's now highlighted with a light grey that is because the selection is enabled:
Make sure that you have not forgetten to set your binding lifecycleOwner in your fragment
binding.lifecycleOwner = this
Related
I have a button. When the button is clicked, the button and a textView are animated. The question is: how to get multiple views on the binding adapter? Is the way I did it correct?
<variable
name="variableTextViewDescription"
type="androidx.appcompat.widget.AppCompatTextView" />
fun bind(task: Task, viewModel: ToDoListViewModel) {
binding.task = task
binding.viewModel = viewModel
binding.variableTextViewDescription = binding.textViewDescription
binding.executePendingBindings()
}
#BindingAdapter(value = ["task", "textViewDescription"], requireAll = true)
fun ImageButton.setOnClickButtonMore(task: Task, textViewDescription: AppCompatTextView) {
if (task.isExpanded) {
toggleArrow(this, false, textViewDescription)
} else {
toggleArrow(this, true, textViewDescription)
}
this.setOnClickListener {
task.isExpanded = toggleArrow(it, task.isExpanded, textViewDescription)
}
}
<ImageButton
android:id="#+id/buttonMore"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:task="#{task}"
app:textViewDescription="#{variableTextViewDescription}"
android:background="?attr/selectableItemBackgroundBorderless"
android:src="#drawable/ic_baseline_keyboard_arrow_down_24"
tools:ignore="ContentDescription" />
I can propose a solution for you, that maybe different from adding multiple Views to the same Binding Adapter.
You can add a MutableLiveData when changed by Button click, it starts the animation.
So, we will have a single MutableLiveData added to 2 Binding Adapters (the button binding adapter and the ImageView binding adapter).
when the value of the MutableLiveData changed, both binding adapters will fire and in both adapters load your animation.
Exposed Dropdown Menu doesn't show items after user selection and fragment transition.
Following is the basic xml declaration:
<com.google.android.material.textfield.TextInputLayout
...
style="#style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.ExposedDropdownMenu"
<AutoCompleteTextView
....
android:id="#+id/dropdown"
android:dropDownHeight="300dp"
android:inputType="none" />
</com.google.android.material.textfield.TextInputLayout>
And, the declared code on the fragment (inside onViewCreated()):
val items = listOf("Material", "Design", "Components", "Android")
val adapter = ArrayAdapter(requireContext(), R.layout.item_menu, items)
dropdown.setAdapter(adapter)
dropdown.setText(items[0], false)
As mentioned here, it was set on AutoCompleteTextView's setText method (dropdown.setText("", false)) the filter parameter as false. However, after navigating to a next fragment and coming back to it only the pre-selected text is shown on the dropdown.
Fragments are changed using navigation component (v. 2.3.2).
The fragment's view gets destroyed when using the navigation component. (maybe not always, but it will certainly happen some of the time as you experienced)
I think you might be able to make it work simply by adding a condition:
if (savedInstanceState == null) {
dropdown.setText(items[0], false)
}
So that the default is only set when not restoring the view state.
Otherwise it's just a matter saving the state as usual. Here's a documentation article about it if you're unsure what I'm talking about. It will essentially amount to adding the following code to your fragment:
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val item = savedInstanceState?.getInt("selectedPos", 0) ?: 0
dropdown.setText(items[item], false)
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putInt("selectedPos", dropdown.getListSelection())
}
If you're using the MVVM architecture, you can save the selected position using SavedStateHandle in your ViewModel, when it gets changed.
I had the same problem. I searched for issues on github page. I found this https://github.com/material-components/material-components-android/issues/2012#issuecomment-808853621 work around for now. It works.
Create an extension like below
fun AutoCompleteTextView.showDropdown(adapter: ArrayAdapter<String>?) {
if(!TextUtils.isEmpty(this.text.toString())){
adapter?.filter?.filter(null)
}
}
Then on click of dropdown
binding.quaters.setOnClickListener {
binding.quaters.showDropdown(arrayAdapter)
}
That's all it should work. This seems to be a bug which should be fixed hopefully.
This is a temprorary solution that is working for me -
https://github.com/material-components/material-components-android/issues/2012#issuecomment-868181589
Write the setup code for ExposedDropdownMenu in onResume() of a fragment,
instead of onCreateView()/onViewCreated()
override fun onResume() {
super.onResume()
val sortingArtist = resources.getStringArray(R.array.sortingArtist)
val arrayAdapterArtist = ArrayAdapter(requireContext(), R.layout.dropdown_items_artist, sortingArtist)
binding?.autoCompleteTextViewArtist?.setAdapter(arrayAdapterArtist)
binding?.autoCompleteTextViewArtist?.setText(sortingArtist[0], false)
}
For reference - https://material.io/components/menus/android#exposed-dropdown-menus
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've made a list which gets items from a Room-database using LiveData. This liveData<List> is then bound to a recyclerView, using a BindingAdapter.
The lists adapter is listAdapter, not `RecyclerView.Adapter.
I need help holding onto the scroll-state or somehow returning to the scroll-index I was at before the recyclerView reloaded:
//In ViewModel
val movieList = moviesRepository.movies
..
//in Repo
val movies: LiveData<List<Movie>> =
Transformations.map(database.movieDao.getMovies()) {
it.asDomainModel()
}
Every time the DB updates, the recyclerView shoots back up to the top.
And here's the bindingAdapter for the RecyclerView and the list.
#BindingAdapter("listData")
fun bindRecyclerView(recyclerView: RecyclerView, data: List<Movie>?) {
val adapter = recyclerView.adapter as MovieListAdapter
//Log.d("listData binding", "${data}")
adapter.submitList(data)
}
I think I need to use something like recyclerView.smoothScrollToPosition(la.getItemCount());
after the update has occured, but I don't know how to automatically call it when the update has occured
Project Repo
First of all don't use ListAdapter RecyclerView has a more optimized adapter here
in your adapter provide a function that overrides the item list and there is where you notify the data change
Use smoothScrollToPosition(lastVisiblePosition) to scroll to the last visible position where lastVisiblePosition = layoutManager.findFirstVisibleItemPosition()
Update lastVisiblePosition before you push new items to the adapter notifyDatasetChanged()
Step 2
fun updateList(newItems:List<Movie>) {
moviesList.addAll(newItems)
lastVisiblePosition = layoutManager.findFirstVisibleItemPosition()
notifyDataSetChanged()
}
from your view when you call adapter.updateList(newItems) just call recyclerView.smoothScrollToPosition(adapter.lastVisiblePosition)
figured it out.
All of the examples I could find were using Activities, not fragments. So all I had to do was pop this into the onCreateView() function in the relevant fragment.kt file.
binding.movieList.layoutManager =
object : LinearLayoutManager(getActivity(), VERTICAL, false) {
override fun onLayoutCompleted(state: RecyclerView.State) {
super.onLayoutCompleted(state)
val lastVisibleItemPosition = findLastVisibleItemPosition()
val count = (binding.movieList.adapter as MovieListAdapter).itemCount
//speed the scroll up a bit, but make it look smooth at the end
binding.movieList.scrollToPosition(count - 5)
binding.movieList.smoothScrollToPosition(count)
}
}
here, the binding.movieList is referring to this xml element
<androidx.recyclerview.widget.RecyclerView
android:id="#+id/movie_list"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="8dp"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:layout_constraintBottom_toTopOf="#+id/load_more_button"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:listData="#{viewModel.movieList}"
tools:listitem="#layout/movie_list_item" />
I need to pass an id of clicked object from AutoCompleteTextView to ViewModel. Here I have a binding adapter to set a spinner with objects for AutoCompleteTextView.
#BindingAdapter("bindAutocomplete")
fun bindAutocomplete(textView: AutoCompleteTextView, cities: List<City>?){
cities?.let {
val adapter = ArrayAdapter<City>(
textView.context,
R.layout.support_simple_spinner_dropdown_item,
it)
textView.setAdapter(adapter)
}
}
My question is: where should I place my OnItemClickListener, in this adapter above or in the Fragment class like in code below?
The problem for first way is that I dont know how to access my ViewModel from Binding Adapter. And for the second, if I put this listener in the Fragment class isn`t it breaks a pattern, because initializations of the Biniding Adapter and of the OnItemClickListener are not synchronized?
So I need to pass a city.id to some method in my ViewModel.
binding.autoCompleteTextView.setOnItemClickListener { parent, view, position, id ->
val city = parent.adapter.getItem(position) as City
binding.viewModel.getWeatherProperties(city.id)
}
You can use two way data binding like below to pass data to viewmodel..
<AutoCompleteTextView
android:id="#+id/autoCompleteTextView"
android:layout_width="200dp"
android:layout_height="wrap_content"
android:layout_marginLeft="92dp"
android:layout_marginTop="144dp"
android:text="#={viewmodel.rememberMe}"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
So, I solved this task just by using an Observer, I am not sure if it is even possible to use a Two-way binding with AutoCompleteTextView here. Probably its because of SetOnItemClickListener has no built-in support for two-way data binding.
viewModel.autocompleteArray.observe(viewLifecycleOwner, Observer {
it?.let {
val adapter = ArrayAdapter<City>(
binding.autoCompleteTextView.context,
R.layout.support_simple_spinner_dropdown_item,
it)
binding.autoCompleteTextView.setAdapter(adapter)
binding.autoCompleteTextView.setOnItemClickListener { parent, view,
position, id ->
val s = parent.adapter.getItem(position) as City
viewModel.getWeatherProperties(s.id)
}
}
})