0

I have a Fragment MyFragment currently which has a Spinner my_spinner. For testing my app, I originally populated the contents of my_spinner manually by observing the property myLiveDataList in the AndroidViewModel MyViewModel as below:

my_fragment.xml

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
        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=".ui.fragments.MyFragment">

    <Spinner
            android:id="@+id/my_spinner"
            android:layout_width="match_parent"
            android:layout_height="100dp" />
</FrameLayout>

MyFragment.kt

import androidx.lifecycle.ViewModelProviders
import android.os.Bundle
import androidx.fragment.app.Fragment
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ArrayAdapter
import androidx.lifecycle.Observer

import com.example.app.R
import com.example.app.data.room.entities.MyEntity
import com.example.app.ui.viewmodels.MyViewModel
import kotlinx.android.synthetic.main.my_fragment.*

class MyFragment : Fragment() {

    companion object {
        fun newInstance() = MyFragment()
    }

    private lateinit var viewModel: MyViewModel

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        return inflater.inflate(R.layout.my_fragment, container, false)
    }

    override fun onActivityCreated(savedInstanceState: Bundle?) {
        super.onActivityCreated(savedInstanceState)
        viewModel = ViewModelProviders.of(this).get(MyViewModel::class.java)

        val myAdapter = ArrayAdapter<MyEntity>(this.context!!, android.R.layout.simple_spinner_item)

        // This is where I populate my_spinner
        viewModel.myLiveDataList.observe(this, Observer<List<MyEntity>> { data ->
            data?.forEach {
                myAdapter.add(it)
            }
        })

        my_spinner.adapter = myAdapter
    }
}

MyViewModel.kt

import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.toLiveData
import com.example.app.data.repositories.MyRepository
import com.example.app.data.room.entities.MyEntity

class MyViewModel(application: Application) : AndroidViewModel(application) {
    private val myRepository = MyRepository(application)

    val myLiveDataList: LiveData<List<MyEntity>>
        get() = myRepository.getAllData().toLiveData()
}

This fills my_spinner successfully when I navigate to MyFragment:

App before making data-binding changes

Since it populates as expected, I went ahead to make the following changes to my_fragment.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.example.app.ui.viewmodels.MyViewModel" />
    </data>

    <FrameLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            tools:context=".ui.fragments.MyFragment">

        <Spinner
                android:id="@+id/my_spinner"
                android:layout_width="match_parent"
                android:layout_height="100dp"
                app:entries="@{viewmodel.myLiveDataList}"/>
    </FrameLayout>
</layout>

I've added in a Binding Adapter file BindingAdapterUtil (following code was copied from this article):

import android.R
import android.view.View
import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.Spinner
import androidx.databinding.BindingAdapter
import androidx.databinding.InverseBindingAdapter
import androidx.databinding.InverseBindingListener
import com.example.app.ui.adapter.SpinnerExtensions.getSpinnerValue
import com.example.app.ui.adapter.SpinnerExtensions.setSpinnerEntries
import com.example.app.ui.adapter.SpinnerExtensions.setSpinnerInverseBindingListener
import com.example.app.ui.adapter.SpinnerExtensions.setSpinnerItemSelectedListener
import com.example.app.ui.adapter.SpinnerExtensions.setSpinnerValue

@BindingAdapter("entries")
fun Spinner.setEntries(entries: List<Any>?) {
    setSpinnerEntries(entries)
}

@BindingAdapter("onItemSelected")
fun Spinner.setItemSelectedListener(itemSelectedListener: SpinnerExtensions.ItemSelectedListener?) {
    setSpinnerItemSelectedListener(itemSelectedListener)
}

@BindingAdapter("newValue")
fun Spinner.setNewValue(newValue: Any?) {
    setSpinnerValue(newValue)
}

@BindingAdapter("selectedValue")
fun Spinner.setSelectedValue(selectedValue: Any?) {
    setSpinnerValue(selectedValue)
}

@BindingAdapter("selectedValueAttrChanged")
fun Spinner.setInverseBindingListener(inverseBindingListener: InverseBindingListener?) {
    setSpinnerInverseBindingListener(inverseBindingListener)
}

@InverseBindingAdapter(attribute = "selectedValue", event = "selectedValueAttrChanged")
fun Spinner.getSelectedValue(): Any? {
    return getSpinnerValue()
}

object SpinnerExtensions {

    fun Spinner.setSpinnerEntries(entries: List<Any>?) {
        if (entries != null) {
            val arrayAdapter = ArrayAdapter(context, R.layout.simple_spinner_item, entries)
            arrayAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
            adapter = arrayAdapter
        }
    }

    fun Spinner.setSpinnerItemSelectedListener(listener: ItemSelectedListener?) {
        if (listener == null) {
            onItemSelectedListener = null
        } else {
            onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
                override fun onItemSelected(parent: AdapterView<*>, view: View, position: Int, id: Long) {
                    if (tag != position) {
                        listener.onItemSelected(parent.getItemAtPosition(position))
                    }
                }

                override fun onNothingSelected(parent: AdapterView<*>) {}
            }
        }
    }

    fun Spinner.setSpinnerInverseBindingListener(listener: InverseBindingListener?) {
        if (listener == null) {
            onItemSelectedListener = null
        } else {
            onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
                override fun onItemSelected(parent: AdapterView<*>, view: View, position: Int, id: Long) {
                    if (tag != position) {
                        listener.onChange()
                    }
                }

                override fun onNothingSelected(parent: AdapterView<*>) {}
            }
        }
    }

    fun Spinner.setSpinnerValue(value: Any?) {
        if (adapter != null ) {
            val position = (adapter as ArrayAdapter<Any>).getPosition(value)
            setSelection(position, false)
            tag = position
        }
    }

    fun Spinner.getSpinnerValue(): Any? {
        return selectedItem
    }

    interface ItemSelectedListener {
        fun onItemSelected(item: Any)
    }
}

And I've modified the onActivityCreated in MyFragment like so:

override fun onActivityCreated(savedInstanceState: Bundle?) {
    super.onActivityCreated(savedInstanceState)
    viewModel = ViewModelProviders.of(this).get(MyViewModel::class.java)

    DataBindingUtil.setContentView<MyFragmentBinding>(
        this.activity!!, R.layout.my_fragment
    ).apply {
        this.setLifecycleOwner(this@MyFragment)
        this.viewmodel = viewModel
    }
}

The result of this is that my_spinner is no longer populating with the contents of MyViewModel.myLiveDataList. To try to ascertain if the property was at fault, I created a new property in MyViewModel like so:

val myList: List<String>?
    get() = listOf("First", "Second", "Third")

And I have bound this property to my_spinner just like MyViewModel.myLiveDataList above with success this time.

The function in MyRepository.getAllData() (which myLiveDataList returns) returns a Flowable<List<MyEntity>> (RxJava), which calls a Room DAO to get the data. My assumption here is that myLiveDataList doesn't have anything to serve when it tries to bind the values for the first time, and never tries again.

Am I missing something when trying to bind a LiveData datasource to a Spinner?

Hayden
  • 2,902
  • 2
  • 15
  • 29

1 Answers1

2

After reading this answer, I've modified my_fragment.xml to the following:

...
<data>
    <import type="java.util.List" />
    <import type="com.example.app.data.room.entities.MyEntity" />
    <import type="androidx.lifecycle.LiveData" />
    <variable name="viewmodel"
              type="com.example.app.ui.viewmodels.MyViewModel" />

    <variable name="myTestList"
              type="LiveData&lt;List&lt;MyEntity&gt;&gt;" />
</data>
...
<Spinner
    android:id="@+id/my_spinner"
    android:layout_width="match_parent"
    android:layout_height="100dp"
    app:entries="@{myTestList}"/>
...

I've also removed the contents of MyFragment.onActivityCreated and modified MyFragment.onCreateView as followed:

override fun onCreateView(
    inflater: LayoutInflater, container: ViewGroup?,
    savedInstanceState: Bundle?
): View? {
    viewModel = ViewModelProviders.of(this).get(MyViewModel::class.java)
    val binding = MyFragmentBinding.inflate(inflater, container, false)
    binding.setLifecycleOwner(this)
    binding.viewmodel = viewModel
    binding.myTestList = viewModel.myLiveDataList

    return binding.root
}

Not a perfect solution, and I still don't know why my original bout at this problem didn't yield the desired results, but it will do. If there is a better way of binding a Spinner to LiveData in this fashion, please let me know.

Hayden
  • 2,902
  • 2
  • 15
  • 29