Android Spinner Setting Selection with 2-Way Binding
Asked Answered
R

2

17

I am struggling to get some functionality to work with Android spinners when configured with 2-way databinding. I would like to set the initial value of the spinner via the 2-way databinding on android:selectedItemPosition. The spinner entries are initialised by the ViewModel and are populated correctly, hence databinding appears to be working correctly.

The problem is with the 2-way binding of selectedItemPosition. The variable is initialised to 5 by the ViewModel but the spinner's selected item remains at 0 (the first item). When debugging it appears that the value of the ObservableInt is initially 5 (as set) but is reset to zero during the second phase of executeBindings.

Any help would be appreciated.

test_spinner_activity.xml

<layout xmlns:tools="http://schemas.android.com/tools"
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <data>
        <variable name="viewModel"
                  type="com.aapp.viewmodel.TestSpinnerViewModel"/>
    </data>
    <LinearLayout android:layout_width="match_parent"
                  android:layout_height="wrap_content">
       <android.support.v7.widget.AppCompatSpinner
            android:layout_width="wrap_content"
            android:layout_height="match_parent"
            android:id="@+id/sTimeHourSpinner"
            android:selectedItemPosition="@={viewModel.startHourIdx}"
            android:entries="@{viewModel.startTimeHourSelections}"/>
    </LinearLayout>
</layout>

TestSpinnerViewModel.java

public class TestSpinnerViewModel {
    public final ObservableArrayList<String> startTimeHourSelections = new ObservableArrayList<>();
    public final ObservableInt startHourIdx = new ObservableInt();

    public TestSpinnerViewModel(Context context) {
        this.mContext = context;

        for (int i=0; i < 24; i++) {
            int hour = i;
            startTimeHourSelections.add(df.format(hour));
        }
        startHourIdx.set(5);
    }
}

TestSpinnerActivity.java

public class TestSpinnerActivity extends AppCompatActivity {
    private TestSpinnerActivityBinding binding;
    private TestSpinnerViewModel mTestSpinnerViewModel;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        binding = DataBindingUtil.bind(findViewById(R.id.test_spinner));
        mTestSpinnerViewModel = new TestSpinnerViewModel(this);
        binding.setViewModel(mTestSpinnerViewModel);
    }

I am using Android Studio 2.2.2 and Databinding is enabled.

Roulers answered 13/11, 2016 at 14:49 Comment(4)
Just a heads-up, this is not two-way databinding, but just databinding. Two-way involves changing the object changes the UI.Woolly
Hi @chisko, I am confused by your comment. Using the latest version of Android Studio and the binding libraries you simply declare a variable in the layout using "@={variable}" as I have done with android:selectedItemPosition="@={viewModel.startHourIdx}" and this automatically generates the boiler plate code to make it 2 way.Roulers
No. Android docs are a bit confusing and misleading regarding this. What you are describing above is one-way binding, in which changes to the UI are automatically reflected to the model.Woolly
If you want it the other way around, the model class has to extend from BaseObservable and you need to notifyPropertyChanged(BR.yourClassMember)Woolly
R
24

thank you for your suggestions. But I found the answer to my own question. It turns out that the reason that the android:selectedItemPosition=@={viewModel.startHourIdx} variable was being reset from the initialised value of 5 to 0 is because of the declaration order of the selectedItemPosition and entries attributes. In my example they were declared in that specific order and the auto-generated binding code produces initialisation in that same order.

Hence, even though the selectedItemPosition was set correctly the initialisation of the entries causes instantiation of the an ArrayAdapter which resets the selectedItemPosition to 0.

Hence, the fix is to swap the two attribute declarations in the layout file.

<data>
    <variable name="viewModel"
              type="com.aapp.viewmodel.TestSpinnerViewModel"/>
</data>
<LinearLayout android:layout_width="match_parent"
              android:layout_height="wrap_content">
   <android.support.v7.widget.AppCompatSpinner
        android:layout_width="wrap_content"
        android:layout_height="match_parent"
        android:id="@+id/sTimeHourSpinner"
        android:entries="@{viewModel.startTimeHourSelections}"
        android:selectedItemPosition="@={viewModel.startHourIdx}"/>
</LinearLayout>

Roulers answered 17/11, 2016 at 4:1 Comment(1)
This doesn't work when I am using some other binding to load data instead of android:entries and use android:selectedItemPosition. (say I need to do some customization in some of the items inside the list)Anissaanita
F
1

I recently created a demo app on GitHub to show how to achieve 2-way databinding on spinners utilising bindingAdapter and InverseBindingAdapter mechanism.

In this app, I am not binding the "android:selectedItemPosition" attribute but binding the selected item itself (utilising ObservableField class) of the spinner as shown in the snippet below. Because it's a two way binding, by assigning an initial value to the bound ObservableField (i.e., the selected item) during spinner adapter setup, along with a special handling within the bindingAdapter of the spinner, the spinner initial selection can be achieved.

Feel free to check the demo app here for more details.

acivity_main.xml

<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"
        xmlns:bind="http://schemas.android.com/apk/res-auto">

    <data>
        <variable
            name="bindingPlanet"
            type="au.com.chrisli.spinnertwowaydatabindingdemo.BindingPlanet"/>
        <variable
            name="spinAdapterPlanet"
            type="android.widget.ArrayAdapter"/>
    </data>

    <RelativeLayout
        android:id="@+id/activity_main"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        ...>

        <android.support.v7.widget.AppCompatSpinner
            android:id="@+id/spin"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_centerInParent="true"
            style="@style/Base.Widget.AppCompat.Spinner.Underlined"
            bind:selectedPlanet="@={bindingPlanet.obvSelectedPlanet_}"
            app:adapter="@{spinAdapterPlanet}"/>

        ...(not relevant content omitted for simplicity)
    </RelativeLayout>

</layout>

Special handling within binding adapter in BindingPlanet.java

public final ObservableField<Planet> obvSelectedPlanet_ = new ObservableField<>(); //for simplicity, we use a public variable here

private static class SpinPlanetOnItemSelectedListener implements AdapterView.OnItemSelectedListener {

    private Planet initialSelectedPlanet_;
    private InverseBindingListener inverseBindingListener_;

    public SpinPlanetOnItemSelectedListener(Planet initialSelectedPlanet, InverseBindingListener inverseBindingListener) {
        initialSelectedPlanet_ = initialSelectedPlanet;
        inverseBindingListener_ = inverseBindingListener;
    }

    @Override
    public void onItemSelected(AdapterView<?> adapterView, View view, int i, long l) {
        if (initialSelectedPlanet_ != null) {
            //Adapter is not ready yet but there is already a bound data,
            //hence we need to set a flag so we can implement a special handling inside the OnItemSelectedListener
            //for the initial selected item
            Integer positionInAdapter = getPlanetPositionInAdapter((ArrayAdapter<Planet>) adapterView.getAdapter(), initialSelectedPlanet_);
            if (positionInAdapter != null) {
                adapterView.setSelection(positionInAdapter); //set spinner selection as there is a match
            }
            initialSelectedPlanet_ = null; //set to null as the initialization is done
        } else {
            if (inverseBindingListener_ != null) {
                inverseBindingListener_.onChange();
            }
        }
    }

    @Override
    public void onNothingSelected(AdapterView<?> adapterView) {}
}

@BindingAdapter(value = {"bind:selectedPlanet", "bind:selectedPlanetAttrChanged"}, requireAll = false)
public static void bindPlanetSelected(final AppCompatSpinner spinner, Planet planetSetByViewModel,
                                      final InverseBindingListener inverseBindingListener) {

    Planet initialSelectedPlanet = null;
    if (spinner.getAdapter() == null && planetSetByViewModel != null) {
        //Adapter is not ready yet but there is already a bound data,
        //hence we need to set a flag in order to implement a special handling inside the OnItemSelectedListener
        //for the initial selected item, otherwise the first item will be selected by the framework
        initialSelectedPlanet = planetSetByViewModel;
    }

    spinner.setOnItemSelectedListener(new SpinPlanetOnItemSelectedListener(initialSelectedPlanet, inverseBindingListener));

    //only proceed further if the newly selected planet is not equal to the already selected item in the spinner
    if (planetSetByViewModel != null && !planetSetByViewModel.equals(spinner.getSelectedItem())) {
        //find the item in the adapter
        Integer positionInAdapter = getPlanetPositionInAdapter((ArrayAdapter<Planet>) spinner.getAdapter(), planetSetByViewModel);
        if (positionInAdapter != null) {
            spinner.setSelection(positionInAdapter); //set spinner selection as there is a match
        }
    }
}

@InverseBindingAdapter(attribute = "bind:selectedPlanet", event = "bind:selectedPlanetAttrChanged")
public static Planet captureSelectedPlanet(AppCompatSpinner spinner) {
    return (Planet) spinner.getSelectedItem();
}
Fuscous answered 15/11, 2016 at 2:6 Comment(7)
if this is 2-way binding, where are your notifyPropertyChanged() calls?Woolly
The clause "inverseBindingListener_.onChange()" inside onItemSelected() will trigger the InverseBindingAdapter function get called by the framework. As the InverseBindingAdapter function is off the current topic, so it is not shown on the above code snippet.Fuscous
did you know you can save yourself from all that boilerplate with the current mechanism only by switching from Observable to BaseObservable?Woolly
@Woolly I'm not quite sure what you meant as I'm already using ObservableField for the bound data which extends BaseObservable by default.Fuscous
I just slightly revised my answer with better description and example snippet to reduce confusion.Fuscous
Hi, I appreciate your example, but I am trying to use the latest 2-way binding mechanisms which are build into Android Studio which should generate all of your custom binding classes. According to @george-mount as per link this should work by specifying the variable in the layout and binding it in the Activity.Roulers
@Adrian Medioli You'r welcome. Because spinner always uses the first item as the initial selected item BY DEFAULT, so the ObservableInt value 5 got overwritten by the default initial selection(in your case, the new value would be 0) right after the adapter is set. To workaround the problem, what I can think of so far is to introduce special handling, i.e., create a custom adapter or do sth special within onItemSelected() as shown on my above example. However, have to say that special handling is not an elegant solution.Fuscous

© 2022 - 2024 — McMap. All rights reserved.