Challenges of composing RecyclerView with ConcatAdapter in a Grid

Most of the mobile apps are just nested lists. And in my time, as an Android dev, I’ve managed to make quite a few of those.

However, what surprised me is how much of a challenge it may be when you are trying to push list items to more non-conventional ways. Especially, when those ways include items, that are displayed using a grid.

Photo by Miti on Unsplash

Managed to stumble upon this when working on a project. Had to do quite a bit of research, hopefully this’ll come in handy.

TLDR;

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

Oh, and almost forgot, uses the Page3 (link) mechanism and adapters for infinite loading mechanism. There’s that as well 👍

Comes in with code snippets and overviews of what did work and what didn’t.

If you are working on combining different item types in lists, maybe that’ll come in handy.

I know it would have come in handy for me :))

Aaaand the blog post cannot be complete without a working sample 👉 https://github.com/marius-m/android-concat-grid

And the end result

THE Challenge

First things first. What we are aiming to achieve.

So here are my challenges

  • 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

Also, it is only a start of development on the project, which means these caveats will change dramatically, as well as the types of items we have on the list. So we have to make the list being flexible enough.

Now, you may say, I’ve seen this before, ’hold my beer 🍺’. Before I WILL hold your beer, here’s what I’ve tried first.

And on the same note, I’m not sure you’re getting back that beer either case 🤷‍.

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

I’m having doubts this would scale well when adding even more item types.

Using NestedScrollView and multiple RecyclerView’s

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

When using RecyclerView inside NestedScrollView, it breaks the ’recycling’ part, and items displayed in RecyclerView will be inflated instantly. This breaks the whole point of using RecyclerView in the first place for memory recycling and displaying only what the user can see.

So this approach is definitely out of the question.

There were other approaches that I don’t even remember, but as we’re in this section, you can imagine it did not work well.

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.

The coolest part is, that you assemble various adapters, so it’s flexible enough to expand with more items, types of headers, and so on.

For headers, I’ve created an adapter with a single item. I know, that by design the there will be multiple types of headers, for the same/similar amount of content in the list.

For horizontal scroll items (yeah, we have those as well) — adapter with a single item, that wraps another RecyclerView + RecyclerView.Adapter inside.

This approach especially works well with different types of adapters. For ex., my main items are using PagingDataAdapter (streamable content), but others are using RecyclerView.Adapter.

Now that we have a display mechanism, we need to figure out how to display items in pairs.

The last part is to use a GridLayoutManager. And this was the biggest challenge. More on that down below, but first, let’s look over a few ’why not..’s

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.

This might not be an issue if the list would have a limited amount of data. In other words, I don’t actually know what is the ’next’ item when trying to display it.

Or I would have to go into the internals of the PagingDataAdapter to figure out how it works 🤷‍

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
}

At first, this seemed like a really good idea. But (of course 🤦‍) there were caveats as well.

When item sizes differ a bit, the gap space that is created, items ’mixes’ places. So instead of ’AA BB CC’, you may get ’AA CC BB’. Which may or may not be a problem. But it is when you’re creating items with specific paddings.

A way to ’fix’ this, is to disable the item mixing by disabling GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS (link). This works, however, it may lead to whole empty space in items. So keep in mind, when using this.

Alright. Now let’s jump to the mechanism I’m using right now. At least for now, that is 🏃.

Set-up GridLayoutManager + ConcatAdapter spans

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

Now we need to create a mechanism, that would define which items need full row and which should display in pairs. To do this, we will need to bind an item listener layoutManager.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() on GridLayoutManager.

So, to provide a different type of span size, we’ll need to figure out what type of item we’re looking at. Lets do that by using ConcatAdapter.getItemViewType(position).

But this may come as a surprise a bit, by default ConcatAdapter will generate its own .getItemViewType(position: Int): Int.

And that makes sense because all the adapters inside ConcatAdapter have their own item types. And if they don’t - when you don’t define getItemViewType(position: Int): Int, it will always be 0.

This really is an issue, because the item types are defined, based on how you assembled your adapters when creating ConcatAdapter. If something changes, it probably will break the whole mechanism.

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.

The trick is actually quite simple. We can define, that ConcatAdapter would provide ’original’ item types ids when using ConcatAdapter.getItemViewType(). To do this, we need to provide config with additional properties like this

val concatAdapterConfig = ConcatAdapter.Config.Builder()
.setIsolateViewTypes(false)
.build()
concatAdapter = ConcatAdapter(
concatAdapterConfig,
adapterHeader1,
adapterHorizontalItems1,
adapterPagingItems
)

By using ConcatAdapter.Config.isolateViewTypes (link) we make sure to return original ids. But we have to be sure, to return unique item type ids for each adapter. From docs 👇

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.

Now that ConcatAdapter is returning original item types, let’s prepare adapters, so it would always return original ids and could be identified.

The whole idea is actually quite simple.

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

For ex.:

  • 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…

First, let’s create an interface, that when the adapter implements it, it’ll say that it ’supports’ this unique item type calculation.

interface ConcatenableAdapter {
val concatAdapterIndex: Int

companion object {
private const val VIEW_ITEM_TYPE_MULTIPLIER = 100
}
}

Now we need a method, that would generate an item type id for ConcatAdapter from the original adapter. Also a method to resolve the original type (other way around).

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

One helper method, that helps to figure out by item type id, to which adapter it belongs to. In other words, ConcatAdapter has many adapters, so when we get item type id, we know it belongs to adapter2.

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
}

And last, but not least. I would love for the adapter itself to say what span size it should provide us.

fun spanSizeByType(globalItemViewType: Int): Int = 1

One thing to note, however. It has a bit of weird mapping (for me at least). For example, my grid takes 2 spaces max, so 1 would mean it should take 1 out of 2, and 2 would mean it takes the whole row. It’s even confusing to explain 😵.

That’s it! Here’s what a complete interface looks like

/**
* 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

Nothing fancy, when you have only one adapter item type

override fun getItemViewType(position: Int): Int {
return globalViewItemType()
}

But, when there’s more item types, it gets more interesting 🤓

override fun getItemViewType(position: Int): Int {
val itemViewType = AdapterViewItemTypeEnum
.toItemType(productItem = items[position])
.itemTypeValue
return globalViewItemType(itemViewType)
}

Of course, if we’re trying to create ViewHolder inside adapter, we’ll have to convert it back to local type

override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): ProductGridViewHolder {
val localViewType = resolveGlobalViewItemType(viewType)
return when (AdapterViewItemTypeEnum.fromItemTypeValue(localViewType)) {
<...>
}
}

And let’s provide span size, based on its item type. Because in my case ’default’ value is always to provide items in grid pairs, we don’t define anything. But for adapters with a single item that should take a whole row (for ex. custom headers), this may look like this (gridSpanSize=2).

override fun spanSizeByType(globalItemViewType: Int): Int {
return gridSpanSize
}

Child adapters are ready, let’s jump to finishing up GridLayoutManager 👇

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

And provide span sizes directly from child adapters based on global item view type

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.

In any case, this was a good learning material and a cool journey that I wanted to share. Maybe even someone may get something out of this.

Oh, and of course, template project to try it out (same link as above) 👉 https://github.com/marius-m/android-concat-grid.

--

--

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