Binding Adapters with Kotlin: Part 2

In part 1 I covered some basics of how to write binding adapters in Kotlin, looking in particular at how data binding passes default values to binding adapters and what those default values are. In particular, I covered the NPE problem when writing binding adapters in Kotlin and how to handle it elegantly.

This article will extend on examples from part 1, so I suggest reading that first.

Very often we need a way to get notified when something in the view changes, the simplest example being when the user ‘clicks’ the view. This is almost always handled via a traditional callback mechanism, where the view will have either a setSomeListener method, or a pair of addSomeListener and removeSomeListener methods.

To follow up on the examples from part 1, we will add a similar callback mechanism to UserView, the code in bold is newly added:

class UserView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {

interface TextChangeListener {
fun beforeChange(text: CharSequence)
fun afterChanged(text: CharSequence)
}

var firstNameChangeListener: TextChangeListener? = null
var lastNameChangeListener: TextChangeListener? = null

var firstName: CharSequence = ""
set(value) {
firstNameChangeListener?.beforeChange(field)
field = value
firstNameChangeListener?.afterChanged(value)
invalidate()
}

var lastName: CharSequence = ""
set(value) {
lastNameChangeListener?.beforeChange(field)
field = value
lastNameChangeListener?.afterChanged(value)
invalidate()
}

override fun onDraw(canvas: Canvas?) {
super.onDraw(canvas)
// TODO imagine this actually renders text
}
}

There are two listener types, one for firstName and one for lastName, UserView can only hold one of each for now. The idea is that whenever the user interacts with the UserView, either the firstName or lastName properties will change, which will trigger either firstNameChangeListener or lastNameChangeListener to be called.

You might be wondering why not just use EditText? It’s because bindings already exist in the framework for EditText. Ultimately this is to demonstrate how data binding works with many of the View classes from the framework as they usually provide a similar API to this. Ideally you won’t need to write custom views to use data binding.

You may have noticed that the change listeners will be triggered regardless of whether the property actually changed or not, i.e. there is no equality check like the view-model does. This is to match the behaviour of some of the framework and support library views — I’ll get to why I’ve deliberately left this out in part 4 where I will discuss two-way binding.

Instead of changing the existing binding adapters, lets add new some new ones to handle these event listeners:

@BindingAdapter("android:onFirstNameChanged")
fun UserView.bindFirstNameChangeListener(
firstNameChangeListener: UserView.TextChangeListener?
) {
this.firstNameChangeListener = firstNameChangeListener
}

@BindingAdapter("android:onLastNameChanged")
fun UserView.bindLastNameChangeListener(
lastNameChangeListener: UserView.TextChangeListener?
) {
this.lastNameChangeListener = lastNameChangeListener
}

It’s pretty straight forward just now, we simply pass an implementation of each listener type in via a separate binding adapter.

Easy tiger, don’t get too excited. This is far from ideal, let’s try to use it and see why.

The xml layout expression is pretty lean:

<com.aidanvii.example.UserView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:firstName="@{viewModel.firstName}"
android:lastName="@{viewModel.firstName}"
android:onFirstNameChanged="@{viewModel.firstNameChanged}"
android:onLastNameChanged="@{viewModel.lastNameChanged}"
/>

Now we need to add an instance of the listeners to the UserViewModel:

package com.aidanvii.example

import android.databinding.BaseObservable
import android.databinding.Bindable
import com.aidanvii.airhockey.BR

class UserViewModel(
firstName: String,
lastName: String
) : BaseObservable() {

@Bindable
var firstName: String = firstName
set(value) {
if (field != value) {
field = value
notifyPropertyChanged(BR.firstName)
}
}

@Bindable
var lastName: String = lastName
set(value) {
if (field != value) {
field = value
notifyPropertyChanged(BR.lastName)
}
}

val firstNameChanged: UserView.TextChangeListener =
object : UserView.FirstNameChangeListener {
override fun beforeChange(text: CharSequence) {
// I don't care about the old value :/
}

override fun afterChanged(text: CharSequence) {
firstName = text.toString()
}
}
val lastNameChanged: UserView.TextChangeListener =
object : UserView.LastNameChangeListener {
override fun beforeChange(text: CharSequence) {
// I don't care about the old value :/
}

override fun afterChanged(text: CharSequence) {
lastName = text.toString()
}
}

}

It’s a lot of noise in the view-model just to update the firstName and lastName properties. The beforeChange callback is pretty useless to us. It could be removed by giving it a default empty implementation, but again, lets pretend we can’t change that and make it work like the view classes from the SDK and support libraries.

We need to return to the binding adapters. One of the main problems with TextChangeListener is that it has more than one method, and thus they cannot be used in lambda expressions or method references. Thankfully, data binding expressions can contain method references or lambda expressions, where it will infer the listener type based on the expression and the type of the parameter in the binding adapter that the attribute relates to.

We can’t take advantage of this feature with the current binding adapters, thankfully there is a relatively simple solution for this.

Using method references, our xml layout can look like:

<com.aidanvii.example.UserView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:firstName="@{viewModel.firstName}"
android:lastName="@{viewModel.firstName}"
android:afterFirstNameChanged="@{viewModel::setFirstName}"
android:afterLastNameChanged="@{viewModel::setLastName}"
/>

Now the listeners can be removed from the view-model:

class UserViewModel(
firstName: String,
lastName: String
) : BaseObservable() {
@Bindable
var firstName: String = firstName
set(value) {
if (field != value) {
field = value
notifyPropertyChanged(BR.firstName)
}
}
@Bindable
var lastName: String = lastName
set(value) {
if (field != value) {
field = value
notifyPropertyChanged(BR.lastName)
}
}
// adios listeners!
}

We need to break the change listeners down into smaller single param, single method interfaces, where the binding adapters accept those instead.

First, lets create a reusable interface we can use whenever we need to write a method reference in xml that accepts a String:

Ideally, we would be able to use an interface such as:

interface BindingConsumer<in T> {
fun invoke(value: T)
}

But this doesn’t currently work, so we need to create similar interfaces for every type such as:

interface StringBindingConsumer {
fun invoke(value: String)
}

Now finally to update the binding adapters:

@BindingAdapter(
"android:beforeFirstNameChanged",
"android:afterFirstNameChanged", requireAll = false
)
fun UserView.bindFirstNameChangeListeners(
beforeFirstNameChanged: StringBindingConsumer?,
afterFirstNameChanged: StringBindingConsumer?

) {
this.firstNameChangeListener = if (
beforeFirstNameChanged != null ||
afterFirstNameChanged != null
) {
object : UserView.TextChangeListener {
override fun beforeChange(text: CharSequence) {
beforeFirstNameChanged?.invoke(text.toString())
}

override fun afterChanged(text: CharSequence) {
afterFirstNameChanged?.invoke(text.toString())
}
}
} else null
}

@BindingAdapter(
"android:beforeLastNameChanged",
"android:afterLastNameChanged", requireAll = false
)
fun UserView.bindLastNameChangeListeners(
beforeLastNameChanged: StringBindingConsumer?,
afterLastNameChanged: StringBindingConsumer?

) {
this.lastNameChangeListener = if (
beforeLastNameChanged != null ||
afterLastNameChanged != null
) {
object : UserView.TextChangeListener {
override fun beforeChange(text: CharSequence) {
beforeLastNameChanged?.invoke(text.toString())
}

override fun afterChanged(text: CharSequence) {
afterLastNameChanged?.invoke(text.toString())
}
}
} else null
}

This is ugly code, but let me explain. In part 1 I explained already that reference types may be null when the binding adapter is executed. This can happen when viewDataBinding.executePendingBindings() is called before any data bound variables (collective term that includes but not limited to view-models) are given to the ViewDataBinding instance. It can also happen when simply nulling the data bound variables manually. A classic case for this is to clean up inRecyclerView.Adapter.onViewRecycled(). Quite simply, binding adapters should clean up behind themselves when executed with the default values for the respective types, in our case null as StringBindingConsumer is a reference type. It might not seem immediately obvious why you should do this, but consider this series of events:

  1. UserViewModel is bound to the generated ViewDataBinding subclass.
  2. Both bindFirstNameChangeListeners and bindLastNameChangeListeners binding adapters are executed, creates listeners and attaches them to UserView so that UserViewModel can be notified of changes.
  3. For whatever reason, the UserViewModel is unbound from the generated ViewDataBinding subclass by setting it as null (maybe it’s being used in a recyclable View )
  4. Both binding adapters are executed with null values, but instead of removing the previous listener as in the example above, ignore null values and leave the previous listeners bound to the UserView.
  5. User interacts with UserView that causes the listeners to be triggered.
  6. As the listeners are still attached to the UserView, both of which have an implicit reference to the unbound UserViewModel, the UserViewModel is notified of changes when it shouldn’t be.

To make it more readable and easier to digest, lets extract some logic, first the null check can be extracted to an extension property:

inline val Any?.notNull: Boolean get() = this != null

Ideally inlining this would allow smart casts to non-null types to work, but for now it doesn’t matter

Next the duplicated creation of a TextChangeListener can be extracted to:

private fun createTextChangeListenerWith(
beforeTextChanged: StringBindingConsumer?,
afterTextChanged: StringBindingConsumer?
) = object : UserView.TextChangeListener {
override fun beforeChange(text: CharSequence) {
beforeTextChanged?.invoke(text.toString())
}
override fun afterChanged(text: CharSequence) {
afterTextChanged?.invoke(text.toString())
}
}

And finally update the binding adapters:

@BindingAdapter(
"android:beforeFirstNameChanged",
"android:afterFirstNameChanged", requireAll = false
)
fun UserView.bindFirstNameChangeListeners(
beforeFirstNameChanged: StringBindingConsumer?,
afterFirstNameChanged: StringBindingConsumer?
) {
this.firstNameChangeListener = if (
beforeFirstNameChanged.notNull or
afterFirstNameChanged.notNull
) createTextChangeListenerWith(
beforeTextChanged = beforeFirstNameChanged,
afterTextChanged = afterFirstNameChanged
)
else null
}

@BindingAdapter(
"android:beforeLastNameChanged",
"android:afterLastNameChanged", requireAll = false
)
fun UserView.bindLastNameChangeListeners(
beforeLastNameChanged: StringBindingConsumer?,
afterLastNameChanged: StringBindingConsumer?
) {
this.lastNameChangeListener = if (
beforeLastNameChanged.notNull or
afterLastNameChanged.notNull
) createTextChangeListenerWith(
beforeTextChanged = beforeLastNameChanged,
afterTextChanged = afterLastNameChanged
)
else null
}

Notice the use of the or infix function that simply makes the traditional || operators more readable.

To summarise, you should only attach a new listener to the view in a binding adapter like this when any of the smaller SAM listeners are non-null, otherwise set the listeners on the view as null, this way we clean up properly in the case of recycled views.

It depends how much you plan to reuse a binding adapter. If you can be certain a binding adapter will not be executed with null values (if you’re careful you can avoid this), and if you’re certain it will never be used in a RecyclerView, then the value of doing this diminishes. Also, if it’s a highly specific custom view like the super contrived UserView example here, then the likelihood of reusing it may be very small, therefore you only have to be careful in the handful of cases where you actually use it.

But consider other view types such as TextView where you can attach a TextWatcher and use it to update a view-model when the user types. Or even something as simple as forwarding click events to a view-model. These are very common cases that are highly likely to be used multiple times throughout a codebase, where it’s better to err on the side of caution and handle corner cases like this.

In the beginning I touched on the fact that some views are able to hold multiple listeners, such as TextView which has addTextChangedListener and removeTextChangedListener. In the case of recycled views, this is problematic as there is no immediately obvious way to remove the previously attached listener. In part 3 I will discuss the use of ListenerUtil, a useful utility class that helps us handle common cases like this.

Until next time! (no 3 months gap this time, promise!)

Unrelated to the main topic of this article, the UserViewModel still looks quite verbose with the custom property setters for notifying changes. This can become shorter with the use of LiveData or ObservableField, but if you prefer having a little more control over your properties, it can simply be reduced to:

import android.databinding.Bindable
import com.aidanvii.toolbox.databinding.ObservableViewModel
import com.aidanvii.toolbox.databinding.bindable
class UserViewModel(
firstName: String,
lastName: String
) : ObservableViewModel() {
@get:Bindable
var firstName by bindable(firstName)
@get:Bindable
var lastName by bindable(lastName)
}

All you need to do is add my Toolbox library, first by adding the JitPack repository to your project build.gradle file:

repositories {
...
maven { url 'https://jitpack.io' }
}

Then in your module:

implementation "com.github.Aidanvii7.Toolbox:delegates-observable-databinding:$toolbox_version"

And you’re good to go!

Android developer and Kotlin enthusiast.