paint-brush
Super-Powered Vintage Lists in Android: Decorations à la Carteby@6hundreds

Super-Powered Vintage Lists in Android: Decorations à la Carte

by Sergey OpivalovSeptember 6th, 2023
Read on Terminal Reader
Read this story w/o Javascript
tldt arrow

Too Long; Didn't Read

In the second part of this series, we delve into the practical implementation of a RecyclerView-based framework for Android lists. This framework introduces data-driven decorations, simplifies UI enhancements, and merges decorations with item data, resulting in greater control and flexibility for developers. Discover how this approach streamlines the creation of scalable and high-quality UIs for your Android applications.
featured image - Super-Powered Vintage Lists in Android: Decorations à la Carte
Sergey Opivalov HackerNoon profile picture


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.


Drawing foundation

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.


Offset

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.


Drawable

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.



Gap


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
 }
}


Drawing

​​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.


Utility

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
 }
}



Divider



abstract class DividerDecoration : Decoration {

  @get:ColorRes
  abstract val color : Int
}


All DividerDecorations 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)
  }
}


Drawing

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>
 }


Transformations

Whereas RecyclerView uses DefaultItemAnimator for animating changes in a list, drawable-based decorations are not animated accordingly by default.



Let’s see:

Look at not animated dividers



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.


Now dividers are animated properly


Shape

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
  }


Drawing

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:


  1. 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.
  2. As I mentioned at the beginning of the section – we need to properly outline 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.
  3. We want to support transformations according to View changes.
  4. In onDraw added a logic for setting ViewOutlineProvider.
  5. 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>
 }


Decorations composition

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.


Conclusion

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:


Decorations are data now

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.

Decorations delegation

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.

UI quality

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.