Adetunji Dahunsi

Android Views as a Function of State with ViewBinding Case Study; RecyclerViews, A/B testing and…

The RecyclerView is a lot like Lego, endlessly configurable

TJ Dahunsi

Feb 14 2021 · 8 min read

Categories:
android

This post is part of a series that shows how Views in android can be represented purely as a function of some state with no side effects helping to build a robust, easy to scale and maintain app with the help of ViewBinding. The previous entry covering animations can be found here here.

The RecyclerView is an insanely flexible ViewGroup. The packaging on its tin extols its benefits for displaying large lists in various configurations in an app, but in this post, we’re going to be looking at more interesting, even unorthodox, yet sensible usages of it including:

  1. Dynamic Content

  2. A/B testing

  3. Tables/spreadsheets

Core to the philosophy of Views being a function of state is that changes to state is changes in state cascade into changes in the UI. This all sounds good in theory, but in practice, the method of dispatching change is non trivial. The View hierarchy is a tree, and any one of the nodes in said tree may have changed. This is one of the things Jetpack compose is tackling head on, but till then, we have the next best thing, RecyclerView and DiffUtil. Let me illustrate with an example:

Dynamic Content

In the Team Manager app we have a couple of screens whose layouts are dependent on the sport in focus:

  1. The GameStream

  2. Live Video

Two different scoreboards, Basketball left, baseball/softball right

Score view holders for live streaming for basketball, baseball/softball and hockey

While not typical, the scoreboards in either the game stream or live video contexts can be thought of a RecyclerView with a singleton list, each scoreboard representing a different view type.

1 2sealed class ScoreBoard { 3 data class Basic( 4 val state: LiveVideoScore? 5 ) : ScoreBoard() 6 7 data class AdvancedBats( 8 val awayTeamName: String, 9 val homeTeamName: String, 10 val awayScore: String, 11 val homeScore: String, 12 val inningNumber: String, 13 val inningPart: InningPart, 14 val ballNumber: String, 15 val strikeNumber: String, 16 val outs: Int, 17 val runners: Set<Base> 18 ) : ScoreBoard() 19 20 data class AdvancedHoops( 21 val ownScore: String, 22 val oppScore: String, 23 val ownName: String, 24 val oppName: String, 25 val period: Int? 26 ) : ScoreBoard() 27 28 val hasScores: Boolean 29 get() { 30 return when (this) { 31 is Basic -> state?.hasScores ?: false 32 else -> true 33 } 34 } 35}

With the above, when the ViewModel has figured out the sport in context, it just updates the singleton list and when change is dispatched to the RecyclerView, it takes care of removing the invalid ViewHolder and replacing it with the sport appropriate one.

A/B Testing

One of the most valuable things when growing a product is user testing. The changes that come as a result of user feedback can be quite drastic, so it’s important to architect and build with that flexibility in mind, making a RecyclerView indispensable for rapid iteration. Why? Well the RecyclerView is highly composable, by swapping out its LayoutManger, you can drastically change it’s appearance with minimal configuration; again this is best illustrated with an example:

The same data presented in different layouts

In the above, depending on the status of a feature tag, we can dynamically swap between a LinearLayoutManager and GridLayoutManager, and also map different items to different view types. This is especially convenient as it’s the same Fragment and same ViewModel, the same state can drive both layouts with no issues:

1override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 2 super.onViewCreated(view, savedInstanceState) 3 val binding = FragmentHoopsTeamBinding.bind(view) 4 val rosterAdapter = rosterAdapter() 5 binding.list.apply { 6 layoutManager = if (FeatureTag.newHoopsSubstitutionUI.isOn) { 7 gridLayoutManager(spanCount = RosterItem.maxSpanSize) { index -> 8 viewModel.items.value?.getOrNull(index)?.spanSize ?: RosterItem.maxSpanSize 9 } 10 } else LinearLayoutManager(view.context) 11 adapter = rosterAdapter 12 itemAnimator = null 13 } 14 viewModel.items.observe(viewLifecycleOwner, rosterAdapter::submitList) 15} 16 17private fun rosterAdapter() = listAdapterOf( 18 initialItems = viewModel.items.value ?: listOf(), 19 viewHolderCreator = { viewGroup, viewType -> 20 when (viewType) { 21 RosterItem.CourtHeader::class.hashCode(), 22 RosterItem.BenchHeader::class.hashCode() -> 23 viewGroup.viewHolderFrom(ViewholderHeaderBinding::inflate).apply { 24 if (FeatureTag.newHoopsSubstitutionUI.isOn) binding.root.setBackgroundColor(Color.WHITE) 25 } 26 RosterItem.CourtPlayer::class.hashCode() -> 27 if (FeatureTag.newHoopsSubstitutionUI.isOn) { 28 viewGroup.viewHolderFrom(ViewholderHoopsCourtPlayerBinding::inflate) 29 } else { 30 viewGroup.viewHolderFrom(ViewholderHoopsRosterEntryBinding::inflate).apply { 31 binding.button.setOnClickListener { 32 toggleActiveStatus(binding.player, binding.isActive) 33 } 34 } 35 } 36 ... 37 else -> throw IllegalArgumentException("Unknown viewType $viewType in scoring table viewHolderCreator") 38 } 39 }, 40 viewHolderBinder = { holder, item, _ -> 41 when { 42 ... 43 holder.binding is ViewholderHoopsRosterEntryBinding -> when (item) { // TODO remove when newHoopsSubstitutionUI is removed, along with XML 44 is RosterItem.Player -> holder.binding<ViewholderHoopsRosterEntryBinding>().apply { 45 name.text = item.player.fullNameOrNumber 46 number.text = item.number 47 avatar.config = item.player.avatar 48 ... 49 } 50 else -> Unit 51 } 52 holder.binding is ViewholderHoopsCourtPlayerBinding -> when (item) { 53 is RosterItem.CourtPlayer -> holder.typed<ViewholderHoopsCourtPlayerBinding>().apply { 54 binding.name.text = item.player.fullNameOrNumber 55 binding.number.text = item.number 56 binding.avatar.config = item.player.avatar 57 ... 58 } 59 else -> throw IllegalArgumentException("Invalid binding") 60 } 61 } 62 }, 63 viewTypeFunction = { it::class.hashCode() } 64 )

If the RecyclerView syntax above is not immediately familiar to you, catch up here: Declarative lists on Android With RecyclerView + ViewBinding One liner xml to stateful RecyclerView.ViewHolder instanceproandroiddev.com

Tables

So while conceptually easy to visualize, it turns out tables in android whose cells only update when the value changes, not when the entire row does are not exactly the easiest thing to create a custom view for. So rather than reinvent the wheel, can we be super clever about this?

One of my favorite coding problems are problems that can be solved with a divide and conquer approach. While a data table is daunting at first, what is the simplest case of one, i.e the base case? It’s a single horizontally scrolling RecyclerView. If we somehow had a class that could synchronize scrolling multiple RecyclerView instances, then we’d be done; let’s go ahead and build one.

1/** 2 * A class that synchronizes scrolling multiple [RecyclerView]s, useful for creating tables. 3 * 4 * It is optimized around usage with a [LinearLayoutManager]. The [CellSizer] provides information 5 * on how large a cell is in any row to keep all [RecyclerView] instances synchronized 6 */ 7class RecyclerViewMultiScroller( 8 @RecyclerView.Orientation 9 private val orientation: Int = RecyclerView.HORIZONTAL, 10 private val cellSizer: CellSizer = DynamicCellSizer(orientation) 11) { 12 var displacement = 0 13 private set 14 private var active: RecyclerView? = null 15 private val syncedScrollers = mutableSetOf<RecyclerView>() 16 private val displacementListeners = mutableListOf<(Int) -> Unit>() 17 18 private val onScrollListener = object : RecyclerView.OnScrollListener() { 19 override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { 20 if (orientation == RecyclerView.HORIZONTAL && dx == 0) return 21 if (orientation == RecyclerView.VERTICAL && dy == 0) return 22 23 if (active != null && recyclerView != active) return 24 25 active = recyclerView 26 syncedScrollers.forEach { if (it != recyclerView) it.scrollBy(dx, dy) } 27 displacement += if (orientation == RecyclerView.HORIZONTAL) dx else dy 28 displacementListeners.forEach { it(displacement) } 29 } 30 31 override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { 32 if (active != recyclerView) return 33 34 displacementListeners.forEach { it(displacement) } 35 if (newState == RecyclerView.SCROLL_STATE_IDLE) active = null 36 } 37 } 38 39 private val onAttachStateChangeListener = object : View.OnAttachStateChangeListener { 40 override fun onViewAttachedToWindow(v: View?) { 41 if (v is RecyclerView) include(v) 42 } 43 44 override fun onViewDetachedFromWindow(v: View?) { 45 if (v is RecyclerView) exclude(v) 46 } 47 } 48 49 private val onItemTouchListener = object : RecyclerView.SimpleOnItemTouchListener() { 50 override fun onInterceptTouchEvent(rv: RecyclerView, e: MotionEvent): Boolean { 51 // If the user flung the list, then touches any other synced list, stop scrolling 52 if (e.actionMasked == MotionEvent.ACTION_DOWN && active != null) active?.stopScroll() 53 return when(active) { 54 null -> false // return false if active is null, we aren't trying to override default scrolling 55 else -> rv != active // Ignore touches on other RVs while scrolling is occurring 56 } 57 } 58 } 59 60 fun clear() = stub() 61 62 fun add(recyclerView: RecyclerView) = stub() 63 64 fun remove(recyclerView: RecyclerView) = stub() 65}

API overview for a RecyclerView Multiscroller

A brief summary of what is going on above is, when a single RecyclerView is scrolled, the RecyclerViewMultiScroller grabs all other included RecyclerView instances and manually scrolls them. With that defined, let’s define the horizontal RecyclerView for each row.

1private var BindingViewHolder<ViewholderStandingsRowBinding>.scroller by viewHolderDelegate<RecyclerViewMultiScroller>() 2private var BindingViewHolder<ViewholderStandingsRowBinding>.cellClicked by viewHolderDelegate<(Cell) -> Unit>() 3private var BindingViewHolder<ViewholderStandingsRowBinding>.row by viewHolderDelegate<Row>() 4private var BindingViewHolder<ViewholderHeaderCellBinding>.cell by viewHolderDelegate<Cell>() 5 6private fun ViewGroup.rowViewHolder( 7 recycledViewPool: RecyclerView.RecycledViewPool, 8 scroller: RecyclerViewMultiScroller, 9 onCellClicked: (Cell) -> Unit 10) = viewHolderFrom(ViewholderStandingsRowBinding::inflate).apply { 11 this.scroller = scroller 12 this.cellClicked = onCellClicked 13 binding.recyclerView.apply { 14 itemAnimator = null 15 layoutManager = horizontalLayoutManager() 16 setRecycledViewPool(recycledViewPool) 17 } 18} 19 20private fun BindingViewHolder<ViewholderStandingsRowBinding>.bind(row: Row) { 21 this.row = row 22 refresh() 23} 24 25private fun BindingViewHolder<ViewholderStandingsRowBinding>.refresh(): Unit = binding.recyclerView.run { 26 // Lazy initialize adapter 27 val columnAdapter = adapter as? ListAdapter<Cell, *> 28 ?: rowAdapter(row, cellClicked).also { adapter = it; scroller.add(this) } 29 30 columnAdapter.submitList(row.cells) 31} 32 33private fun rowAdapter( 34 row: Row, 35 onCellClicked: (Cell) -> Unit 36) = listAdapterOf( 37 initialItems = row.cells, 38 viewHolderCreator = { viewGroup, viewType -> 39 when (viewType) { 40 Cell.Stat::class.hashCode() -> viewGroup.viewHolderFrom(ViewholderSpreadsheetCellBinding::inflate) 41 Cell.Text::class.hashCode() -> viewGroup.viewHolderFrom(ViewholderSpreadsheetCellBinding::inflate) 42 Cell.StatHeader::class.hashCode() -> headerViewHolder(viewGroup, onCellClicked) 43 Cell.Image::class.hashCode() -> viewGroup.viewHolderFrom(ViewholderBadgeBinding::inflate) 44 else -> throw IllegalArgumentException("Unknown view type") 45 } 46 }, 47 viewHolderBinder = { holder, item, _ -> 48 when (item) { 49 is Cell.Stat -> holder.typed<ViewholderSpreadsheetCellBinding>().bind(item) 50 is Cell.Text -> holder.typed<ViewholderSpreadsheetCellBinding>().bind(item) 51 is Cell.StatHeader -> holder.typed<ViewholderHeaderCellBinding>().bind(item) 52 is Cell.Image -> holder.typed<ViewholderBadgeBinding>().apply { 53 Picasso.get() 54 .load(item.drawableRes) 55 .into(binding.image) 56 } 57 } 58 59 }, 60 viewTypeFunction = { it::class.hashCode() } 61) 62 63private fun headerViewHolder( 64 viewGroup: ViewGroup, 65 onCellClicked: (Cell) -> Unit 66): BindingViewHolder<ViewholderHeaderCellBinding> { 67 val viewHolder = viewGroup.viewHolderFrom(ViewholderHeaderCellBinding::inflate) 68 viewHolder.itemView.setOnClickListener { 69 val cell = viewHolder.cell 70 if (cell.inHeader) onCellClicked(cell) 71 } 72 return viewHolder 73} 74 75private fun BindingViewHolder<ViewholderHeaderCellBinding>.bind(cell: Cell.StatHeader) { 76 this.cell = cell 77 val isSelectedHeader = when (cell.type) { 78 cell.selectedType -> cell.ascending 79 else -> null 80 } 81 82 binding.cell.text = cell.content 83 binding.up.visibility = if (isSelectedHeader == true) View.VISIBLE else View.INVISIBLE 84 binding.down.visibility = if (isSelectedHeader == false) View.VISIBLE else View.INVISIBLE 85} 86 87private fun BindingViewHolder<ViewholderSpreadsheetCellBinding>.bind(item: Cell) { 88 binding.cell.text = item.content 89 binding.cell.textAlignment = item.textAlignment 90}

Definition for a vertical RecyclerView of multi scrolling horizontal RecyclerViews, i.e a table

With the above, we can easily render:

RecyclerView table examples

The second gif uses some trickery to achieve its result, there are actually 3 Top level RecyclerView instances in the View:

  1. The main vertical RecyclerView

  2. The Sticky Header horizontal RecyclerView

  3. The Sticky overlapping side bar vertical RecyclerView

The inter dependencies are even more fun:

  • The main RecyclerView and sticky header RecyclerView share the same horizontal RecyclerViewMultiScroller. The sticky header only appears when the main list has been scrolled vertically.

  • The main RecyclerView and overlapping side bar RecyclerView share the same vertical RecyclerViewMultiScroller. The sticky bar only appears when the main list has been scrolled horizontally.

  • The header observes the exact same list as the main list, but only takes the first row

  • The Sticky bar observes the exact same list as the main list, but only takes the first 2 items for each row

Closing

The RecyclerView is extremely versatile at its core; its almost anything you want it to be, you just have to cajole it a bit and add some helpers to its verbose API. So go ahead, do something a little unorthodox, but super cool with a RecyclerView.

As always, samples for the APIs used above can be found here:

To see all of the above in one compelling UI package, give the latest Team Manager app a spin, note you may not get to see the A/B test differences depending on the status of the feature tag: