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
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:
-
Dynamic Content
-
A/B testing
-
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:
-
The GameStream
-
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
:
-
The main vertical
RecyclerView
-
The Sticky Header horizontal
RecyclerView
-
The Sticky overlapping side bar vertical
RecyclerView
The inter dependencies are even more fun:
-
The main
RecyclerView
and sticky headerRecyclerView
share the same horizontalRecyclerViewMultiScroller
. The sticky header only appears when the main list has been scrolled vertically. -
The main
RecyclerView
and overlapping side barRecyclerView
share the same verticalRecyclerViewMultiScroller
. 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:
96