Android Data Binding: 2-way Your Way

Making custom 2-way binding attributes

As you’ll recall from the previous article Let’s Flip This Thing, two-way data binding allows you to automatically transfer data from user input back into your data model. A few of you have caught on that when I wrote about custom setters that it wasn’t enough for two-way data binding. I was intending to save this for later, but I hate to leave you hanging.

ColorPicker Refresher

As you know from the Custom Setters article, I have a custom View, ColorPicker, that has one property, “color”, and supports an “OnColorChangeListener” to notify when the user picks a new color:

public class ColorPicker extends View {
private int color;

public void setColor(int color) {
this.color = color;
invalidate();
}

public int getColor() {
return color;
}
    public void addListener(OnColorChangeListener listener) {
//...
}
    public void removeListener(OnColorChangeListener listener) {
//...
}
    //...
}

I want to support two-way binding on the color property using the two-way binding “@={expression}” syntax:

<com.example.myapp.ColorPicker
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:color="@={color}"
/>

What Getter Do We Use?

If we have a simple getter that matches the type used in the attribute, we can use an InverseBindingMethod:

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

Here, I’ve explicitly set the method property to “getColor.” The InverseBindingMethod defaults the method name to the getter based on the attribute name, so it would have defaulted to “getColor” even if I hadn’t explicitly set it.

If you need to do something more complex than calling a getter method, you’ll need to use an InverseBindingAdapter. Here, I’ve converted the color (whatever type that may be) to an integer. I also need a regular binding adapter to convert the other direction as well:

public class ColorPickerBindingAdapters {
@InverseBindingAdapter(attribute = "color")
public static int getColor(ColorPicker view) {
return convertColorToInt(view.getColor());
}

@BindingAdapter("color")
public static void setColor(ColorPicker view, int color) {
view.setColor(convertIntToColor(color));
}
}

Hooking The Event

Now that data binding knows which method to use for the getter, it must also hook up a listener to know when the value changes. Fortunately, we hooked up a listener in the ColorPicker in the previous article. However, data binding doesn’t really understand what “onColorChange” is, so we must hook up the data change event using its InverseBindingListener.

For every 2-way binding, a synthetic event attribute is generated with the same name as the attribute, but with the suffix “AttrChanged.” In this case, the event attribute is “colorAttrChanged.” This allows us to create a BindingAdapter to associate the event listener to the View. I also don’t want to lose the ability to assign the “onColorChange” event as before, so the binding adapter must look for both event types:

@BindingAdapter(value = {"onColorChange", "colorAttrChanged"},
requireAll = false)
public static void setListeners(ColorPicker view,
final OnColorChangeListener onColorChangeListener,
final InverseBindingListener inverseBindingListener) {
ColorPicker.OnColorChangeListener newListener;
if (inverseBindingListener == null) {
newListener = onColorChangeListener;
} else {
newListener = new ColorPicker.OnColorChangeListener() {
@Override
public void onColorChange(ColorPicker colorPicker,
int newColor) {
if (onColorChangeListener != null) {
onColorChangeListener.onColorChange(colorPicker,
newColor);
}
inverseBindingListener.onChange();
}
};
}

ColorPicker.OnColorChangeListener oldListener =
ListenerUtil.trackListener(view, newListener,
R.id.colorChangeListener);

if (oldListener != null) {
view.removeListener(oldListener);
}
if (newListener != null) {
view.addListener(newListener);
}
}

When a two-way binding expression is used, the data binding framework will assign the InverseBindingListener for the “colorAttrChanged” attribute. Now that we’ve hooked up the listener to the onColorChanged event, the data binding framework will know when to assign a value back to the model.

Preventing Infinite Loops

After the user has made a change, the event is fired, the value is received, and the value is then set on the model. As discussed in the previous article, an observable model then notifies that there has been a change and the data binding framework assigns that value to the View. The View then notifies a change again, the value is received again, and the value is then set on the model again. The whole things continues this way forever, once each frame. This is not good for battery life.

Unfortunately, Android data binding doesn’t handle this automatically, so you must break the loop. You could do this by having a check in your View’s setter or by not notifying when there is no value change in the notification code. However, you can’t always have control of a View’s source, so you can make the check before the value is set by using a binding adapter:

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

Recap

Now you’re ready to enable two-way data binding for attributes of your custom Views.

  1. Add an InverseBindingMethod or InverseBindingAdapter to identify how to retrieve the data from the View.
  2. Add a BindingAdapter to hook up the InverseBindingListener to listen for changes to the attribute values.
  3. Ensure there is no infinite loop by adding a value check either in a BindingAdapter or in your View’s setter.