androidx databinding with Spinner and custom objects
Asked Answered
C

3

6

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

Carminacarminative answered 17/11, 2018 at 20:10 Comment(2)
android:entries would be an 1D array ...while Project has 4 fields and no .toString() method. for id + name it would take two synchron 1D arrays to populate.Amphiprostyle
That's what the BindingAdapter would be for. To tell the databinding class how to get the 1D array from my list of objects. But while doing this I must have access to the/a SpinnerAdapter. I'm not sure how or where to create this SpinnerAdapter and if I should/can use data binding within the adapter, tooCarminacarminative
C
7

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
}
Carminacarminative answered 2/12, 2018 at 16:19 Comment(1)
Very helpful! But don't you need to check for old values to prevent infinite loops?Bourke
G
4

This question and replies have been very helpful as I muscled my way through last couple of days to solve a similar problem. In the sprit of sharing, here are all my files:

MainActivity.kt

package com.mandal.mvvmspinnerviewbindingexample

import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.databinding.DataBindingUtil
import androidx.lifecycle.ViewModelProvider
import com.mandal.mvvmspinnerviewbindingexample.databinding.ActivityMainBinding
import com.mandal.mvvmspinnerviewbindingexample.viewmodel.UserViewModel


class MainActivity : AppCompatActivity()  {
    /**
     * Lazily initialize our [UserViewModel].
     */
   private val viewModel: UserViewModel by lazy {
       ViewModelProvider(this).get(UserViewModel::class.java)
   }
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
    enter code here
     val binding: ActivityMainBinding =
            DataBindingUtil.setContentView(this, R.layout.activity_main)

        // Allows Data Binding to Observe LiveData with the lifecycle of this Activity
        binding.lifecycleOwner = this

        // Giving the binding access to the UserViewModel
       binding.viewModel = viewModel

    }
}

User.kt

package com.mandal.mvvmspinnerviewbindingexample.model

data class User(
    val id: Int,
    val name: String,
) {
    override fun toString(): String = name
}

Entry.kt

package com.mandal.mvvmspinnerviewbindingexample.model

data class Entry (var user: User)

NameAdapter.kt

package com.mandal.mvvmspinnerviewbindingexample.adapter

import android.content.Context
import android.view.View
import android.view.ViewGroup
import android.widget.ArrayAdapter
import android.widget.TextView
import com.mandal.mvvmspinnerviewbindingexample.model.User

class NameAdapter(context: Context, textViewResourceId: Int, private val values: List<User>) : ArrayAdapter<User>(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
    }
}

UserViewModel.kt

package com.mandal.mvvmspinnerviewbindingexample.viewmodel

import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import com.mandal.mvvmspinnerviewbindingexample.model.Entry
import com.mandal.mvvmspinnerviewbindingexample.model.User

class UserViewModel: ViewModel (){

    var users : List<User> = getUserList()
    var entry: MutableLiveData<Entry> = MutableLiveData()

    /**
     * Sets the value of the status LiveData to the Mars API status.
     */
    private fun getUserList() : List<User>{
        //Setup Users
        val user1 = User(1, "John")
        val user2 = User(2, "Mary")
        val user3 = User(2, "Patrick")
        val user4 = User(2, "Amanda")
        //Setup User List
        val list = arrayListOf<User>(user1, user2, user3, user4)
        return list
    }

}

BindingAdapters.kt

package com.mandal.mvvmspinnerviewbindingexample

import android.R
import android.view.View
import android.widget.AdapterView
import android.widget.Spinner
import android.widget.Toast
import androidx.databinding.BindingAdapter
import androidx.databinding.InverseBindingAdapter
import androidx.databinding.InverseBindingListener
import com.mandal.mvvmspinnerviewbindingexample.adapter.NameAdapter
import com.mandal.mvvmspinnerviewbindingexample.model.User

/**
 * fill the Spinner with all available projects.
 * Set the Spinner selection to selectedProject.
 * If the selection changes, call the InverseBindingAdapter
 */
@BindingAdapter(value = ["users", "selectedUser", "selectedUserAttrChanged"], requireAll = false)
fun setUsers(spinner: Spinner, users: List<User>?, selectedUser: User?, listener: InverseBindingListener) {
    if (users == null) return
    spinner.adapter = NameAdapter(spinner.context, R.layout.simple_spinner_dropdown_item, users)
    setCurrentSelection(spinner, selectedUser)
    setSpinnerListener(spinner, listener)
}


@InverseBindingAdapter(attribute = "selectedUser")
fun getSelectedUser(spinner: Spinner): User {
    Toast.makeText(
        spinner.context,
        (spinner.selectedItem as User).name,
        Toast.LENGTH_LONG
    )
        .show()
    return spinner.selectedItem as User
}

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: User?): Boolean {
    for (index in 0 until spinner.adapter.count) {
        if (spinner.getItemAtPosition(index) == selectedItem?.name) {
            spinner.setSelection(index)
            return true
        }
    }
    return false
}

activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    >
<data>

    <variable name="viewModel"
        type="com.mandal.mvvmspinnerviewbindingexample.viewmodel.UserViewModel" />
</data>
    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        tools:context=".MainActivity">

    <!--Spinner widget-->
    <Spinner
        android:id="@+id/userSpinner"
        android:layout_width="160dp"
        android:layout_height="50dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintHorizontal_bias="0.498"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintVertical_bias="0.17"
        app:users="@{viewModel.users}"
        app:selectedUser="@={viewModel.entry.user}"
     />

</androidx.constraintlayout.widget.ConstraintLayout>
</layout>

AndroidManifest.xml

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.mandal.mvvmspinnerviewbindingexample" >

    <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/AppTheme" >
        <activity android:name="com.mandal.mvvmspinnerviewbindingexample.MainActivity">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />

            </intent-filter>
        </activity>

    </application>

</manifest>
Googly answered 5/1, 2021 at 17:37 Comment(0)
F
0

You can set it inside the fragment

            binding.spinnerState.adapter = ArrayAdapter(
                context!!,
                R.layout.simple_spinner_item_1line,
                viewModel.projects?.map { it.name }!!
            )

Please note that project should be a

 MutableLiveData<List<Projects>>()
Fidler answered 23/11, 2018 at 12:1 Comment(1)
Thanks, I am close to a final and complete solution. For now I accept your answer and hopefully I will post a complete code sample here soon.Carminacarminative

© 2022 - 2024 — McMap. All rights reserved.