RecyclerView ConcatAdapter

Apr 11, 2021

IMG_1338.jpg

RecyclerView 1.2.0 released a couple of days ago makes ConcatAdapter finally stable. The class used to called MergeAdapter when it was first introduced in alpha version but was renamed to ConcatAdapter by the time it became stable. The new name better reflects the purpose of this class which is to concatenate different types of items, each managed by their own Adapter, sequentially.

Some useful use cases for ConcatAdapter:

  • List of data with by a header and/or a footer section

  • Group of items followed by other groups of items sequentially (e.g. trending items followed by recent items, etc.)

Why multiple adapters?

Better separation of concerns will result in code that’s easier to maintain as your requirements change. If you’ve been writing Android code for some time, you probably ran into large Adapters that support many different types each with its own ViewHolders all in the same class/file. Eventually, this becomes unmanageable.

Let’s look at an example:

concatadapter.gif

Here we have 2 “featured” items followed by some “regular” items. Styling is the only difference and each type has its own layout file. Let’s look at the code.

Model

sealed class Item(
  open val id: String,
  open val title: String,
  @DrawableRes open val imageResId: Int,
)

data class Featured(
  override val id: String,
  override val title: String,
  @DrawableRes override val imageResId: Int,
  val description: String
) : Item(
  id = id,
  title = title,
  imageResId = imageResId
)

data class Regular(
  override val id: String,
  override val title: String,
  @DrawableRes override val imageResId: Int,
) : Item(
  id = id,
  title = title,
  imageResId = imageResId
)

Mock Data Repository

object DataRepository {
    fun getItems(): List<Item> =
        listOf(
            Featured(
                id = UUID.randomUUID().toString(),
                title = "Item 1 Featured",
                description = "Super exciting new design",
                imageResId = R.drawable.image1
            ),
            Featured(
                id = UUID.randomUUID().toString(),
                title = "Item 2 Featured",
                description = "Featured design is here",
                imageResId = R.drawable.image2
            ),
            Regular(
                id = UUID.randomUUID().toString(),
                title = "Item 3",
                imageResId = R.drawable.image3
            ),
            Regular(
                id = UUID.randomUUID().toString(),
                title = "Item 4",
                imageResId = R.drawable.image4
            ),
            Regular(
                id = UUID.randomUUID().toString(),
                title = "Item 5",
                imageResId = R.drawable.image5
            ),
            Regular(
                id = UUID.randomUUID().toString(),
                title = "Item 6",
                imageResId = R.drawable.image6
            )
        )
}

ViewModel

class MainViewModel : ViewModel() {
    private val _itemList = MutableStateFlow<List<Item>>(emptyList())
    val itemList: StateFlow<List<Item>> = _itemList

    init {
        _itemList.value = DataRepository.getItems()
    }

    fun onItemSelected(id: String) {
        _itemList.value
            .first { it.id == id }.let {
                Timber.d("Item selected $it")
            }
    }
}

RecyclerView Adapter

class ItemAdapter(
    private val listener: (String) -> Unit
) : ListAdapter<Item, ItemViewHolder>(ItemDiffCallback()) {

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ItemViewHolder {
        return when (viewType) {
            R.layout.item_featured -> {
                val binding = ItemFeaturedBinding.inflate(
                    LayoutInflater.from(parent.context),
                    parent,
                    false
                )

                FeaturedViewHolder(binding, listener)
            }
            else -> {
                val binding = ItemRegularBinding.inflate(
                    LayoutInflater.from(parent.context),
                    parent,
                    false
                )

                RegularViewHolder(binding, listener)
            }
        }
    }

    override fun onBindViewHolder(holder: ItemViewHolder, position: Int) {
        holder.bind(getItem(position))
    }

    override fun getItemViewType(position: Int): Int {
        return getItemType(getItem(position))
    }

    @LayoutRes
    private fun getItemType(item: Item): Int {
        return when (item) {
            is Featured -> R.layout.item_featured
            is Regular -> R.layout.item_regular
        }
    }
}

abstract class ItemViewHolder(itemView: View) :
    RecyclerView.ViewHolder(itemView) {

    abstract fun bind(item: Item)
}

class FeaturedViewHolder(
    private val binding: ItemFeaturedBinding,
    private val listener: (String) -> Unit
) : ItemViewHolder(binding.root) {

    lateinit var item: Featured

    init {
        itemView.setOnClickListener {
            listener(item.id)
        }
    }

    override fun bind(item: Item) {
        this.item = item as Featured

        binding.image.load(item.imageResId) {
            crossfade(true)
        }
        binding.title.text = item.title
        binding.description.text = item.description
    }
}

class RegularViewHolder(
    private val binding: ItemRegularBinding,
    private val listener: (String) -> Unit
) : ItemViewHolder(binding.root) {

    lateinit var item: Regular

    init {
        itemView.setOnClickListener {
            listener(item.id)
        }
    }

    override fun bind(item: Item) {
        this.item = item as Regular

        binding.image.load(item.imageResId) {
            crossfade(true)
        }
        binding.title.text = item.title
    }
}

class ItemDiffCallback : DiffUtil.ItemCallback<Item>() {
    override fun areItemsTheSame(oldItem: Item, newItem: Item): Boolean {
        return oldItem.id == newItem.id
    }

    override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean {
        return oldItem == newItem
    }
}

Fragment

class MainFragment : Fragment(R.layout.main_fragment) {

    private val binding by viewBinding(MainFragmentBinding::bind)

    private lateinit var viewModel: MainViewModel

    private val itemAdapter = ItemAdapter(this::onItemSelected)

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        viewModel = ViewModelProvider(this).get(MainViewModel::class.java)

        binding.recyclerView.adapter = itemAdapter

        lifecycleScope.launchWhenResumed {
            viewModel.itemList.collect { itemList ->
                Timber.d("current item list $itemList")
                itemAdapter.submitList(itemList)
            }
        }
    }

    private fun onItemSelected(id: String) {
        viewModel.onItemSelected(id)
    }
}

The Adapter is manageable for now as our UI requirements evolve, so will the Adapter code and eventually it may become really hard to work with. It’s time to split the Adapter into multiple classes.

Introducing ConcatAdapter

Our one ItemAdapter will become two separate Adapters (FeaturedItemAdapter and RegularItemAdapter). And they will both be added used via ConcatAdapter.

Here is a Pull Request introducing this change https://github.com/jshvarts/ConcatAdapterBasicDemo/pull/1

The Fragment will changed as follows:

class MainFragment : Fragment(R.layout.main_fragment) {

    private val binding by viewBinding(MainFragmentBinding::bind)

    private lateinit var viewModel: MainViewModel

    private val featuredItemAdapter = FeaturedItemAdapter(this::onItemSelected)
    private val regularItemAdapter = RegularItemAdapter(this::onItemSelected)

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        viewModel = ViewModelProvider(this).get(MainViewModel::class.java)

        val concatAdapter = ConcatAdapter(
            featuredItemAdapter,
            regularItemAdapter
        )
        binding.recyclerView.adapter = concatAdapter

        lifecycleScope.launchWhenResumed {
            viewModel.itemList.collect { itemList ->
                itemList.filterIsInstance<Featured>().takeIf { it.isNotEmpty() }?.let {
                    Timber.d("featured item list $it")
                    featuredItemAdapter.submitList(it)
                }
            }
        }

        lifecycleScope.launchWhenResumed {
            viewModel.itemList.collect { itemList ->
                itemList.filterIsInstance<Regular>().takeIf { it.isNotEmpty() }?.let {
                    Timber.d("regular item list $it")
                    regularItemAdapter.submitList(it)
                }
            }
        }
    }

    private fun onItemSelected(id: String) {
        viewModel.onItemSelected(id)
    }
}

With ConcatAdapter introduced, ViewHolder.getAdapterPosition is now deprecated and replaced with two new functions:

Configuring Concat Adapter

You can customize config for your Concat Adapter depending on your needs. By default the config is:

 Config DEFAULT = Config(
   isolateViewTypes = stableIdMode = true, 
   stableIdMode = NO_STABLE_IDS
 )

To customize config, use a ConcatAdapter.Config.Builder and pass the resulting config into the constructor of your ConcatAdapter:

val config = ConcatAdapter.Config.Builder()
  .setStableIdMode(ConcatAdapter.Config.StableIdMode.SHARED_STABLE_IDS)
  .setIsolateViewTypes(false)
  .build()

val concatAdapter = ConcatAdapter(
  config,
  featuredItemAdapter,
  regularItemAdapter
)
 
Previous
Previous

Consumable LiveData Wrapper

Next
Next

Retrying Network Requests with Flow