How do you use the androidx databinding library to fill a Spinner with a list of custom objects (app:entries)? And how to create a proper selection callback for the Spinner (app:onItemSelected)?
My layout:
<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="viewModel"
type=".ui.editentry.EditEntryViewModel" />
</data>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.editentry.EditEntryActivity">
<Spinner
android:id="@+id/spClubs"
android:layout_width="368dp"
android:layout_height="25dp"
app:entries="@{viewModel.projects}"
app:onItemSelected="@{viewModel.selectedProject}"
/>
</FrameLayout>
</layout>
EditEntryViewModel.kt
class EditEntryViewModel(repository: Repository) : ViewModel() {
/** BIND SPINNER DATA TO THESE PROJECTS **/
val projects : List<Project> = repository.getProjects()
/** BIND SELECTED PROJECT TO THIS VARIABLE **/
val selectedProject: Project;
}
Project.kt
data class Project(
var id: Int? = null,
var name: String = "",
var createdAt: String = "",
var updatedAt: String = ""
)
The Spinner should display the names of each project and when I select a project it should be saved in viewModel.selectedProject. The use of LiveData is optional.
I guess that I have to write a @BindingAdapter for app:entries and an @InverseBindingAdapter for app:onItemSelected. But I can't figure out how to implement them without writing the usual boilerplate code for the Spinneradapter...
Okay, I came up with a proper solution. Here's the code with some explanation:
layout.xml
<Spinner
android:id="@+id/spProjects"
android:layout_width="368dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="16dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/spActivities"
app:projects="@{viewModel.projects}"
app:selectedProject="@={viewModel.entry.project}" />
app:projects
is bound to val projects: List<Project>
in my ViewModel
app:selectedProject
is bound to val entry: Entry
which is a class having a Project
as property.
So this is part of my ViewModel:
class EditEntryViewModel() : ViewModel() {
var entry: MutableLiveData<Entry> = MutableLiveData()
var projects : List<Project> = repository.getProjects()
}
What's missing now are the BindingAdapter and the InverseBindingAdapter to achieve the following things:
entry
entry
automaticallyBindingAdapter
/** * fill the Spinner with all available projects. * Set the Spinner selection to selectedProject. * If the selection changes, call the InverseBindingAdapter */ @BindingAdapter(value = ["projects", "selectedProject", "selectedProjectAttrChanged"], requireAll = false) fun setProjects(spinner: Spinner, projects: List?, selectedProject: Project, listener: InverseBindingListener) { if (projects == null) return spinner.adapter = NameAdapter(spinner.context, android.R.layout.simple_spinner_dropdown_item, projects) setCurrentSelection(spinner, selectedProject) setSpinnerListener(spinner, listener) }
You can place the BindingAdapter in an empty file. It has not to be part of any class.
The important thing are its parameters. They are deducted by the BindingAdapters value
s. In this case the values are projects
, selectedProject
and selectedProjectAttrChanged
. The first two parameters correspond to the two layout-xml attributes that we defined ourselves. The last/third parameter is part of the DataBinding process: For each layout-xml attribute with two-way databining (i.e. @={) a value get generated with the name <attribute-name>AttrChanged
Another important part for this special case is the NameAdapter
which is my own SpinnerAdapter that is able to hold my Projects as items and only display their name
property in the UI. That way we always have access to the whole Project instances instead of only a String (which is usually the case for the default SpinnerAdapter).
Here's the code for my custom Spinner Adapter:
NameAdapter
class NameAdapter(context: Context, textViewResourceId: Int, private val values: List<Project>) : ArrayAdapter<Project>(context, textViewResourceId, values) {
override fun getCount() = values.size
override fun getItem(position: Int) = values[position]
override fun getItemId(position: Int) = position.toLong()
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
val label = super.getView(position, convertView, parent) as TextView
label.text = values[position].name
return label
}
override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View {
val label = super.getDropDownView(position, convertView, parent) as TextView
label.text = values[position].name
return label
}
}
Now that we have a Spinner that holds our whole Project information, the InverseBindingAdapter is easy. It is used to tell the DataBinding library what value it should set from the UI to the actual class property viewModel.entry.project
:
InverseBindingAdapter
@InverseBindingAdapter(attribute = "selectedProject") fun getSelectedProject(spinner: Spinner): Project { return spinner.selectedItem as Project }
That's it. All working smoothly together. One thing to mention is that this approach is not recommended if your List would contain a lot of data, since all this data is stored in the adapter. In my case it's only a bit of String fields, so it should be fine.
For completion, I wanna add the two methods from the BindingAdapter:
private fun setSpinnerListener(spinner: Spinner, listener: InverseBindingListener) {
spinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) = listener.onChange()
override fun onNothingSelected(adapterView: AdapterView<*>) = listener.onChange()
}
}
private fun setCurrentSelection(spinner: Spinner, selectedItem: HasNameField): Boolean {
for (index in 0 until spinner.adapter.count) {
if (spinner.getItemAtPosition(index) == selectedItem.name) {
spinner.setSelection(index)
return true
}
}
return false
}