Adetunji Dahunsi

Declarative lists on Android With RecyclerView + ViewBinding

One liner xml to stateful RecyclerView.ViewHolder instance

TJ Dahunsi

Mar 01 2020 · 7 min read

Categories:
android
kotlin

The RecyclerView is an extremely flexible and indispensable Android ViewGroup. It unfortunately requires quite a bit of boilerplate to set up; a fully functional RecyclerView asks that you:

  1. Set a LayoutManager

  2. Create an Adapter to return the different kinds of view types you may have

  3. Specify ViewHolder classes for each of the different view types

  4. Implement binding for each of them

That’s at least two classes that need to subclassed for every list shown and gets tedious very, very quickly.

Write once, use everywhere

In the case of the Adapter, one remedy is to create a method that takes individual arguments that maps to each public RecyclerView.Adapter method, where the only arguments required are the necessities like those for creating and binding ViewHolders. This allows for composing an Adapter so it becomes completely unnecessary to extend the base RecyclerView.Adapter class anymore.

1private class ComposedAdapter<ItemT : Any?, VH : RecyclerView.ViewHolder>( 2 private val itemsSource: () -> List<ItemT>, 3 private val viewHolderCreator: (ViewGroup, Int) -> VH, 4 private val viewHolderBinder: (holder: VH, item: ItemT, position: Int) -> Unit, 5 private val viewTypeFunction: ((ItemT) -> Int)? = null, 6 private val itemIdFunction: ((ItemT) -> Long)? = null, 7 ... /* other callbacks follow */ 8) : RecyclerView.Adapter<VH>() { 9 10 init { 11 setHasStableIds(itemIdFunction != null) 12 } 13 14 override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH = viewHolderCreator(parent, viewType) 15 16 override fun onBindViewHolder(holder: VH, position: Int) = viewHolderBinder(holder, itemsSource()[position], position) 17 18 override fun getItemCount(): Int = itemsSource().size 19 20 override fun getItemViewType(position: Int): Int = 21 viewTypeFunction?.invoke(itemsSource()[position]) ?: super.getItemViewType(position) 22 23 override fun getItemId(position: Int): Long = 24 itemIdFunction?.invoke(itemsSource()[position]) ?: super.getItemId(position) 25 26 override fun onBindViewHolder(holder: VH, position: Int, payloads: MutableList<Any>) = 27 viewHolderPartialBinder?.invoke(holder, itemsSource()[position], position, payloads) 28 ?: super.onBindViewHolder(holder, position, payloads) 29 ... /* ... and so on */ 30}

Breaking out all the callbacks for a RecyclerView Adapter out to allow for easy composition

A version for composing a ListAdapter also exists here: tunjid/Android-Extensions An Android library with modules to quickly bootstrap an Android application. - tunjid/Android-Extensionsgithub.com

Enter ViewBinding

With Android Studio 3.6’s stable release, we can use ViewBinding to further cut down on boilerplate with respect to ViewHolder creation as well, as fundamentally there is little difference between the intent behind the view holder pattern, and a view binding, i.e they are both wrapper classes around one or more View instances.

First, taking a look at the APIs, A RecyclerView.ViewHolder has a reference to its root itemView. Similarly, all generated ViewBinding classes inherit from the ViewBinding interface with the following signature:

1/** A type which binds the views in a layout XML to fields. */ 2public interface ViewBinding { 3 /** 4 * Returns the outermost {@link View} in the associated layout file. If this binding is for a 5 * {@code <merge>} layout, this will return the first view inside of the merge tag. 6 */ 7 @NonNull 8 View getRoot(); 9}

From a view property perspective, the APIs are identical. Peeking further into generated ViewBinding classes like outlined in Mark Allison’s Styling Android post, the following generated static methods can be seen to exist on every ViewBinding instance:

1public final class MyViewHolderBinding implements ViewBinding { 2 @NonNull 3 private final ConstraintLayout rootView; 4 5 @NonNull 6 public final TextView text1; 7 8 private MyViewHolderBinding(@NonNull ConstraintLayout rootView, @NonNull TextView text1) { 9 this.rootView = rootView; 10 this.text1 = text1; 11 } 12 13 @Override 14 @NonNull 15 public ConstraintLayout getRoot() { 16 return rootView; 17 } 18 19 @NonNull 20 public static MyViewHolderBinding inflate(@NonNull LayoutInflater inflater) { 21 ... 22 } 23 24 @NonNull 25 public static MyViewHolderBinding inflate(@NonNull LayoutInflater inflater, 26 @Nullable ViewGroup parent, boolean attachToParent) { 27 ... 28 } 29 30 @NonNull 31 public static MyViewHolderBinding bind(@NonNull View rootView) { 32 ... 33}

The great thing here is the static inflate(@NonNull LayoutInflater inflater, @Nullable ViewGroup parent, boolean attachToParent) method. Its signature is nearly identical to that used in the onCreateViewHolder for a RecyclerView.Adapter: it takes a parent ViewGroup and inflates a View using the LayoutParams of the parent, and crucially doesn’t attach it to the parent ViewGroup. Attachment is left to the RecyclerView to do when it’s ready to add the ViewHolder to the list, and when it wants to detach it for recycling.

Taking advantage of this, We can create a BindingViewHolder class generic on ViewBinding type, that holds a reference to the ViewBinding, not just the itemView.

1open class BindingViewHolder<T : ViewBinding> private constructor( 2 val binding: T 3) : RecyclerView.ViewHolder(binding.root) { 4 constructor( 5 parent: ViewGroup, 6 creator: (inflater: LayoutInflater, root: ViewGroup, attachToRoot: Boolean) -> T 7 ) : this(creator( 8 LayoutInflater.from(parent.context), 9 parent, 10 false 11 )) 12} 13 14fun <T : ViewBinding> ViewGroup.viewHolderFrom( 15 creator: (inflater: LayoutInflater, root: ViewGroup, attachToRoot: Boolean) -> T 16): BindingViewHolder<T> = BindingViewHolder(this, creator)

So if you have an existing XML file that you intend to use in a ViewHolder, creating a ViewHolder instance is as simple simple as passing the method references of the static inflate method and calling:

1override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingViewHolder<MyViewHolderBinding> = 2 parent.viewHolderFrom(MyViewHolderBinding::inflate)

XML to ViewHolder in a clean 1 liner using a method reference to the static inflate method

Going further with dynamic property binding

Despite this, creating a ViewHolder is only part of the way to a fully working RecyclerView implementation. ViewHolders are often bound to model objects so that when a child View is interacted with, clicked for example, a callback like a View.OnClickedListener is invoked and some action performed. A ViewHolder wrapper around a ViewBinding can only take us so far, a BindingViewHolder should also allow the seamless binding of generic properties without having to subclass it again, otherwise, we could just directly subclass RecyclerView.ViewHolder, and pass it the ViewBinding reference directly.

This yet again is another example where Kotlin shines as a language because of how malleable it is to a developer’s intent: the general idea is to create a delegate on a BindingViewHolder that can statically store and retrieve any property of any type on the ViewHolder while not holding a strong reference to it that can cause a memory leak. In other words a generic function <T>(BindingViewHolder<VB>) -> T that can be used as a delegate.

The Android View class via its getTag(@resourceId id: Int) and setTag(@resourceId id: Int, item: Any?) API is pretty much equivalent to a Map&lt;Int, Any&gt; interface. The only caveat here is that the key must be an Android resource Id to reduce the likelihood of hash collisions internally, and if the resource is private, make it almost impossible to leak it outside the module the id is created and used in. We can (ab?)use this however in the following fashion:

1open class BindingViewHolder<T : ViewBinding> private constructor( 2 val binding: T 3) : RecyclerView.ViewHolder(binding.root) { 4 ... 5 6 class Prop<T> : ReadWriteProperty<BindingViewHolder<*>, T> { 7 override fun getValue(thisRef: BindingViewHolder<*>, property: KProperty<*>): T { 8 @Suppress("UNCHECKED_CAST") 9 return thisRef.propertyMap[property.name] as T 10 } 11 12 override fun setValue(thisRef: BindingViewHolder<*>, property: KProperty<*>, value: T) { 13 thisRef.propertyMap[property.name] = value 14 } 15 } 16} 17 18 19@Suppress("UNCHECKED_CAST") 20private inline val BindingViewHolder<*>.propertyMap 21 get() { 22 val existing = itemView.getTag(R.id.recyclerview_view_binding_map) as? MutableMap<String, Any?> 23 val delegateMap = existing ?: mutableMapOf<String, Any?>() 24 .also { itemView.setTag(R.id.recyclerview_view_binding_map, it) } 25 return delegateMap 26 }

With the power of Kotlin delegate, we delegate to the root View of the ViewBinding instance in the BindingViewHolder (which is the same reference of the itemView of the same) to store a reference to a MutableMap<String, Any?> that is lazily initialized. When attempting to read a property from the BindingViewHolder, this Map is read from, and likewise when written to. This makes it possible to declare properties on any BindingViewHolder<T> of any ViewBinding that can be written to in onBindViewHolder of the Adapter, and read from when an View.OnClickListener is invoked.

You also needn’t worry if this is inefficient; looking up a View’s tag is implemented internally with a SparseArray which uses binary search for lookups, the MutableMap is constant time, and the delegate implementation is identical to the by Map delegate in the Kotlin Standard Library. If the extension method that uses the Prop delegate is defined in a static context, an extra benefit of having only one Prop delegate instance being created for the app, inlining the code for the lookup.

How significant is all this? The following is the full implementation of a RecyclerView that shows the result of a Bluetooth Low Energy scan in a Fragment.

1class BleScanFragment : AppBaseFragment(R.layout.fragment_ble_scan) { 2 3 private val viewModel by viewModels<BleViewModel>() 4 5 override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 6 super.onViewCreated(view, savedInstanceState) 7 8 val recyclerView = FragmentBleScanBinding.bind(view).list.apply { 9 layoutManager = verticalLayoutManager() 10 adapter = adapterOf( 11 itemsSource = viewModel::scanResults, 12 viewHolderCreator = { parent, _ -> 13 parent.viewHolderFrom(ViewholderScanBinding::inflate).apply { 14 itemView.setOnClickListener { onBluetoothDeviceClicked(result.device) } 15 } 16 }, 17 viewHolderBinder = { viewHolder, scanResult, _ -> viewHolder.bind(scanResult) } 18 ) 19 20 viewModel.devices.observe(viewLifecycleOwner) { diffResult -> 21 recyclerView.adapter?.let { diffResult.dispatchUpdatesTo(it) } } 22 } 23 } 24 } 25 26 private fun onBluetoothDeviceClicked(bluetoothDevice: BluetoothDevice) { 27 uiState = uiState.copy(snackbarText = bluetoothDevice.address) 28 } 29} 30private var BindingViewHolder<ViewholderScanBinding>.result by BindingViewHolder.Prop<ScanResultCompat>() 31 32fun BindingViewHolder<ViewholderScanBinding>.bind(result: ScanResultCompat) { 33 this.result = result 34 if (result.scanRecord != null) binding.apply { 35 deviceName.text = result.scanRecord!!.deviceName 36 deviceAddress.text = result.device.address 37 } 38}

Easy, simple, clean, and extremely convenient. No subclasses of RecyclerView.Adapter or RecyclerView.ViewHolder are necessary, all with a declarative API.

Even more stark is the reduction in the number of lines of code for the implementation of the RecyclerView, I’ll let the following git diff illustrate this:

Added and changed code for BindingViewHolder and static property extensionAdded and changed code for BindingViewHolder and static property extension

Deleted code for previous standalone ViewHolder classDeleted code for previous standalone ViewHolder class

This motif is used throughout the Android-Extensions project linked below if you want more examples that include shared element transitions, or animations: