RecyclerView ConcatAdapter
Apr 11, 2021
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:
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:
getBindingAdapterPosition returns the position relative to the Adapter which bound that item.
getAbsoluteAdapterPosition returns the position relative to the whole RecyclerView.
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 )
See full source code at https://github.com/jshvarts/ConcatAdapterBasicDemo