Declarative lists on Android With RecyclerView + ViewBinding
One liner xml to stateful RecyclerView.ViewHolder instance
TJ Dahunsi
Mar 01 2020 · 7 min read
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:
-
Set a
LayoutManager
-
Create an
Adapter
to return the different kinds of view types you may have -
Specify
ViewHolder
classes for each of the different view types -
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<Int, Any>
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 extension
Deleted 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:
0