Reducing Data Binding Boilerplate With Kotlin

data binding

Routing data to views, and responding to user interactions has been a messy process on Android for years. Maintaining view states, references to the views, the data connections, their various states — it just becomes a lot of boilerplate for what should be a relatively simple task. Luckily for all of us, Google has given us a way to make this easier: Data Binding. How Data Binding works has been talked about ad nauseam, and that’s not what I want to tell you about. Rather, I’d like to explore a specific use of Data Binding and how to simplify that into a nearly painless declaration.

Data Binding has two fundamental ways of interacting with data: the BaseObservable class and it’s associated @Bindable annotation, and the LiveData observable wrapper. While I was at Google I/O this year I asked Ian Lake, author of the new Navigation library in Android Jetpack, which approach Google recommended taking. He spent some time considering it and ultimately concluded that both are continuing to be supported. His recommendation was to use whichever best fit your business case and I will echo that recommendation. However, I believe the specific implementation of data binding explained below can significantly increase your productivity when writing your view bindings. The rest of this article will discuss how to work with the BaseObservable and @Bindable to accomplish this.

Related: Choose Kotlin For Your Next Android Project

As a quick refresher, if you inherit your ViewModel from BaseObservable then all of the variables you annotate as @Bindable will be added to the BR global reference object. BR is the a Bindable Resource. Similar to your R reference object, this is just a collection of static integers referencing specific views.

A naive implementation of this pattern for an imaginary game could look something like:

@Bindable
var currentLevel = levelManager.currentLevel()

@Bindable
var maxLevel = levelManager.maxLevel()

fun updateLevel(level: Int) {
   currentLevel = level
   notifyPropertyChanged(BR.currentLevel)
}

fun goBack() {
   currentLevel--
   notifyPropertyChanged(BR.currentLevel)
}

...

Every time you modify the level for some reason, you update the property, and that change is reflected in your UI. But that seems a bit unnecessary. After all, in many cases, every time that variable is modified, we would like to reflect that in the UI. That’s simple enough, we can provide a custom setter for the bound value.

@Bindable
var currentLevel = levelManager.currentLevel()
   set(value) {
       field = value
       notifyPropertyChanged(BR.currentLevel)
   }

And there we go, our UI will always reflect this value. And that seems great, until you have 10+ values on screen that all update due to various user interactions, and then you will be copying that snippet, and manually editing the BR.<variable> section. Since all that setter is doing is issuing the notification, there has to be a way to simplify that further. For this, we turn to Delegated variables.

class DelegatedBindable<T>(private var value: T,
                          private val observer: BaseObservable) {

   private var bindingTarget: Int = -1

   operator fun getValue(thisRef: Any?, p: KProperty<*>) = value

   operator fun setValue(thisRef: Any?, p: KProperty<*>, v: T) {

       val oldValue = value
       value = v
       if (bindingTarget == -1) {
           bindingTarget = BR::class.java.fields.filter {
               it.name == p.name
           }[0].getInt(null)
       }
       observer.notifyPropertyChanged(bindingTarget)
   }
}

This class wraps an arbitrary value that we want to data bind, and then does a lazily instantiated lookup of the target on the observer. The look up filters through the available fields until it finds its binding target. Because the generated BR file always leverages the name of the annotated field we can guarantee there will be a match. This removes the need for you to explicitly call notifyPropertyChanged. Pass it the BaseObservable containing the and you’re good to go. When your variable is updated the observable will be notified and your UI updated. Our variable declarations reduce down to this:

@get:Bindable
var helloWorldDisplay by DelegatedBindable(“Hello World”, this)

Remember, we are using the by keyword to indicate that we are delegating this property. Using = will actually set the value of the variable to be the DelegatedBindable, rather than leveraging it.

This is pretty succinct, but there’s one piece left that stands out as boilerplate. Having to include this in every declaration, because that variable will never not be this. If you passed in anything else, then the BaseObservable wouldn’t receive the notifications that the variable was changing and, moreover, wouldn’t be able to find the correct variable and crash your app. To both save us some time and reduce our risk of error, we create an extension function off of BaseObservable that handles this for us:

fun <T> BaseObservable.bindDelegate(value: T):
       DelegatedBindable<T> =
       DelegatedBindable(value, this)

And our this disappears! Our variable declaration then condenses into:

@get:Bindable
var helloWorldDisplay by bindDelegate(“Hello World”)

This is pretty great. But, now we’ve run into the problem of having programmed ourselves into a corner. What happens if you actually do need to go and do something when your variable gets set — other than just update the bindable resource? You would need to write your own custom set, and handle the field assignment as well as the bindable update. Now we’re back where we started, having not saved ourselves any time. That seems less than ideal. However, we can pretty easily solve that by providing an optional lambda argument to our delegated function.

We can update our DelegatedBindable like so:
“`
class DelegatedBindable(private var value: T,
private val observer: BaseObservable,
private val expression: ((oldValue: T, newValue: T) -> Unit)? = null) {

private var bindingTarget: Int = -1

operator fun getValue(thisRef: Any?, p: KProperty<*>) = value

operator fun setValue(thisRef: Any?, p: KProperty<*>, v: T) {

   val oldValue = value
   value = v
   if (bindingTarget == -1) {
       bindingTarget = BR::class.java.fields.filter {
           it.name == p.name
       }[0].getInt(null)
   }
   observer.notifyPropertyChanged(bindingTarget)
   expression?.invoke(oldValue, value)

}
}
“`

We also need to update our bindDelegate extension:

fun <T> BaseObservable.bindDelegate(value: T,
                                   expression: ((oldValue: T, newValue: T) -> Unit)? = null):
       DelegatedBindable<T> =
       DelegatedBindable(value, this, expression)

This allows us to pass in an arbitrary expression that can leverage the update during the set operation. In practice this looks like:

@get:Bindable
var subject: String by bindDelegate("math") { _, newValue ->
   notifyPropertyChanged(BR.explanation)
   errorEnabled = newValue.isBlank()
   if (errorEnabled) {
       error = getString(R.string.fields_subject_error)
   } else {
       error = null
   }
}

This sets the default value of subject to math, as we would expect. In addition, it also establishes that if the value is updated not only do we need to update subject, but we also need to update the computed property BR.explanation that leverages subject. We go on to update our error state variable and, if necessary, either show or hide the relevant error text. These error variables are themselves DelegatedBindables that will again update our UI based on being set or unset respectively.

With this, we have a very clean and lightweight abstraction that removes almost all of the thinking required to get UI updates from your ViewModels. It provides a single source of truth for your UI updates, reducing your boilerplate which in turn means your code is far less error prone. What do you think of this approach? Is this pattern something you could easily incorporate into your codebase? Let us know!

Say hello to your new mobile product team.

  • * By filling out this form, I accept stable|kernel’s Privacy Policy.
  • This field is for validation purposes and should be left unchanged.


0 replies

Leave a Reply

Want to join the discussion?
Feel free to contribute!

Leave a Reply

Your email address will not be published. Required fields are marked *