visit
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.
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.
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.
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 suggest to referring to API of DiffUtil.ItemCallback
interface ListItem {
fun isItemTheSame(other: ListItem): Boolean
fun isContentTheSame(other: ListItem): Boolean {
return this == other
}
}
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.
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)
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()
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.
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 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.