2-way Data Binding on Android!

Released with Android Studio 2.1 Preview 3, Android Data Binding now has 2-way data binding.

Data Binding Quick Recap

For a short recap on data binding, you can now add expressions to the layout files to reference variables in your data model. For example:

<layout ...>
  <data>
    <variable type="com.example.myapp.User" name="user"/>
  </data>
  <RelativeLayout ...>
    <TextView android:text="@{user.firstName}" .../>
  </RelativeLayout>
</layout>

The above binds the user’s first name to the TextView’s text field. The UI is updated whenever the user’s data has changed.

Two-Way Data Binding

Android isn’t immune to typical data entry and it is often important to reflect changes from the user’s input back into the model. For example, if the above data were in a contact form, it would be nice to have the edited text pushed back into the model without having to pull the data from the EditText. Here’s how you do it:

<layout ...>
  <data>
    <variable type="com.example.myapp.User" name="user"/>
  </data>
  <RelativeLayout ...>
    <EditText android:text="@={user.firstName}" .../>
  </RelativeLayout>
</layout>

Pretty nifty, eh? The only difference here is that the expression is marked with “@={}” instead of “@{}”. It is expected that most data binding will continue to be one-way and we don’t want to have all those listeners created and watching for changes that will never happen.

Implicit Attribute Listeners

You can also reference attributes on other Views:

<layout ...>
  <data>
    <import type="android.view.View"/>
  </data>
  <RelativeLayout ...>
    <CheckBox android:id="@+id/seeAds" .../>
    <ImageView android:visibility="@{seeAds.checked ? View.VISIBLE : View.GONE}" .../>
  </RelativeLayout>
</layout>

In the above, whenever the checked state of CheckBox changes, the ImageView’s visibility will change. No need to attach a listener on your own! This kind of expression only works with attributes that support 2-way data binding and those that have binding expressions.

Enabling Two-Way Data Binding

So, what else do you need? First, you must be sure to be using the 2.1-alpha3 or above version of the android gradle plugin in your project’s build.gradle:

classpath 'com.android.tools.build:gradle:2.1.0-alpha3'

And, of course, you need to enable data binding in your module’s android section of the build.gradle:

android {
    ...
    dataBinding.enabled = true
}

Catch?

So, what’s the catch? Well, the main one is that only a few attributes are supported. This is because there aren’t listeners to allow the data binding framework to know when something has changed. The good news is that these are probably the attributes you care most about:

  • AbsListView android:selectedItemPosition
  • CalendarView android:date
  • CompoundButton android:checked
  • DatePicker android:year, android:month, android:day (yes, these are synthetic, but we had a listener, so we thought you’d want to use them)
  • NumberPicker android:value
  • RadioGroup android:checkedButton
  • RatingBar android:rating
  • SeekBar android:progress
  • TabHost android:currentTab (you probably don’t care, but we had the listener)
  • TextView android:text
  • TimePicker android:hour, android:minute (again, synthetic, but we had the listener)

You’re also going to start getting warnings now if your variable names collide with the View id’s in your layout. How do we know whether you mean the View or the variable in your expressions?

Rolling Your Own

Let’s imagine that the attribute you care about isn’t one of those listed above. How do you go about making your own? Let’s imagine that you have a color picker View where you want to have two-way binding for the color choice.

public class ColorPicker extends View {
    public void setColor(int color) { /* ... */ }
    public int getColor() { /* ... */ }
    public void setOnColorChangeListener(OnColorChangeListener listener) { 
        /*...*/
    }
    public interface OnColorChangeListener {
        void onColorChange(ColorPicker view, int color);
    }
}

The important aspect of the above View is that it has a listener that the data binding framework can listen for. Now we need to tell data binding about the attribute. The simplest way is using an InverseBindingMethod attribute on any class:

@InverseBindingMethods({
  @InverseBindingMethod(type = ColorPicker.class, attribute = "color"),
})

In this case, the name of the getter matches the name of the attribute “getColor” for “app:color.” If the name is something different, you can supply a method attribute to correct that. This creates a synthetic attribute to be used when binding the event using the name of the attribute with the suffix “AttrChanged.” Let’s see how this is used to set up the listener:

@BindingAdapter("colorAttrChanged")
public static void setColorListener(ColorPicker view,
        final InverseBindingListener colorChange) {
    if (colorChange == null) {
        view.setOnColorChangeListener(null);
    } else {
        view.setOnColorChangeListener(new OnColorChangeListener() {
            @Override
            public void onColorChange(ColorView view, int color) {
                colorChange.onChange();
            }
        });
    }
}

That’s the simplest kind of BindingAdapter — it converts between the color change listener type and the one used by the data binding framework, InverseBindingListener. However, you’re often going to want to also support binding to the colorChange event as well, right? You’re going to have to combine listeners:

@BindingAdapter(value = {"onColorChange", "colorAttrChanged"}, 
                requireAll = false)
public static void setColorListener(ColorPicker view,
        final OnColorChangeListener listener,
        final InverseBindingListener colorChange) {
    if (colorChange == null) {
        view.setOnColorChangeListener(listener);
    } else {
        view.setOnColorChangeListener(new OnColorChangeListener() {
            @Override
            public void onColorChange(ColorView view, int color) {
                if (listener != null) {
                    listener.onColorChange(view, color);
                }
                colorChange.onChange();
            }
        });
    }
}

Now you can use two-way data binding and bind to the onColorChange event, even in the same View.

InverseBindingAdapters

Similar to BindingAdapters and setters, there are times when you have to do something other than just calling a getter to retrieve a value. For example, if our ColorPicker has an enumeration of only a few colors, we may need to convert to int:

@InverseBindingAdapter(attribute = "color")
public static int getColorInt(ColorPicker view) {
    return ConvertColorEnumToInt(view.getColor());
}

Preventing Loops

One problem we encounter with two-way data binding is a sort of infinite loop. When making a change to a value raises an event, the listener will set the value on the target object. This can raise another event and it may trigger a change to the View again and we’re off on our loop. This kind of loop is normally caught either in the View’s setter or the data value’s setter, but this can’t be guaranteed if you don’t have control over the View or the data value. We’ve decided to prevent loops through BindingAdapters. For our ColorPicker example:

@BindingAdapter("color")
public static void setColor(ColorPicker view, int color) {
    if (color != view.getColor()) {
        view.setColor(color);
    }
}

There! No more loop.

I hope you get some use out of two-way data binding and let me know if you have any issues.

Next time I’ll talk about event lambda expressions that we released as well.

 

Advertisements