Nowadays, Jetpack Compose gains an adoption and it definitely deserves it. It is a real paradigm shift in Android UI development. So much creating lists boilerplate was eliminated there. And it’s beautiful.
I believe that many projects are still using the View framework and therefore using RecyclerView for having lists.
But anyway, Compose is the future and this is why I’m using the word “vintage” in a title.
Vintage means old but gold. This article is the result of a compilation of my experience with RecyclerView, and for those who are still on track with it, I’ll tell about my vision to building a framework around RecyclerView.
The framework should provide a sane level of scalability. So the main scenarios should be handled with a minimum amount of boilerplate, but it shouldn’t get in the way if the user wants to create something custom.
It is known best practice during working with RecyclerView is using DiffUtil
API for updating a data in list. We are not going to re-invent a wheel here, so we’ll focus on leveraging DiffUtil
and also will try to reduce a ceremony for its implementation.
It is natural to have several types of views in a list. Screens of mobile devices are constrained by height and different devices has different screen sizes. So even your content is fitting to screen at one device without scrolling, it could require to be scrolled at another device.
That’s why it can be a good idea to develop some screens on the top of RecyclerView.
Decorations are a very common case for lists. And although you can have decorations merged to items layouts, what is can look straightforward, it’s not very flexible: same items types can require different decorations at different screens or even different decorations depending on item position in a list.
The first option is to make everything from scratch. It’s giving full control over implementation, but we can always do it.
There are a bunch of third party open-source frameworks that allowing to work with RecyclerView with less boilerplate. I’m not going to enumerate all, but want to show two of them with completely opposite approaches:
Solution from AirBnb for creating complex screens with RecyclerView. It is adding a bunch of new APIs and even a code generation to reduce a boilerplate as much as possible.
But I can't get rid of the feeling that resulting code is looking foreign. Also, an annotation processing adding another layer of complexity.
Tiny library, that actually provides a concise bunch of functions and interfaces that only suggest you a way to split your heterogeneous list to set of delegated adapters. In fact, you can create such own solution in relatively short time.
I like small native solutions that simple enough to keep all the behavior at fingertips and that doesn’t hide under carpet all the implementation details. That’s why I believe that AdapterDelagates and its principles are a good candidate for the foundation of our framework.
The very basic thing for the beginning is a common declaration of list items. List items in the real world are very different, but the only thing we are confident in – we should be able to compare it.
I suggest to referring to API of DiffUtil.ItemCallback
interface ListItem {
fun isItemTheSame(other: ListItem): Boolean
fun isContentTheSame(other: ListItem): Boolean {
return this == other
}
}
It is a minimalistic (and therefore solid, IMO) declaration of a list item in the framework. Semantic of methods is intended to be the same as a DiffUtil using:
isItemTheSame
checks identity of other
and this
isContentTheSame
checks the data in other
and this
The most common way to provide a stable and unique identity is using identifiers that came from the server.
So let’s have an abstract implementation to reduce a bit of possible boilerplate:
abstract class DefaultListItem : ListItem {
abstract val id: String
override fun isItemTheSame(other: ListItem): Boolean = when {
other !is DefaultListItem -> false
this::class != other::class -> false
else -> this.id == other.id
}
}
isContentTheSame
in the vast majority of cases will be quite simple (an equality check), if implementations of ListItem will be a data class
.
KeepingListItem
separate is still reasonable in cases when you have items in your list that are not projection of server data and haven't any sane identity. For example, if you have a footer and header as items in a list, or you have a single item of some type:
data class HeaderItem(val title: String) : ListItem {
override fun isItemTheSame(other: ListItem): Boolean = other is HeaderItem
}
Suggested approach allows us to have a very natural and single DiffUtil
callback implementation:
object DefaultDiffUtil : DiffUtil.ItemCallback<ListItem>() {
override fun areItemsTheSame(oldItem: ListItem, newItem: ListItem): Boolean =
oldItem.isItemTheSame(newItem)
override fun areContentsTheSame(oldItem: ListItem, newItem: ListItem): Boolean =
oldItem.isContentTheSame(newItem)
}
And the final step here is a declaration of default adapter that extends AsyncListDifferDelegationAdapter
from AdapterDelegates:
class CompositeListAdapter :
AsyncListDifferDelegationAdapter<ListItem>(DefaultDiffUtil)
AdapterDelegates library provides a handy way to declare delegates for a particular view type. Only thing we can improve is reduce a boilerplate a bit:
inline fun <reified I : ListItem, V : ViewBinding> defaultAdapterDelegate(
noinline viewBinding: (layoutInflater: LayoutInflater, parent: ViewGroup) -> V,
noinline block: AdapterDelegateViewBindingViewHolder<I, V>.() -> Unit
) = adapterDelegateViewBinding<I, ListItem, V>(viewBinding = viewBinding, block = block)
For showcase purposes, let’s consider an example how would declaration of some TitleListItem
, containing one text field could look like:
data class TitleListItem(
override val id: String,
val title: String,
) : DefaultListItem()
and delegate for it
fun titleItemDelegate(onClick: ((String) -> Unit)) =
defaultAdapterDelegate<
TitleListItem,
TitleListItemBinding
>(viewBinding = { inflater, root -> TitleListItemBinding.inflate(inflater,root,false) }) {
itemView.setOnClickListener { it(item.id) }
bind {
binding.root.text = item.title
}
}
}
Originally, because of the API of RecyclerView
, setting decorations are made separately from setting data to list. If you have some logic for decorations like: all items should have offset at the bottom except the last one
, or all items should have a divider at the bottom, but headers should have an offset at bottom
than creating decoration becomes a pain.
Often teams coming with “god” decorator that can be configured:
class SmartDividerItemDecorator(
val context: Context,
val skipDividerFor: Set<Int> = emptySet(),
val showDividerAfterLastItem: Boolean = false,
val showDividerBeforeFirstItem: Boolean = false,
val dividerClipToPadding: Boolean = true,
val dividerPaddingLeft: Int = 0,
val dividerPaddingRight: Int = 0
) : ItemDecoration()
You can only imagine implementation details and how fragile and not scalable it is.
We can use an old proven approach with mimicry to someRecyclerView
API and delegating an implementation.
interface Decoration
Interface marker for decorations, that will be presented in the future.
interface HasDecorations {
var decorations: List<Decoration>
}
This interface should be implemented by our data items to declare that this particular item wants to be decorated with a given list of decorations. Decorations are var
in sake of simplicity to change it in runtime, if needed.
Very often an item has a single decoration, so to reduce boilerplate with wrapping a single item to listOf()
we can do such maneuver:
interface HasDecoration : HasDecorations {
var decoration: Decoration
override var decorations: List<Decoration>
get() = listOf(decoration)
set(value) {
decoration = when {
value.isEmpty() -> None
value.size == 1 -> value.first()
else -> throw IllegalArgumentException("Applying few decorations to HasDecoration instance is prohibited. Use HasDecorations")
}
}
}
Next stage is mimic to ItemDecoration
API:
interface DecorationDrawer<T : Decoration> {
fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State,
decoration: T
)
fun onDraw(
c: Canvas,
view: View,
parent: RecyclerView,
state: RecyclerView.State,
decoration: T
)
}
As you can see, it completely repeats ItemDecoration
with a single difference – now it’s aware of the decoration type and instance is coming.
Possible implementations for DecorationDrawer
is a topic of the second article, now let’s focus only on interface and how it should be handled to presenting decorations.
class CompositeItemDecoration(
private val context: Context
) : RecyclerView.ItemDecoration() {
private val drawers = mutableMapOf<Class<Decoration>, DecorationDrawer<Decoration>>() // [1]
private fun applyDecorationToView(parent: RecyclerView, view: View, applyDecoration: (Decoration) -> Unit) {
val position = getAdapterPositionForView(parent, view) // [2]
when { // [3]
position == RecyclerView.NO_POSITION -> return
adapter.items[position] is HasDecorations -> {
val decoration = (adapter.items[position] as HasDecorations).decorations
decoration.forEach(applyDecoration)
}
else -> return
}
}
override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State
) {
applyDecorationToView(parent, view) { decoration ->
val drawer = getDrawerFor(decoration)
drawer.getItemOffsets(outRect, view, parent, state, decoration)
}
}
override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
parent.forEach { child ->
applyDecorationToView(parent, child) { decoration ->
val drawer = getDrawerFor(decoration)
drawer.onDraw(c, child, parent, state, decoration)
}
}
}
private fun getDrawerFor(decoration: Decoration): DecorationDrawer<Decoration> {
// [4]
}
private fun getAdapterPositionForView(parent: RecyclerView, view: View): Int {
var position = parent.getChildAdapterPosition(view)
if (position == RecyclerView.NO_POSITION) {
val oldPosition = parent.getChildViewHolder(view).oldPosition
if (oldPosition in 0 until (parent.adapter?.itemCount ?: 0)) {
position = oldPosition
}
}
return position
}
}
getAdapterPositionForView()
is not something specific to decoration applying, it’s just a method for correctly resolving adapter position for given viewHasDecorations
instanceThis article discovered an approach to build a list framework around RecyclerView
. Delegation used as a base principle for wrapping list items and list decorations.
Nativeness. Implementation is not depending on third party libraries, that can reduce flexibility in the future. AdapterDelegates was chosen to consider exactly these criteria – it’s very simple and natural and all the behavior at your fingertips.
Scalability. Thanks to delegation and separation of concerns principle, we are able to decouple implementations of items delegates or decorations drawers.
It’s easy to integrate incrementally to an existing project
In the second part, we’ll discuss all types of list decorations that we probably expect to see in an Android application.