The second part of my vision is to develop a RecyclerView-based framework for building lists in Android applications. While the first part focuses on unveiling foundation decisions, this part is focused on implementation details.
À la carte means “according to menu“. So, I’m going to suggest solutions for the most popular list scenarios in mobile applications, and then you can choose.
Public API of RecyclerView.ItemDecoration
like a
fun getItemOffsets(...)
fun onDraw(canvas : Canvas, ...)
Hints for us: Iems can be decorated by an offset (not necessary in between items), and by drawing something on the canvas of the screen.
So in the framework, we’ll present implementations for the most common decorations:
Gap
. Decoration is based on applying an offset to list items.Divider
. Decoration is based on drawing a drawable in between items.Shape
. Decoration is based on drawing canvas primitives.
At the same time, the framework will be extensible enough to allow users to have their own decoration types with arbitrary implementations.
As a foundation for an efficient framework for drawing decorations, we are introducing a simple DecorationDrawer
implementation. This implementation aims to tackle potential performance concerns while also providing a high-level API for implementors.
abstract class OffsetDecorationDrawer<T : Decoration> : DecorationDrawer<T> {
private val decorationsToOffsets: MutableMap<Decoration, Int> = mutableMapOf()
override fun onDraw(c: Canvas, view: View, parent: RecyclerView, state: RecyclerView.State, decoration: T) {
//No op
}
protected fun getOffsetFor(decoration: T, ifEmpty: () -> Int): Int =
decorationsToOffsets.getOrPut(decoration, ifEmpty)
}
This offset-based drawer has a caching map of decorations to offsets. This is especially useful when offsets are declared as a DimenRes
, so we are not extracting a dimen size from Resources
over and over.
abstract class DrawableDecorationDrawer<T : Decoration> : DecorationDrawer<T> {
private val decorationsToDrawables: MutableMap<Decoration, Drawable> = mutableMapOf()
@CallSuper
override fun onDraw(c: Canvas, view: View, parent: RecyclerView, state: RecyclerView.State, decoration: T) {
val drawable = getDrawableFor(decoration)
setDrawableBounds(view, drawable)
drawable.draw(c)
}
abstract fun createDrawable(decoration: T): Drawable
abstract fun setDrawableBounds(view: View, drawable: Drawable)
private fun getDrawableFor(decoration: T): Drawable =
decorationsToDrawables.getOrPut(decoration) { createDrawable(decoration) }
}
This Drawable-based drawer has a similar caching mapper as well, and exposes some Drawable
related API.
Note, that these drawers are abstract enough, not declaring any constraints for T
. It allows users to use goodies of these drawers in their own implementations for custom decorations.
First, I'm declaring data, representing Gap decoration:
abstract class GapDecoration : Decoration {
@get:DimenRes
abstract val gap: Int
}
Each gap decoration keeps the size of the gap.
Now let’s have something more specific:
data class TopGap(override val gap: Int) : GapDecoration() {
companion object {
val gridHalf = TopGap(R.dimen.grid_0_5)
val grid1 = TopGap(R.dimen.grid_1)
val grid2 = TopGap(R.dimen.grid_2)
}
}
Data class is presenting top gap decoration in a list. It’s handy to have some set of the most common sizes.
The rest directions of gaps are pretty straightforward:
data class BottomGap(override val gap: Int) : GapDecoration() {
companion object {
// defaults
}
}
data class LeftGap(override val gap: Int) : GapDecoration() {
companion object {
// defaults
}
}
data class RightGap(override val gap: Int) : GapDecoration() {
companion object {
// defaults
}
}
class BottomGapDrawer(private val context: Context) : OffsetDecorationDrawer<BottomGap>() {
override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State,
decoration: BottomGap
) {
outRect.bottom += getOffsetFor(decoration) { context.resources.getDimensionPixelSize(decoration.gap) }
}
}
Just adding an offset to the appropriate side of outRect
. Implementations for TopGap
, LeftGap
and RightGap
are done in the same manner.
Now let’s pay closer attention to end users. Whereas putting a vertical gap in between items is simple, gapping of the grid items(RecyclerView with GridLayoutManager
) is verbose and error-prone.
So we could develop some utility functions for this:
fun <T : HasDecorations> List<T>.applyHorizontalGridGap(
spanCount: Int,
leftGap: LeftGap,
rightGap: RightGap
): List<T> =
mapIndexed { index, item ->
item.decorations += when (index % spanCount) {
0 -> listOf(rightGap)
spanCount.dec() -> listOf(leftGap)
else -> listOf(leftGap, rightGap)
}
item
}
Setting horizontal gaps to grid items according to logic: the first item in a row shouldn’t have a left gap, and the last item in a row shouldn’t have a right gap.
fun <T : HasDecorations> List<T>.applyVerticalGridGap(spanCount: Int, topGap: TopGap = TopGap.grid1): List<T> =
mapIndexed { index, item ->
if (index >= spanCount) item.decorations += topGap
item
}
The first row shouldn’t have a top gap. The rest of the rows should.
And a composite function that allows one to apply a gap to the grid in an elegant way:
fun <T : HasDecorations> List<T>.applyGridGap(spanCount: Int, @DimenRes gapSize: Int) {
applyHorizontalGridGap(spanCount, LeftGap(gapSize), RightGap(gapSize))
applyVerticalGridGap(spanCount, TopGap(gapSize))
}
Eventually, let’s add these drawers to CompositeItemDecoration
:
# CompositeItemDecoration.kt
private val drawers = mutableMapOf<Class<Decoration>, DecorationDrawer<Decoration>>()
// unrelated content is omitted
private fun getDrawerFor(decoration: Decoration): DecorationDrawer<Decoration> =
drawers.getOrPut(decoration.javaClass) {
val drawer = when (decoration) {
is BottomGap -> BottomGapDrawer(context)
is TopGap -> TopGapDrawer(context)
is LeftGap -> LeftGapDrawer(context)
is RightGap -> RightGapDrawer(context)
else -> NoneDecorationDrawer
}
drawer as DecorationDrawer<Decoration>
}
Note the NoneDecorationDrawer
as well:
object NoneDecorationDrawer : DecorationDrawer<None> {
override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State,
decoration: None
) {
// no op
}
override fun onDraw(
c: Canvas,
view: View,
parent: RecyclerView,
state: RecyclerView.State,
decoration: None
) {
// no op
}
}
abstract class DividerDecoration : Decoration {
@get:ColorRes
abstract val color : Int
}
All DividerDecoration
s will use the same drawable as a divider, but it’s possible to have a different color for drawable tinting.
Now let’s have decorations that draw a divider at the top and at the bottom of the item:
data class BottomDivider(override val color: Int) : DividerDecoration() {
companion object {
val default = BottomDivider(R.color.dividerDefaultColor)
}
}
data class TopDivider(override val color: Int) : DividerDecoration() {
companion object {
val default = TopDivider(R.color.dividerDefaultColor)
}
}
abstract class DividerDecorationDrawer<T : DividerDecoration>(private val context: Context) :
DrawableDecorationDrawer<T>() {
protected val divider = ContextCompat.getDrawable(context, R.drawable.list_divider)!!
override fun createDrawable(decoration: T): Drawable =
divider.constantState?.newDrawable()?.apply {
setTint(ContextCompat.getColor(context, decoration.color))
} ?: divider
}
Common drawer for divider decorations. It’s declaring a drawable for dividers, and implementing createDrawable
the method in a way that allows tint to be applied to the drawable correctly.
Implementation for BottomDivider
:
class BottomDividerDrawer(private val context: Context) :
DividerDecorationDrawer<BottomDivider>(context) {
override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State,
decoration: BottomDivider
) {
outRect.bottom += divider.intrinsicHeight
}
override fun setDrawableBounds(view: View, drawable: Drawable) {
drawable.setBounds(
view.left,
view.bottom,
view.right,
view.bottom + drawable.intrinsicHeight
)
}
}
We need to offset an item for the height of the drawable because we want not to overlap an item with a divider, but place said divider in between items. And then set the correct bounds for the drawable. Implementation for TopDivider
is similar.
Finally, add these drawers to CompositeItemDecoration
:
# CompositeItemDecoration.kt
private val drawers = mutableMapOf<Class<Decoration>, DecorationDrawer<Decoration>>()
// unrelated content is omitted
private fun getDrawerFor(decoration: Decoration): DecorationDrawer<Decoration> =
drawers.getOrPut(decoration.javaClass) {
val drawer = when (decoration) {
is BottomGap -> BottomGapDrawer(context)
is TopGap -> TopGapDrawer(context)
is LeftGap -> LeftGapDrawer(context)
is RightGap -> RightGapDrawer(context)
is TopDivider -> TopDividerDrawer(context)
is BottomDivider -> BottomDividerDrawer(context)
else -> NoneDecorationDrawer
}
drawer as DecorationDrawer<Decoration>
}
Whereas RecyclerView
uses DefaultItemAnimator for animating changes in a list, drawable-based decorations are not animated accordingly by default.
Let’s see:
To address this, we should synchronize the state of item’s View
with state of a decoration:
interface ViewAwareDrawableTransformer {
fun transform(drawable: Drawable, view: View)
}
This intends to transform the given drawable according to view properties. Let’s see what most useful transformers could look like:
class AlphaTransformer : ViewAwareDrawableTransformer {
override fun transform(drawable: Drawable, view: View) {
drawable.alpha = (view.alpha * 255).toInt()
}
}
or
class TranslationYTransformer : ViewAwareDrawableTransformer {
override fun transform(drawable: Drawable, view: View) {
drawable.apply {
updateBounds(
top = bounds.top + view.translationY.toInt(),
bottom = bounds.bottom + view.translationY.toInt(),
)
}
}
}
It’s worth remembering that since transform
method going to be invoked in onDraw
, we should avoid doing any additional allocations there.
Now we can use the composition of transformers in DrawableDecorationDrawer
:
abstract class DrawableDecorationDrawer<T : Decoration> : DecorationDrawer<T> {
open val transformers: List<ViewAwareDrawableTransformer> = emptyList()
// unrelated content is omitted
@CallSuper
override fun onDraw(c: Canvas, view: View, parent: RecyclerView, state: RecyclerView.State, decoration: T) {
val drawable = getDrawableFor(decoration)
setDrawableBounds(view, drawable)
transformers.forEach { it.transform(drawable, view) }
drawable.draw(c)
}
// unrelated content is omitted
}
We are allowing inheritors to overload transformations that apply to the drawable before drawing.
For instance DividerDecorationDrawer
will declare it like this:
abstract class DividerDecorationDrawer<T : Decoration>(context: Context) :
DrawableDecorationDrawer<T>() {
// unrelated content is omitted
override val transformers: List<ViewAwareDrawableTransformer> =
listOf(AlphaTransformer(), TranslationYTransformer())
}
Now, the result of applying such transformations to drawable-based decorations makes the updating of list data much smoother.
It’s worth demonstrating, that we can have drawable-based decorations be a bit more complex, in that simple divider line. Under the Shape
I’’ll mean decoration around list items like this:
sealed class RoundedShape : Decoration {
@get:ColorRes
abstract val color: Int
@get:DimenRes
abstract val radius: Int
abstract val style: Style
enum class Style {
STROKE, FILL
}
data class Top(
@ColorRes override val color: Int,
@DimenRes override val radius: Int,
override val style: Style
) : RoundedShape() {
companion object {
val default = Top(defaultColor, defaultRadius, Style.STROKE)
}
}
data class Bottom(
@ColorRes override val color: Int,
@DimenRes override val radius: Int,
override val style: Style
) : RoundedShape() {
companion object {
val default = Bottom(defaultColor, defaultRadius, Style.STROKE)
}
}
data class None(
override val color: Int,
override val style: Style
) : RoundedShape() {
override val radius: Int = 0
companion object {
val default = None(defaultColor, Style.STROKE)
}
}
data class All(
@ColorRes override val color: Int,
@DimenRes override val radius: Int,
override val style: Style
) : RoundedShape() {
companion object {
val default = All(defaultColor, defaultRadius, Style.STROKE)
}
}
companion object {
@DimenRes
val defaultRadius = R.dimen.grid_2
@ColorRes
val defaultColor = R.color.dividerDefaultColor
const val width = 1
}
}
Declaring a few types of decorations: Top
and Bottom
are using for the very first and very last items, None
is used in the middle, All
is useful when a list contains only one item. Also, we have two styles of rounded shape: Stroke
and Fill
.
As before, let’s pay some a little bit of attention to the end users and provide a handy way to apply a rounded shape to the list:
fun <T : HasDecorations> List<T>.applyRoundedShape(
@ColorRes color: Int = RoundedShape.defaultColor,
@DimenRes radius: Int = RoundedShape.defaultRadius,
style: RoundedShape.Style = RoundedShape.Style.STROKE
): List<T> =
mapIndexed { index, item ->
item.decorations = when {
index == 0 && size > 1 -> item.decorations + listOf(RoundedShape.Top(color, radius, style))
index == size - 1 && size > 1 -> item.decorations + listOf(RoundedShape.Bottom(color, radius, style))
size == 1 -> item.decorations + listOf(RoundedShape.All(color, radius, style))
else -> item.decorations + listOf(RoundedShape.None(color, style))
}
item
}
Before drawing, I want to look a bit ahead and remember, that list items are very likely to be clickable. So having rounded shape decoration around an item forces us to properly outline the reveal effect of the click:
abstract class RoundedShapeDrawer<T : RoundedShape>(private val context: Context) :
DrawableDecorationDrawer<T>() {
// 1
private val frameWidth = RoundedShape.width.dipToPixels(context.resources).toFloat()
protected val frameHalfWidth = (frameWidth / 2).roundToInt()
// 2
private val decorationsToOutlineProviders: MutableMap<Decoration, ViewOutlineProvider> = mutableMapOf()
// 3
override val transformers: List<ViewAwareDrawableTransformer> = listOf(
AlphaTransformer(), TranslationYTransformer()
)
override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State,
decoration: T
) {
// No op
}
// 4
override fun onDraw(c: Canvas, view: View, parent: RecyclerView, state: RecyclerView.State, decoration: T) {
super.onDraw(c, view, parent, state, decoration)
val outlineProvider = getOutlineProviderFor(decoration) { createOutlineProvider(decoration) }
if (view.outlineProvider == outlineProvider) return
view.outlineProvider = outlineProvider
if (view.clipToOutline) return
view.clipToOutline = true
}
abstract fun createOutlineProvider(decoration: T): ViewOutlineProvider
// 5
protected fun MaterialShapeDrawable.setupShape(decoration: T): Drawable = this
.apply {
val color = decoration.color
val style = decoration.style
fillColor =
if (style == RoundedShape.Style.STROKE) ColorStateList.valueOf(Color.TRANSPARENT)
else ColorStateList.valueOf(color)
strokeColor =
if (style == RoundedShape.Style.STROKE) ColorStateList.valueOf(color)
else ColorStateList.valueOf(Color.TRANSPARENT)
strokeWidth =
if (style == RoundedShape.Style.STROKE) frameWidth
else 0f
}
private fun getOutlineProviderFor(decoration: T, ifEmpty: () -> ViewOutlineProvider): ViewOutlineProvider =
decorationsToOutlineProviders.getOrPut(decoration, ifEmpty)
This is a bit massive, so here is a step-by-step explanation:
frameWidth
and frameWidthHalf
is intended to be used heavily during a setting of drawable bound, so we calculate it once and keep it for further use.View
of list item to support the reveal effect of the click. The approach for working with ViewOutlineProvider
is the same as we're having for Drawable
‘s – we are caching it, and avoiding any extra work.View
changes.onDraw
added a logic for setting ViewOutlineProvider
.setupShape
is intended to for use by inheritors to set up the final shape according to RoundedShape
that came.
Let’s see an RoundedShapeDrawer
implementations:
class AllRoundedShapeDrawer(private val context: Context) :
RoundedShapeDrawer<RoundedShape.All>(context) {
override fun createOutlineProvider(decoration: RoundedShape.All): ViewOutlineProvider =
object : ViewOutlineProvider() {
val radius = context.resources.getDimension(decoration.radius)
override fun getOutline(view: View, outline: Outline) {
outline.setRoundRect(
0,
0,
view.width,
view.height,
radius
)
}
}
override fun createDrawable(decoration: RoundedShape.All): Drawable {
val radius = context.resources.getDimension(decoration.radius)
return MaterialShapeDrawable(
ShapeAppearanceModel()
.toBuilder()
.setAllCornerSizes(radius)
.build()
).setupShape(decoration)
}
override fun setDrawableBounds(view: View, drawable: Drawable) {
drawable.setBounds(
view.left,
view.top,
view.right,
view.bottom
)
}
}
Simplest one. No extra logic, just creating ViewOutlineProvider
and MaterialShapeDrawable
According to bound of View
.
class NoneRoundedShapeDrawer(context: Context) :
RoundedShapeDrawer<RoundedShape.None>(context) {
override fun createDrawable(decoration: RoundedShape.None): Drawable =
MaterialShapeDrawable(ShapeAppearanceModel()).setupShape(decoration)
override fun setDrawableBounds(view: View, drawable: Drawable) {
drawable.setBounds(
view.left,
view.top - frameHalfWidth, // Overlapping neighbour frames to keep divider width not doubled
view.right,
view.bottom + frameHalfWidth // Overlapping neighbour frames to keep divider width not doubled
)
}
override fun createOutlineProvider(decoration: RoundedShape.None): ViewOutlineProvider =
object : ViewOutlineProvider() {
override fun getOutline(view: View, outline: Outline) {
outline.setRect(
0,
0,
view.width,
view.height,
)
}
}
}
Here is setDrawableBounds
is addressing an issue, when two neighbour items are decorated by RoundedShape.None
:
class TopRoundedShapeDrawer(private val context: Context) :
RoundedShapeDrawer<RoundedShape.Top>(context) {
override fun createDrawable(decoration: RoundedShape.Top): Drawable {
val radius = context.resources.getDimension(decoration.radius)
return MaterialShapeDrawable(
ShapeAppearanceModel()
.toBuilder()
.setTopLeftCornerSize(radius)
.setTopRightCornerSize(radius)
.build()
).setupShape(decoration)
}
override fun setDrawableBounds(view: View, drawable: Drawable) {
drawable.setBounds(
view.left,
view.top,
view.right,
view.bottom + frameHalfWidth // Overlapping neighbor frames to keep divider width not doubled
)
}
override fun createOutlineProvider(decoration: RoundedShape.Top): ViewOutlineProvider =
object : ViewOutlineProvider() {
val radius = context.resources.getDimension(decoration.radius)
override fun getOutline(view: View, outline: Outline) {
outline.setRoundRect(
0,
0,
view.width,
// Only rect/round rect/oval is fully supported by rendering pipeline.
// We need rounded rect with different corners. This is only workaround for having it.
view.height + radius.roundToInt(),
radius
)
}
}
}
Here is createOutlineProvider
is performing a trick to have an outline provider in a rect with only two rounded corners. Here is on such issue.
BottomRoundedShapeDrawer
is omitted because of its similarity with TopRoundedShapeDrawer
.
Finally, add RoundedShape
drawers to CompositeItemDecoration
:
# CompositeItemDecoration.kt
private val drawers = mutableMapOf<Class<Decoration>, DecorationDrawer<Decoration>>()
// unrelated content is omitted
private fun getDrawerFor(decoration: Decoration): DecorationDrawer<Decoration> =
drawers.getOrPut(decoration.javaClass) {
val drawer = when (decoration) {
is BottomGap -> BottomGapDrawer(context)
is TopGap -> TopGapDrawer(context)
is LeftGap -> LeftGapDrawer(context)
is RightGap -> RightGapDrawer(context)
is TopDivider -> TopDividerDrawer(context)
is BottomDivider -> BottomDividerDrawer(context)
is RoundedShape.Top -> TopRoundedShapeDrawer(context)
is RoundedShape.Bottom -> BottomRoundedShapeDrawer(context)
is RoundedShape.None -> NoneRoundedShapeDrawer(context)
is RoundedShape.All -> AllRoundedShapeDrawer(context)
else -> NoneDecorationDrawer
}
drawer as DecorationDrawer<Decoration>
}
So as soon we have all default decorations in our framework, it’s time to remember about basis – HasDecorations
interface. It declares multiple decorations for each item. I think it’s useful for scenarios when you need to combine some divider and offset after:
However, it has limited support in the current framework implementation. For example, it doesn’t support ordering or applying decorations, and it does not considering “shift” of canvas after previously applied offset-based decoration. So multiple decorations should be used carefully and without any extra expectations, it works only for primitive scenarios.
The final product of this article series is a framework around RecyclerView
that focused on scalability and high standards for UI. It suggests to users a list of out-of-box decorations, and this list is not limited and can easily be extended according to your requirements. Here are a number of highlights that I’d like to mention:
Originally, RecyclerView
supposes that decorations of list items is intended to be aside to list items data. This leads to mind “splitting“ for developers and getting in to have some generic solution for decorations in the project. Also, this approach is making your decorations kind of static – it’s very hard to control where decorations should be applied with updated list data.
Our framework is merging decorations and item data. Take a look:
private var items = listOf<ListItem>(
TitleListItem(
id = "1",
decorations = listOf(BottomGap.grid1),
title = "Title1"
),
TitleListItem(
id = "2",
decorations = listOf(BottomGap.grid1),
title = "Title2"
),
TitleListItem(
id = "3",
decorations = listOf(BottomDivider.default, BottomGap.grid1),
title = "Title3"
)
)
Now it’s declarative, you directly see which items will be decorated and how. This gives much more control to developers. What is more important – now it’s easy to unit test the application of decorations. It is just assertions on the content of list data.
This framework is heavily based on the delegation principle. It makes it very flexible. In this article, I’ve described different and most popular use cases for decorations and suggested an abstract foundation like a DrawableDecorationDrawer
and OffsetsDecorationDrawer
for your future implementations. But again – if you need something very custom, it’s easy to integrate it into a framework.
Forget about fragile “god“ RecyclerView.ItemDecorators
implementations. Now, the application decorations are a split extendable list of individual isolated pieces.
Despite the flexibility of the proposed approach, the framework does not forget to care about the quality of the final UI. It’s supporting View
– aware transformations after list data updates, and these transformations are easy to reuse. It’s even giving out-of-box care about clipping reveal effect on item click to achieve high-quality UI standards.