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 mins

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.

sealed class ScoreBoard { data class Basic( val state: LiveVideoScore? ) : ScoreBoard() data class AdvancedBats( val awayTeamName: String, val homeTeamName: String, val awayScore: String, val homeScore: String, val inningNumber: String, val inningPart: InningPart, val ballNumber: String, val strikeNumber: String, val outs: Int, val runners: Set<Base> ) : ScoreBoard() data class AdvancedHoops( val ownScore: String, val oppScore: String, val ownName: String, val oppName: String, val period: Int? ) : ScoreBoard() val hasScores: Boolean get() { return when (this) { is Basic -> state?.hasScores ?: false else -> true } } }

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:

override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) val binding = FragmentHoopsTeamBinding.bind(view) val rosterAdapter = rosterAdapter() binding.list.apply { layoutManager = if (FeatureTag.newHoopsSubstitutionUI.isOn) { gridLayoutManager(spanCount = RosterItem.maxSpanSize) { index -> viewModel.items.value?.getOrNull(index)?.spanSize ?: RosterItem.maxSpanSize } } else LinearLayoutManager(view.context) adapter = rosterAdapter itemAnimator = null } viewModel.items.observe(viewLifecycleOwner, rosterAdapter::submitList) } private fun rosterAdapter() = listAdapterOf( initialItems = viewModel.items.value ?: listOf(), viewHolderCreator = { viewGroup, viewType -> when (viewType) { RosterItem.CourtHeader::class.hashCode(), RosterItem.BenchHeader::class.hashCode() -> viewGroup.viewHolderFrom(ViewholderHeaderBinding::inflate).apply { if (FeatureTag.newHoopsSubstitutionUI.isOn) binding.root.setBackgroundColor(Color.WHITE) } RosterItem.CourtPlayer::class.hashCode() -> if (FeatureTag.newHoopsSubstitutionUI.isOn) { viewGroup.viewHolderFrom(ViewholderHoopsCourtPlayerBinding::inflate) } else { viewGroup.viewHolderFrom(ViewholderHoopsRosterEntryBinding::inflate).apply { binding.button.setOnClickListener { toggleActiveStatus(binding.player, binding.isActive) } } } ... else -> throw IllegalArgumentException("Unknown viewType $viewType in scoring table viewHolderCreator") } }, viewHolderBinder = { holder, item, _ -> when { ... holder.binding is ViewholderHoopsRosterEntryBinding -> when (item) { // TODO remove when newHoopsSubstitutionUI is removed, along with XML is RosterItem.Player -> holder.binding<ViewholderHoopsRosterEntryBinding>().apply { name.text = item.player.fullNameOrNumber number.text = item.number avatar.config = item.player.avatar ... } else -> Unit } holder.binding is ViewholderHoopsCourtPlayerBinding -> when (item) { is RosterItem.CourtPlayer -> holder.typed<ViewholderHoopsCourtPlayerBinding>().apply { binding.name.text = item.player.fullNameOrNumber binding.number.text = item.number binding.avatar.config = item.player.avatar ... } else -> throw IllegalArgumentException("Invalid binding") } } }, viewTypeFunction = { it::class.hashCode() } )

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.

/** * A class that synchronizes scrolling multiple [RecyclerView]s, useful for creating tables. * * It is optimized around usage with a [LinearLayoutManager]. The [CellSizer] provides information * on how large a cell is in any row to keep all [RecyclerView] instances synchronized */ class RecyclerViewMultiScroller( @RecyclerView.Orientation private val orientation: Int = RecyclerView.HORIZONTAL, private val cellSizer: CellSizer = DynamicCellSizer(orientation) ) { var displacement = 0 private set private var active: RecyclerView? = null private val syncedScrollers = mutableSetOf<RecyclerView>() private val displacementListeners = mutableListOf<(Int) -> Unit>() private val onScrollListener = object : RecyclerView.OnScrollListener() { override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { if (orientation == RecyclerView.HORIZONTAL && dx == 0) return if (orientation == RecyclerView.VERTICAL && dy == 0) return if (active != null && recyclerView != active) return active = recyclerView syncedScrollers.forEach { if (it != recyclerView) it.scrollBy(dx, dy) } displacement += if (orientation == RecyclerView.HORIZONTAL) dx else dy displacementListeners.forEach { it(displacement) } } override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { if (active != recyclerView) return displacementListeners.forEach { it(displacement) } if (newState == RecyclerView.SCROLL_STATE_IDLE) active = null } } private val onAttachStateChangeListener = object : View.OnAttachStateChangeListener { override fun onViewAttachedToWindow(v: View?) { if (v is RecyclerView) include(v) } override fun onViewDetachedFromWindow(v: View?) { if (v is RecyclerView) exclude(v) } } private val onItemTouchListener = object : RecyclerView.SimpleOnItemTouchListener() { override fun onInterceptTouchEvent(rv: RecyclerView, e: MotionEvent): Boolean { // If the user flung the list, then touches any other synced list, stop scrolling if (e.actionMasked == MotionEvent.ACTION_DOWN && active != null) active?.stopScroll() return when(active) { null -> false // return false if active is null, we aren't trying to override default scrolling else -> rv != active // Ignore touches on other RVs while scrolling is occurring } } } fun clear() = stub() fun add(recyclerView: RecyclerView) = stub() fun remove(recyclerView: RecyclerView) = stub() }

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.

private var BindingViewHolder<ViewholderStandingsRowBinding>.scroller by viewHolderDelegate<RecyclerViewMultiScroller>() private var BindingViewHolder<ViewholderStandingsRowBinding>.cellClicked by viewHolderDelegate<(Cell) -> Unit>() private var BindingViewHolder<ViewholderStandingsRowBinding>.row by viewHolderDelegate<Row>() private var BindingViewHolder<ViewholderHeaderCellBinding>.cell by viewHolderDelegate<Cell>() private fun ViewGroup.rowViewHolder( recycledViewPool: RecyclerView.RecycledViewPool, scroller: RecyclerViewMultiScroller, onCellClicked: (Cell) -> Unit ) = viewHolderFrom(ViewholderStandingsRowBinding::inflate).apply { this.scroller = scroller this.cellClicked = onCellClicked binding.recyclerView.apply { itemAnimator = null layoutManager = horizontalLayoutManager() setRecycledViewPool(recycledViewPool) } } private fun BindingViewHolder<ViewholderStandingsRowBinding>.bind(row: Row) { this.row = row refresh() } private fun BindingViewHolder<ViewholderStandingsRowBinding>.refresh(): Unit = binding.recyclerView.run { // Lazy initialize adapter val columnAdapter = adapter as? ListAdapter<Cell, *> ?: rowAdapter(row, cellClicked).also { adapter = it; scroller.add(this) } columnAdapter.submitList(row.cells) } private fun rowAdapter( row: Row, onCellClicked: (Cell) -> Unit ) = listAdapterOf( initialItems = row.cells, viewHolderCreator = { viewGroup, viewType -> when (viewType) { Cell.Stat::class.hashCode() -> viewGroup.viewHolderFrom(ViewholderSpreadsheetCellBinding::inflate) Cell.Text::class.hashCode() -> viewGroup.viewHolderFrom(ViewholderSpreadsheetCellBinding::inflate) Cell.StatHeader::class.hashCode() -> headerViewHolder(viewGroup, onCellClicked) Cell.Image::class.hashCode() -> viewGroup.viewHolderFrom(ViewholderBadgeBinding::inflate) else -> throw IllegalArgumentException("Unknown view type") } }, viewHolderBinder = { holder, item, _ -> when (item) { is Cell.Stat -> holder.typed<ViewholderSpreadsheetCellBinding>().bind(item) is Cell.Text -> holder.typed<ViewholderSpreadsheetCellBinding>().bind(item) is Cell.StatHeader -> holder.typed<ViewholderHeaderCellBinding>().bind(item) is Cell.Image -> holder.typed<ViewholderBadgeBinding>().apply { Picasso.get() .load(item.drawableRes) .into(binding.image) } } }, viewTypeFunction = { it::class.hashCode() } ) private fun headerViewHolder( viewGroup: ViewGroup, onCellClicked: (Cell) -> Unit ): BindingViewHolder<ViewholderHeaderCellBinding> { val viewHolder = viewGroup.viewHolderFrom(ViewholderHeaderCellBinding::inflate) viewHolder.itemView.setOnClickListener { val cell = viewHolder.cell if (cell.inHeader) onCellClicked(cell) } return viewHolder } private fun BindingViewHolder<ViewholderHeaderCellBinding>.bind(cell: Cell.StatHeader) { this.cell = cell val isSelectedHeader = when (cell.type) { cell.selectedType -> cell.ascending else -> null } binding.cell.text = cell.content binding.up.visibility = if (isSelectedHeader == true) View.VISIBLE else View.INVISIBLE binding.down.visibility = if (isSelectedHeader == false) View.VISIBLE else View.INVISIBLE } private fun BindingViewHolder<ViewholderSpreadsheetCellBinding>.bind(item: Cell) { binding.cell.text = item.content binding.cell.textAlignment = item.textAlignment }

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:

GameChanger Team Manager

,