Challenges of composing RecyclerView with ConcatAdapter in a Grid

Photo by Miti on Unsplash

TLDR;

My road in using ConcatAdapter with different types of RecyclerView.Adapter’s and displaying items in pairs (or not) by using GridLayoutManager.

THE Challenge

First things first. What we are aiming to achieve.

  • Display items in a list (simple, huh ?)
  • Some of the items will be in a grid (in pairs)
  • Some items will take the whole row in the grid (1 item in line)
  • Some items will be scrollable horizontally
  • There will be quite a lot of those items (the app is a marketplace with infinite items on the main screen)
Conceptual RecyclerView using grid layout and different item types

Trial and (mostly) error

I’ve tried multiple ways to get this done. Some ways I’ve had to try it twice, just to confirm it would not work, so here’s what I’ve encountered.

One RecyclerView with different item types in one adapter

This seems to work well enough. However, the biggest problem I have around it is having a streaming adapter with items that are added / removed dynamically by using Page3 (which is amazing by the way). I also need to mix in static content items as well (headers, footers).

Using NestedScrollView and multiple RecyclerView’s

This sounded too good to be true if it worked. And it actually is too good.

Trial and (kind-of-mostly) working

Using ConcatAdapter (link) with RecyclerView and GridLayoutManager

This approach almost works, but it still has some trade-offs. But the whole approach looks flexible enough in the future.

Why not LinearLayoutManager (link) + items in pairs?

I have a design, which has items in pairs. So instead of displaying items one by one in the adapter, I’d have to ’pair’ them and display it by two.

Why not StaggeredLayoutManager (link) + LayoutParams.fullSpan() (link)

You can define an item to take a full row by using .fullSpan. You could use this in RecyclerAdapter.onBindViewHolder(viewHolder: ViewHolder, i: Int) or somewhere similar. I even have a snippet, if you would like to try it out 👇

/**
* Spans fully in layouts if used in grid layout manager
*/
fun ViewGroup.LayoutParams?.fullSpanInGridLayout() {
(this as? GridLayoutManager.LayoutParams?)
?.spanSize
}

Set-up GridLayoutManager + ConcatAdapter spans

To set up ConcatAdapter + GridLayoutManager is quite simple. But as always, the devil is in the details.

Child adapter global item type calculation

But of course, I would not be writing this post, if I would not have managed to solve it.

val concatAdapterConfig = ConcatAdapter.Config.Builder()
.setIsolateViewTypes(false)
.build()
concatAdapter = ConcatAdapter(
concatAdapterConfig,
adapterHeader1,
adapterHorizontalItems1,
adapterPagingItems
)
Setting this to false will allow nested adapters to share RecyclerView.ViewHolders but it also means these adapters should not have conflicting view types (RecyclerView.Adapter.getItemViewType(int)) such that two different adapters return the same view type for different RecyclerView.ViewHolders.
  • Each adapter would have an ’index’ where is when its added in ConcatAdapter
  • When an adapter is returning an item type, instead it would return the calculated number * 100. So this creates a unique item type id with 100 spaces for item types.
  • Adapter0 + itemType0 = 100 (generated item type id)
  • Adapter0 + itemType1 = 101
  • Adapter0 + itemType2 = 102
  • Adapter1 + itemType0 = 200
  • Adapter1 + itemType1 = 201
  • Adapter1 + itemType2 = 202
  • Adapter2 + itemType0 = 300…
interface ConcatenableAdapter {
val concatAdapterIndex: Int

companion object {
private const val VIEW_ITEM_TYPE_MULTIPLIER = 100
}
}
fun globalViewItemType(localItemViewType: Int = 0): Int {
return VIEW_ITEM_TYPE_MULTIPLIER * (concatAdapterIndex + 1) + localItemViewType
}

fun resolveGlobalViewItemType(globalItemViewType: Int): Int {
return globalItemViewType - (VIEW_ITEM_TYPE_MULTIPLIER * (concatAdapterIndex + 1))
}
fun hasGlobalViewItemType(globalItemViewType: Int): Boolean {
val minItemIndex = VIEW_ITEM_TYPE_MULTIPLIER * (concatAdapterIndex + 1)
val maxItemIndex = VIEW_ITEM_TYPE_MULTIPLIER * (concatAdapterIndex + 2)
return globalItemViewType >= minItemIndex &&
globalItemViewType < maxItemIndex
}
fun spanSizeByType(globalItemViewType: Int): Int = 1
/**
* Identifies adapter items that it will be used in [ConcatAdapter]
* This class functionality creates adapter itemViewType unique for all adapters
* This is useful when used in [ConcatAdapter] + [ConcatAdapter.setIsolateViewTypes(false)]
* Be sure to provide [globalViewItemType] when identifying an item in child adapter, and
* restore itemViewType back when used internally
*/
interface ConcatenableAdapter {
val concatAdapterIndex: Int

/**
* Returns span size when used in Grid
* Span size is resolved in numbers.
* - Grid spanCount=2
* - When takes 1 column out of 2, spanSize = 1
* - When takes 2 column out of 2, spanSize = 1
* - When takes both columns 2, spanSize = 2
* By default this does not change span size
* @param globalItemViewType global item view type (calculated with [resolveGlobalViewItemType])
* @return span size
*/
fun spanSizeByType(globalItemViewType: Int): Int = 1

/**
* @return true if item type belongs to adapter
*/
fun hasGlobalViewItemType(globalItemViewType: Int): Boolean {
val minItemIndex = VIEW_ITEM_TYPE_MULTIPLIER * (concatAdapterIndex + 1)
val maxItemIndex = VIEW_ITEM_TYPE_MULTIPLIER * (concatAdapterIndex + 2)
return globalItemViewType >= minItemIndex &&
globalItemViewType < maxItemIndex
}

/**
* @return [RecyclerView.Adapter.getItemViewType(position: Int)] when used in
* [ConcatAdapter] to provide a unique item type
*/
fun globalViewItemType(localItemViewType: Int = 0): Int {
return VIEW_ITEM_TYPE_MULTIPLIER * (concatAdapterIndex + 1) + localItemViewType
}

/**
* Returns the original view item type for internal use
* @param globalItemViewType is calculated type with [globalViewItemType]
* @return resolved local itemViewType
*/
fun resolveGlobalViewItemType(globalItemViewType: Int): Int {
return globalItemViewType - (VIEW_ITEM_TYPE_MULTIPLIER * (concatAdapterIndex + 1))
}

companion object {
private const val VIEW_ITEM_TYPE_MULTIPLIER = 100
}
}

Child adapter item type integration

Now, that we a control interface, the integration is quite simple, here are the key points.

  • Adapter should ’implement’ this interface first
class RecyclerAdapterSingleHeader(
private val context: Context,
override val concatAdapterIndex: Int,
private val gridSpanSize: Int,
) : RecyclerView.Adapter<RecyclerView.ViewHolder>(), ConcatenableAdapter
  • Adapter should provide item type by converting its local type to ’global’ type
override fun getItemViewType(position: Int): Int {
return globalViewItemType()
}
override fun getItemViewType(position: Int): Int {
val itemViewType = AdapterViewItemTypeEnum
.toItemType(productItem = items[position])
.itemTypeValue
return globalViewItemType(itemViewType)
}
override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): ProductGridViewHolder {
val localViewType = resolveGlobalViewItemType(viewType)
return when (AdapterViewItemTypeEnum.fromItemTypeValue(localViewType)) {
<...>
}
}
override fun spanSizeByType(globalItemViewType: Int): Int {
return gridSpanSize
}

Set-up GridLayoutManager + ConcatAdapter spans #2

Lets create ConcateAdapter and bind everything together

adapterSingleHeader1 = AdapterSingleHeader(
context = context,
concatAdapterIndex = 0,
gridSpanSize = GRID_SPAN_SIZE,
)
adapterSingleNestedItems1 = AdapterSingleNestItems(
context = context,
concatAdapterIndex = 1,
gridSpanSize = GRID_SPAN_SIZE,
)
adapterPagingItems = PagingItemAdapter(
context = context,
concatAdapterIndex = 2
)
val layoutManager = GridLayoutManager(context, GRID_SPAN_SIZE)
val concatAdapterConfig = ConcatAdapter.Config.Builder()
.setIsolateViewTypes(false)
.build()
concatAdapter = ConcatAdapter(
concatAdapterConfig,
adapterSingleHeader1,
adapterSingleNestedItems1,
adapterPagingItems
)
binding.productGridRecycler.layoutManager = layoutManager
binding.productGridRecycler.adapter = concatAdapter
layoutManager.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
override fun getSpanSize(position: Int): Int {
val globalItemViewType = concatAdapter.getItemViewType(position)
val spanSize: Int = concatAdapter
.adapters
.filterIsInstance<ConcatenableAdapter>()
.first { it.hasGlobalViewItemType(globalItemViewType) }
.spanSizeByType(globalItemViewType)
return spanSize
}
}

Conclusion

For now, this approach seems to be working fine. Though because it is ’fresh’, not sure I won’t stumble on something else.

--

--

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store