Binding Adapters with Kotlin: Part 3

Binding Adapters with Kotlin: Part 1

Binding Adapters with Kotlin: Part 2

In the previous article we looked at how to send events from the UserView to the UserViewModel in a way that allowed the UserViewModel to stay free of complex view listener implementations. This was done by taking the UserView.TextChangeListener which has 2 methods and breaking it down into SAM interfaces at the binding adapter layer. This allowed us to write write simple method reference bindings in XML, where the databinding compiler helped us by implementing these SAM interfaces for us under the hood.

In this article the UserViewModel will remain the same. Instead we will enhance our UserView by allowing multiple listeners to be attached at any given time. This has no real benefit for databinding, but it introduces some overhead when writing binding adapters that you should be aware of. Like most framework views, this is a very common feature, where they may have an addSomeListener and removeSomeListener.

Changing the UserView to have multiple listeners is easy. To follow up on the examples from part 2, we will change the single callbacks into a list of callbacks. The code in bold is added/changed:

import android.content.Context
import android.graphics.Canvas
import android.util.AttributeSet
import android.view.View

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)
}

val firstNameChangeListeners =
mutableListOf<TextChangeListener>()

val lastNameChangeListeners =
mutableListOf<TextChangeListener>()

var firstName: CharSequence = ""
set(value) {
firstNameChangeListeners.forEach {
it.beforeChange(field)
}
field = value
firstNameChangeListeners.forEach {
it.afterChanged(field)
}
invalidate()
}

var lastName: CharSequence = ""
set(value) {
lastNameChangeListeners.forEach {
it.beforeChange(field)
}
field = value
lastNameChangeListeners.forEach {
it.afterChanged(value)
}
invalidate()
}

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

It’s a pretty straight forward change. We’ve replaced the firstNameChangeListener and lastNameChangeListener with a mutable list for both. For the sake of brevity, we will ignore adding the add/remove methods that you would normally see on a framework view and instead add and remove from the lists directly. I’ll also focus only on firstNameChangeListeners as the same approach is applicable to lastNameChangeListeners.

Our binding adapter is broken now as we need to add a listener instead of setting a single listener. The naive approach to this would be simply to do the following:

@BindingAdapter(
"android:beforeFirstNameChanged",
"android:afterFirstNameChanged", requireAll = false
)
fun UserView.bindFirstNameChangeListeners(
beforeFirstNameChanged: StringBindingConsumer?,
afterFirstNameChanged: StringBindingConsumer?
) {
if (beforeFirstNameChanged.notNull or
afterFirstNameChanged.notNull
) createTextChangeListenerWith(
beforeTextChanged = beforeFirstNameChanged,
afterTextChanged = afterFirstNameChanged
).also { textChangeListener ->
firstNameChangeListeners += textChangeListener
}
}
//... same for bindLastNameChangeListeners

In part 2, if beforeFirstNameChanged and afterFirstNameChanged were both null, then the single TextChangeListener on the UserView would be nulled. Secondly, if either beforeFirstNameChanged or afterFirstNameChanged were non-null, a new TextChangeListener would replace the previous instance on the UserView (if one is previously bound), so both cleanup cases were handled easily. This is particularly important when dealing with recycled views where different view model instances can be bound within the lifecycle of the view. This is not the case here with the above solution, listeners will stack up leading to potentially weird bugs if the views are recycled.

We need a reference to the previously bound TextChangeListener if we want to remove it from the UserView before adding a new one. While we could handle this logic inside UserView itself, lets continue to pretend we can’t modify it at this point, as this may be the case with framework views (subclassing views to add databinding peculiarities isn’t ideal). Thankfully there is a handy utility class inside the databinding library called ListenerUtil. It has 2 methods, trackListener and getListener, however we only care about trackListener for now.

Looking at the documentation for trackListener, it states:

This method tracks listeners for a View. Only one listener per listenerResourceId can be tracked at a time. This is useful for add*Listener and remove*Listener methods when used with BindingAdapters. This guarantees not to leak the listener or the View, so will not keep a strong reference to either.

trackListener accepts the following:

  1. a view
  2. a listener (any object actually, but mostly useful for listener type objects)
  3. an integer (preferably unique)

trackListener takes advantage of View.setTag(key: Int, object: Any), which internally holds a SparseArray of tags (a map of tags with integer keys). The listener is added as a keyed tag on the view, where the key is any unique integer, but recommended to be a unique integer generated from resources. trackListener also returns the previous listener that was added as a tag with the same key (if any), this is what ultimately allows us to clean up properly.

First, we should create a unique integer somewhere in our in a resource xml file such as:

<?xml version="1.0" encoding="utf-8"?>
<resources>
<id name="text_change_listener" />
</resources>

Then we can use trackListener in our binding adapter:

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

val oldTextChangeListener: UserView.TextChangeListener? =
ListenerUtil.trackListener(
this,
newTextChangeListener,
R.id.text_change_listener
)


if (oldTextChangeListener != null) {
firstNameChangeListeners -= oldTextChangeListener
}
if (newTextChangeListener != null) {
firstNameChangeListeners += newTextChangeListener
}

}

The process here is as follow:

  1. Create a newTextChangeListener if either beforeFirstNameChanged or afterFirstNameChanged is non-null.
  2. Pass in the view reference, the newTextChangeListener (which may be null), and the unique key to trackListener and take a reference to the returned oldTextChangeListener (which also may be null).
  3. If the oldTextChangeListener is non-null, then we should remove it from the view’s listener registry (UserView.firstNameChangeListeners ) .
  4. Similarly, if the newTextChangeListener is non-null, then we should add it it to the view’s listener registry.

This ensures us that there will only be a single listener bound at any one time via this binding adapter.

In almost all cases where you need to perform this sort of defensive listener cleanup inside a binding adapter, the ceremony will be the same. It’s not a difficult process to remember, but we can abstract most of this logic away inside a function elsewhere. To do that, we need to identify the variables in the process above, which are:

  1. The listener instance
  2. The unique integer ID
  3. The exact method signature of the addListener method
  4. The exact method signature of the removeListener method
  5. The view type
  6. The listener type

The unique integer ID is self explanatory, so lets take a look at the others. Since we can use this for cleaning up when no listener instance is present, the listener instance parameter must be nullable. Passing in the addListener and removeListener method requires us to pass in function types. Both those function types should be of the same type, that is, accept a non nullable listener of the same type given as the listener instance. In Kotlin, that would look something like:

(listener: ListenerType) -> Unit

Since the type of the listener and view will be different depending on the use case, we know that our function needs to have a generic type parameter for the listener, based on the given listener’s type, and also a generic type parameter for the view type.

As mentioned before ListenerUtil.trackListener can be used to track anything, so lets call our method trackInstance. Based on what we know, the signature of the method should look something like this:

inline fun <V : View, I> V.trackInstance(
newInstance: I?,
@IdRes instanceResId: Int,
onDetached: V.(I) -> Unit,
onAttached: V.(I) -> Unit
) {
// TODO: implement!
}

We can hint to the calling code that the instanceResID should be an integer from resources by using the IDRes annotation from the support annotations library. Making the function inline will save object allocation on the given function types for onDetached and onAttached.

Lets implement the function:

inline fun <V : View, I> V.trackInstance(
newInstance: I?,
@IdRes instanceResId: Int,
onDetached: V.(I) -> Unit = {},
onAttached: V.(I) -> Unit = {}
) {
ListenerUtil.trackListener(
this,
newInstance,
instanceResId
).let { oldInstance ->
if (oldInstance !== newInstance) {
oldInstance?.let { onDetached(oldInstance) }
newInstance?.let { onAttached(newInstance) }
}
}
}

The first thing is to call ListenerUtil.trackListener, giving this view, the newInstance to track and the instanceResId as the key. For short hand I’ve used let to capture the return value, which will be the oldInstance that was previously tracked — if any.

We should only continue if the oldInstance and newInstance are not equal, but because both oldInstance and newInstance may both be null, this check may still pass. Also, I’ve used the reference equality operator !== here as we shouldn’t be comparing object equality for listener type objects.

Finally, since onDetached and onAttached can only accept non-nullable types, a null check is required before calling each of them. This is really down to a matter of preference of traditional null checks vs Kotlin’s null-safe operator ?., which is what I’ve opted for. Using the null-safe operator on let allows us to capture value to the left hand side only when it’s non-null. We don’t need to use the convenience parameter it here as the value has already been safe-cast to non-null (highlighted in green in Android Studio/IntelliJ), but really just a matter of preference.

Now that we have this handy abstraction for tracking listeners, the binding adapter implementation can simply be replaced with:

@BindingAdapter(
"android:beforeFirstNameChanged",
"android:afterFirstNameChanged", requireAll = false
)
fun View.bindFirstNameChangeListeners(
beforeFirstNameChanged: StringBindingConsumer?,
afterFirstNameChanged: StringBindingConsumer?
) {
trackInstance(
newInstance = if (
beforeFirstNameChanged.notNull or
afterFirstNameChanged.notNull
) createTextChangeListenerWith(
beforeTextChanged = beforeFirstNameChanged,
afterTextChanged = afterFirstNameChanged
) else null,
instanceResId = R.id.text_change_listener,
onDetached = { firstNameChangeListeners -= it },
onAttached = { firstNameChangeListeners += it }
)

}

And that’s it!

In part 4 we will look at inverse binding adapters to allow us to have a more expressive relationship between UserView and UserViewModel. While this won’t change the current behaviour, it will trim down the binding adapters references in XML.

Stay tuned!

Android developer and Kotlin enthusiast.