Search code examples
androidandroid-spinnerandroid-databindingandroid-viewmodel

androidx databinding with Spinner and custom objects


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...


Solution

  • 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:projectsis 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:

    1. The Spinner should list all the Projects coming from the repsitory
    2. The Spinner should pre-select the currently selected Project of entry
    3. When a new Project is selected, it should be set to entry automatically

    BindingAdapter

        /**
         * 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 values. 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
    }