MergeAdapter/ConcatAdapter the first glance
The MergeAdapter has renamed to ConcatAdapter from1.2.0-alpha04. https://developer.android.com/jetpack/androidx/releases/recyclerview#recyclerview-1.2.0-alpha04
For Android developer RecyclerView
probably is the most important UI class we should know and master because it’s useful and suitable for most of the use cases.
RecyclerView
is powerful but still quite basic so lot’s of extended tools like DiffUtil, ListAdapter, SortedList, etc., are built to help us easily achieve the complex business logic for different scenario.
It never ends. If you want to support sectionalized data or add header/footer we still have to add some additional logic inside the Adapter
. Here are some strategy I often use (and probably you’re familiar as well):
- Keep the original data and do adjustment when we use it in
getItemCount
,onBindViewHolder
, etc. - Modify the original data every time we receive the update.
Either way is not complex but easy to mess up and didn’t scale well, we need to change the logic inside the adapter every time we want to add more sections or header/footer. It’d be better if we have a high level solution instead of manipulate the offset by ourself.
That’s why MergeAdapter
is here to the rescue.
MergeAdapter
As the name imply, MergeAdapter
is a tool we can use to combine different Adapter
together to separate logic and reuse in Adapter
level. It’s so simple that you can easily know how to use it by just few lines of code here.
val adapterA: AdapterA = …
val adapterB: AdapterB = …val mergeAdapter = MergeAdapter(adapterA, adapterB)
recyclerView.adapter = mergeAdapterval adapterC: AdapterC = …
mergeAdapter.addAdapter(adapterC)
Yes, that’s it.
MergeAdapter
will handle rest of the things for us and we can dynamically add or remove anAdapter
or change the data in each Adapter
without breaking anything.
If you’re not satisfying let’s keep digging more.
How it works
Let’s spend a few minutes to think about if we have bunch of Adapter
we want to merge together like MergeAdapter
do, how can we do it?
First, we need to make MergeAdapter
extends fromAdapter
as it’s still an Adapter
from outside. Here is the minimal functions we need to create an Adapter
.
class ImagineAdapter: RecyclerView.Adapter() { override fun getItemCount(): Int override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int): RecyclerView.ViewHolder override fun onBindViewHolder(
holder: RecyclerView.ViewHolder,
position: Int) fun addAdapter(adapter: RecyclerView.Adapter) fun removeAdapter(adapter: RecyclerView.Adapter)}
Here are some missing part we need to fulfill:
- We need to know the total number of items in each
Adapter
. - Each
ViewHolder
will be created by its correspondingViewType
. - We’ll need to bind the correct pair of data and
ViewHolder
inonBindViewHolder
.
Wrapper
The pattern we use here is called Wrapper — or if you preferred Decorator pattern is often used to solve problem like ours, we create a decorator class implement the original interface(Adapter
) with new functionality (Add/Remove child Adapter
) and wrap the original class(actually a list in our case) and then we can use the decorator class directly without changing any of the existing interfaces.
The slight difference is we wrap a bunch of Adapter
instead so we can’t just bypass the parameter/result we have, we need to calculate the correct value internally like something below:
// ImagineAdapterval list: List<RecyclerView.Adapter<RecyclerView.ViewHolder>>
override fun getItemCount(): Int {
return list.sumBy { it.itemCount }
}override fun onBindViewHolder(
holder: RecyclerView.ViewHolder,
position: Int
) {
var index = 0
var localPosition = position
while (localPosition >= list[index].itemCount) {
localPosition -= list[index++].itemCount
}
list[index].onBindViewHolder(holder, localPosition)
}
The getItemCount
is easy since we can sum all the result from child getItemCount
, but we need things like onBindViewHolder
to calculate the correct offset first before pass it to the child Adapter
. For example we have three adapters inside MergeAdapter
and each has 10 items, the getItemCount
will be 30, and if we get a holder and position is 27 in onBindViewHolder
call, we need to call the 3rd adapter with the same holder and position 7 instead.
This is indeed something we need to be careful about in every functions. But it’ll become stable once we finish, as the pattern is quite the same and no need to change. You can extract the same calculation logic to another function and reuse it every time as well.
ViewType
Even though the calculation is tedious along with small overhead, it’s not a big issue for now. The actual first problem we’ll have is that we don’t know the relationship of the ViewType
in each Adapter
and the MergeAdapter
. Every child Adapter
may have the same ViewType
but relate to different ViewHolder
because each Adapter
shouldn’t know if other Adapter
is already use the same ViewType
or not.
It’s an interesting situation and we can have several options for this. If you build this only for yourself, you can add some restrictions on ViewType
to make things easier. One of the solutions might be to force each ViewType
unique so you can use it directly, or ViewType
should bound by some static number so you can allocate distinct partitions for each Adapter
easily.
The solution Google provides is also quite open, we can determine if we have the global unique ViewType
or we need to isolate the ViewType
in each Adapter
by setting Config
at the MergeAdapter
constructor.
val config = MergeAdapter.Config.Builder()
.setIsolateViewTypes(false)
.build()
val adapter = MergeAdapter(config)
By setting isolateViewTypes
to false we indicate we’ll have the global unique ViewType
which also gain a bit of performance as we earlier mentioned there’s no mapping needed. Otherwise we need an additional map to keep track of ViewType
mapping from adapter to adapter in whatever logic.
p.s. You can find ViewTypeStorage to see how MergeAdapter deal with the ViewType issue we mentioned above.
Notify
The second problem we have is how can we notify RecyclerView
to update if the data is changed.
In order to solve this we need to understand how the notify the mechanism work first. Let’s go to RecyclerView
class to find out the related code.
//RecyclerViewprivate final RecyclerViewDataObserver mObserver = new RecyclerViewDataObserver();public void setAdapter(@Nullable Adapter adapter) {
setAdapterInternal(adapter, false, true);
}private void setAdapterInternal(@Nullable Adapter adapter,
boolean compatibleWithPrevious,
boolean removeAndRecycleViews) {
if (mAdapter != null) {
mAdapter.unregisterAdapterDataObserver(mObserver);
}
mAdapter = adapter;
if (adapter != null) {
adapter.registerAdapterDataObserver(mObserver);
}
}
We found RecyclerView
has a RecyclerViewDataObserver
which will pass to Adapter
to register events when we called setAdapter
. And RecyclerViewDataObserver
is a subclass of AdapterDataObserver
which looks like what we need.
public abstract static class AdapterDataObserver {
public void onChanged()
public void onItemRangeChanged(int positionStart, int itemCount)
public void onItemRangeChanged(int positionStart, int itemCount, @Nullable Object payload)
public void onItemRangeInserted(int positionStart, int itemCount)
public void onItemRangeRemoved(int positionStart, int itemCount)
public void onItemRangeMoved(int fromPosition, int toPosition, int itemCount)
public void onStateRestorationPolicyChanged()
}
Let’s go back to Adapter
to see how observe works.
// RecyclerView.Adapterpublic void registerAdapterDataObserver(@NonNull AdapterDataObserver observer) {
mObservable.registerObserver(observer);
}public final void notifyDataSetChanged() {
mObservable.notifyChanged();
}public final void notifyItemChanged(int position) {
mObservable.notifyItemRangeChanged(position, 1);
}public final void notifyItemInserted(int position) {
mObservable.notifyItemRangeInserted(position, 1);
}public final void notifyItemMoved(int fromPosition, int toPosition) {
mObservable.notifyItemMoved(fromPosition, toPosition);
}public final void notifyItemRemoved(int position) {
mObservable.notifyItemRangeRemoved(position, 1);
}
In registerAdapterDataObserver
we attach the observer
to mObservable
and when we call any notify function like notifyItemRemoved
, mObservable
will be triggered and then observer
will be notified about the change we made.
So the original notify function in each child Adapter
will not work since there’s no setAdapter
occurred when we have a MergeAdapter
on top, but we can link them gracefully by calling registerAdapterDataObserver
in addAdapter
to bind the observer from childAdapter to MergeAdapter
. Just remember before we pop the event to RecyclerView
, we need to adjust the offset like we always do.
p.s. Check NestedAdapterWrapper.Callback on how MergeAdapter link the notify chain we talked here.
There are things we didn’t covered like setHasStableIds
or other properties in this article, but overall you may apply the some strategy again and again. The sample code may differ from the actual source code in MergeAdapter
but the concept will remain the same and it’s also my intention to not use the actual source code to highlight the thing we talk. Another reason is that it’s still in alpha so every thing might change but most important thing is that I wish you can have your own joy when reading the source.
Thanks for reading, and if you like this article please give me claps or share. Also don’t hesitate to leave comments if you have any feedback.