Binding Adapters with Kotlin: Part 1
Binding adapters are often a point of confusion for those new to data binding, and it’s easy to see why. They are pretty easy for simple cases, but tricky to get right when doing something slightly more complex. Once you throw two-way binding into the mix, things can become overwhelming fast.
But don’t worry, in this series I will cover the fundamentals of writing good binding adapters. By the end of this article, you will see that writing basic binding adapters in Kotlin is quite straightforward — as long as you follow some guidelines. By the end of the series, you’ll be writing binding adapters like a boss.
Wait, do I even need a Binding Adapter?
It depends. There are 3 ways in which a data bound variable can be bound to a view attribute.
Automatic Setters
To borrow an example of automatic setters from the documentation, an expression such as:
<TextView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:text="@{viewModel.textString}" />
Assuming viewModel.textString
is a String
, data binding will look for a method on TextView
with the signature:
public void setText(String value)
It won’t find that as it doesn’t exist, but since String
is a CharSequence
, it will also look for a method with the signature:
public void setText(CharSequence value)
Similarly, with the example:
<TextView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:text="@{viewModel.textResId}" />
Assuming viewModel.textResId
is an Int
, data binding will look for a method on TextView
with the signature:
public void setText(int value)
It’s less code to manage if you can get away with using automatic setters, but these are only for the simplest of cases.
Renamed Setters
Renamed setters allow you to associate some custom attribute name with a setter on the view type that it targets. They are useful when a setter on a view has a particularly long method signature, or when you want the renamed attribute to match the real view attribute name. To borrow another example from the documentation:
<ImageView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:imageTintList="@{viewModel.colorStateList}" />
This is using an automatic setter for setImageTintList
, but to shorten it we can create a renamed setter such as:
@BindingMethods(
BindingMethod(
type = ImageView::class,
attribute = "android:tint",
method = "setImageTintList"
)
)
class ImageViewBindingAdapters
Then the view attribute becomes slightly leaner:
<ImageView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:tint="@{viewModel.colorStateList}" />
Personally, I avoid renamed setters as they contain method names as string literals. Also, the BindingMethods
annotation needs to be associated with a class, which may or may not be completely empty such as ImageViewBindingAdapters
above. In the end it’s a matter of taste, the same can be achieved with super simple binding adapters.
Custom Setters
Custom setters, aka binding adapters should be used when you need to do something slightly more complicated, such as fetching an image then setting it on an ImageView
when it’s ready. Or simply when you need a middle man between data binding and the view to do some translation from the type passed to the binding adapter to a different type that is passed to the view. Also if you simply want to avoid using renamed setters, you will need to use binding adapters. They can also be used when you need to update multiple attributes on a view at the same time, or if you want to get feedback from the view back to the view-model.
Slow down! What is a Binding Adapter exactly!?
A binding adapter is simply a static or instance method that is used to manipulate how some user defined attributes map data bound variables to views.
Again, to elaborate on an example from the documentation, say we want to set the left padding on a view. The problem is that the method View.setPadding
takes 4 parameters, so automatic setters won’t work here.
We could write a binding adapter for a single padding, for example:
@BindingAdapter("android:paddingLeft")
fun View.bindPaddingLeft(paddingLeft: Int) {
setPadding(
paddingLeft,
// these call getPaddingTop etc on the receiver View
this.paddingTop,
this.paddingRight,
this.paddingBottom
)
}
Or we could write a binding adapter to facilitate setting all padding attributes:
@BindingAdapter(
"android:paddingLeft",
"android:paddingTop",
"android:paddingRight",
"android:paddingBottom", requireAll = false)
fun View.bindPadding(
paddingLeft: Int,
paddingTop: Int,
paddingRight: Int,
paddingBottom: Int
) {
setPadding(
paddingLeft,
paddingTop,
paddingRight,
paddingBottom
)
}
Extension Methods?
You may be wondering why they are written as extension methods, there are two reasons for this. It makes it clearer the type of view that the binding adapter targets, and in most cases it makes the method body leaner.
The reason they work as extension methods is because top level extension methods are translated to static methods, where the receiver type (the type to the left of the .
) becomes the first parameter in the underlying JVM method signature. This results in a valid binding adapter that the generated Java code can call.
If you are writing binding adapters as extension methods as part of a library, it may be desirable to mark them as internal
to hide them from Kotlin code in modules that consume the library. Since they are still callable from Java code in consuming modules, the generated data binding classes can call them without issue.
Parameters and Attributes
A binding adapter must have at least 1 attribute. The amount of attributes must match the amount of parameters that the binding adapter accepts plus one, as the first parameter is the view in which it operates on, aka the receiver of the extension method. The attribute "android:paddingLeft"
is associated with the paddingLeft
parameter and so on.
By default, requireAll
is true, by setting it as false simply allows us to omit the attributes in the xml layouts that we don’t care about.
Binding adapters with reference types
We’ll return to the padding example later, for now let’s say we have a custom view called UserView
, it will be responsible for displaying the first and last name of a UserViewModel
.
UserViewModel
will look something like:
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)
}
}
}
If you read my last article, you’ll know how I feel about properties like this, but lets keep it simple as the focus is on binding adapters.
UserView
will look something like:
class UserView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) { var firstName: CharSequence = ""
set(value) {
field = value
invalidate()
} var lastName: CharSequence = ""
set(value) {
field = value
invalidate()
} override fun onDraw(canvas: Canvas?) {
super.onDraw(canvas)
// imagine this actually renders text
}
}
Disclaimer: I wouldn’t normally write such a trivial custom view, it’s just easier to explain from scratch as TextView
and EditText
already have a lot of out the box binding adapters from the framework.
You can see the UserViewModel
and UserView
have almost matching properties, i.e. they both have firstName
and lastName
properties. On both the UserView
and UserViewModel
these properties are reference types, i.e. they are not primitive types.
What do you mean by this?
In Kotlin, the basic types such as Double
,Float
, Long
, Int
, Short
and Byte
are represented as JVM primitive types, where all other types are reference types (non primitive). If any of these basic types are marked as nullable, their JVM representation will be their boxed primitive equivalent, allowing them to be null.
We’ll get to why this is important soon.
The naive approach with automatic setters
The layout xml would look something like:
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"> <data>
<variable
name="viewModel"
type="com.aidanvii.example.UserViewModel" />
</data> <com.aidanvii.example.UserView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:firstName="@{viewModel.firstName}"
android:lastName="@{viewModel.lastName}" />
</layout>
We haven’t written a single binding adapter yet, but this will still compile as it will use automatic setters. However there is a flaw with this that can cause it to crash at runtime. If the generated binding class evaluates the bindings before a UserViewModel
is given to it, a NPE will be thrown.
Data Binding doesn’t respect non-null reference type parameters
That’s right, so when combined with Kotlin, things can get ugly. Both the firstName
and lastName
properties of UserView
are non-null reference types, meaning that if data binding decides to pass null, it will crash. Right down at the bottom of this section of the data binding documentation, it states:
If the expression cannot be evaluated due to
null
objects, Data Binding returns the default Java value for that type. For example,null
for reference types,0
forint
,false
forboolean
, etc.
Now matching that up with the Java to Kotlin interop documentation which states:
When calling Kotlin functions from Java, nobody prevents us from passing null as a non-null parameter. That’s why Kotlin generates runtime checks for all public functions that expect non-nulls. This way we get a
NullPointerException
in the Java code immediately.
Unfortunately the data binding compiler doesn’t check the nullability of reference type parameters in our code, be it through automatic setters or binding adapters. This means the underlying JVM setters for our Kotlin properties will throw a NPE when the generated data binding class passes a null to them. Ideally it would detect this and fail during compilation.
This could be solved by changing the UserView
, but lets assume we can’t change it for now. It can be tempting to create custom views to make our lives easier with data binding, but we often find ourselves in situations where we would rather use view classes from the SDK, or those from the support libraries directly without subclassing them. In most cases we should try to keep any data binding related peculiarities away from the view directly, and deal with them inside binding adapters.
How do we solve the null problem without touching the View?
By using binding adapters with nullable parameters, for example:
@BindingAdapter("android:firstName")
fun UserView.bindFirstName(firstName: CharSequence?) {
this.firstName = firstName ?: ""
}@BindingAdapter("android:lastName")
fun UserView.bindLastName(lastName: CharSequence?) {
this.lastName = lastName ?: ""
}
Here we simply guard against null by interpreting null as an empty string, it’s as simple as that!
Wrapping up
It is important that parameters of binding adapters written in Kotlin are nullable when those types are reference types such as CharSequence
. Basic types in Kotlin can safely be non-null parameters of binding adapters. This is because they are represented as JVM primitives which cannot be null, in which case data binding will pass the JVM defaults for those primitive types. That said, you should take care when doing this as the default values may be undesirable. To avoid this you can simply make the parameter type nullable which will translate to the equivalent boxed primitive reference type. This will cause data binding to pass null instead of the default value for the primitive equivalent.
Returning finally to the padding example, this is one such case where nullable boxed primitives may be desirable over the defaults for the JVM primitive types.
Lets say the view in the xml layout looks like this:
<View
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingLeft="@{@dimen/padding_left}"
android:paddingTop="@dimen/padding_top"
android:paddingRight="@dimen/padding_right"
android:paddingBottom="@dimen/padding_bottom" />
Only android:paddingLeft
will be set via the binding adapter as it is the only padding attribute that uses a binding expression, aka:
"@{/*binding expression in curly braces*/}”
Where the others will be set in the traditional way. However when this binding adapter is called, the default value 0
will be passed for the missing padding attributes, wiping out their current value. This can be fixed by making the parameters nullable boxed primitives such as:
@BindingAdapter(
"android:paddingLeft",
"android:paddingTop",
"android:paddingRight",
"android:paddingBottom", requireAll = false)
fun View.bindPadding(
paddingLeft: Int?,
paddingTop: Int?,
paddingRight: Int?,
paddingBottom: Int?
) {
setPadding(
paddingLeft ?: this.paddingLeft,
paddingTop ?: this.paddingTop,
paddingRight ?: this.paddingRight,
paddingBottom ?: this.paddingBottom
)
}
Now null
will be passed for paddingTop
, paddingRight
and paddingBottom
, where the binding adapter will ignore them, so the existing values will not be wiped.
Where to next?
Many view types have event listeners for listening to changes in the view, for example from user input. A classic example of this is by adding a TextWatcher
to an EditText
. In part 2 we will look at how to add similar functionality to our somewhat contrived UserView
— without all the bells and whistles of actually handling user interaction like EditText
does.