Delegate Adapter - why and how

In the article I want to show how the DelegateAdapter pattern solves this problem. Familiar with the pattern may be interesting to see the implementation on Kotlin using the LayoutContainer.
Problem
Let's start with an example. Suppose we have a task to display a ribbon with two types of data - a text with a description and a picture.
public interface IViewModel {}public class TextViewModel implements IViewModel {
@NonNull public final String title;
@NonNull public final String description;
public TextViewModel(@NonNull String title, @NonNull String description) {
this.title = title;
this.description = description;
}
}public class ImageViewModel implements IViewModel {
@NonNull public final String title;
@NonNull public final @DrawableRes int imageRes;
public ImageViewModel(@NonNull String title, @NonNull int imageRes) {
this.title = title;
this.imageRes = imageRes;
}
}
public class BadAdapter extends RecyclerView.Adapter {
private static final int TEXT_VIEW_TYPE = 1;
private static final int IMAGE_VIEW_TYPE = 2;
private List items;
private View.OnClickListener imageClickListener;
public BadAdapter(List items,
View.OnClickListener imageClickListener) {
this.items = items;
this.imageClickListener = imageClickListener;
}
public int getItemViewType(int position) {
IViewModel item = items.get(position);
if (item instanceof TextViewModel) return TEXT_VIEW_TYPE;
if (item instanceof ImageViewModel) return IMAGE_VIEW_TYPE;
throw new IllegalArgumentException(
"Can't find view type for position " + position);
}
@Override
public RecyclerView.ViewHolder onCreateViewHolder(
ViewGroup parent, int viewType) {
RecyclerView.ViewHolder holder;
LayoutInflater inflater = LayoutInflater.from(parent.getContext());
if (viewType == TEXT_VIEW_TYPE) {
holder = new TextViewHolder(
inflater.inflate(R.layout.text_item, parent, false));
} else if (viewType == IMAGE_VIEW_TYPE) {
holder = new ImageViewHolder(
inflater.inflate(R.layout.image_item, parent, false),
imageClickListener);
} else {
throw new IllegalArgumentException(
"Can't create view holder from view type " + viewType);
}
return holder;
}
@Override
public void onBindViewHolder(RecyclerView.ViewHolder holder, int position) {
int viewType = getItemViewType(position);
if (viewType == TEXT_VIEW_TYPE) {
TextViewHolder txtViewHolder = (TextViewHolder) holder;
TextViewModel model = (TextViewModel) items.get(position);
txtViewHolder.tvTitle.setText(model.title);
txtViewHolder.tvDescription.setText(model.description);
} else if (viewType == IMAGE_VIEW_TYPE) {
ImageViewHolder imgViewHolder = (ImageViewHolder) holder;
ImageViewModel model = (ImageViewModel) items.get(position);
imgViewHolder.tvTitle.setText(model.title);
imgViewHolder.imageView.setImageResource(model.imageRes);
} else {
throw new IllegalArgumentException(
"Can't create bind holder fro position " + position);
}
}
@Override
public int getItemCount() {
return items.size();
}
private static class TextViewHolder extends RecyclerView.ViewHolder {
private TextView tvTitle;
private TextView tvDescription;
private TextViewHolder(View parent) {
super(parent);
tvTitle = parent.findViewById(R.id.tv_title);
tvDescription = parent.findViewById(R.id.tv_description);
}
}
private static class ImageViewHolder extends RecyclerView.ViewHolder {
private TextView tvTitle;
private ImageView imageView;
private ImageViewHolder(View parent,
View.OnClickListener listener) {
super(parent);
tvTitle = parent.findViewById(R.id.tv_title);
imageView = parent.findViewById(R.id.img_bg);
imageView.setOnClickListener(listener);
}
}
} The disadvantage of this implementation is in violation of the principles of DRY and SOLID (single responsibility and open closed). To verify this, it’s enough to add two requirements: enter a new data type (checkbox) and another tape where there will be only checkboxes and pictures.
We are faced with the choice - to use the same adapter for the second tape or create a new one? Regardless of the solution that we choose, we will have to change the code (about the same thing, but in different places). You will need to add a new VIEW_TYPE, a new ViewHolder and edit the methods: getItemViewType (), onCreateViewHolder () and onBindViewHolder ().
If we decide to leave one adapter, then the changes will end. But if in the future new data types with new logic will be added only to the second tape, the first will have extra functionality, and it will also need to be tested, although it has not changed.
If we decide to create a new adapter, then there will simply be a ton of duplicate code.
Ready-made solutions
The Delegate Adapter pattern successfully copes with this problem - no need to change already written code, it is easy to reuse existing adapters.
For the first time, I came across a pattern while reading a series of articles by João Ignacio about writing a project in Kotlin. The implementation of Juan, as well as the solution illuminated on the hub - RendererRecyclerViewAdapter - I do not like because the knowledge about ViewType is distributed across all adapters and even further.
object AdapterConstants {
val NEWS = 1
val LOADING = 2
}create a model that implements the ViewType interface:
class SomeModel : ViewType {
override fun getViewType() = AdapterConstants.NEWS
}register DelegateAdapter c with a constant:
delegateAdapters.put(AdapterConstants.NEWS, NewsDelegateAdapter(listener))Thus, the logic with the data type is spread over three classes (constants, model, and the place where the registration takes place). In addition, you need to ensure that you do not accidentally create two constants with the same value, which is very easy to do in a solution with RendererRecyclerViewAdapter:
class SomeModel implements ItemModel {
public static final int TYPE = 0; // вдруг 0 есть у какой-то еще модели?
@NonNull private final String mTitle;
...
@Override public int getType() {
return TYPE;
}
}Both of these approaches are based on the Hans Dorfman AdapterDelegates library , which I like more, although I see a drawback in the need to create an adapter. This part is a “boilerplate”, which could be dispensed with.
Another solution
The code will speak for itself better than words. Let's try to implement the same tape with two data types (text and picture). I’ll write the implementation on Kotlin using the LayoutContainer (I will describe in more detail below).
We write the adapter for the text:
class TxtDelegateAdapter : KDelegateAdapter() {
override fun onBind(item: TextViewModel, viewHolder: KViewHolder) =
with(viewHolder) {
tv_title.text = item.title
tv_description.text = item.description
}
override fun isForViewType(items: List<*>, position: Int) =
items[position] is TextViewModel
override fun getLayoutId(): Int = R.layout.text_item
} adapter for pictures:
class ImageDelegateAdapter(private val clickListener: View.OnClickListener)
: KDelegateAdapter() {
override fun onBind(item: ImageViewModel, viewHolder: KViewHolder) =
with(viewHolder) {
tv_title.text = item.title
img_bg.setOnClickListener(clickListener)
img_bg.setImageResource(item.imageRes)
}
override fun isForViewType(items: List<*>, position: Int) =
items[position] is ImageViewModel
override fun getLayoutId(): Int = R.layout.image_item
} and register the adapters in the place of creation of the main adapter:
val adapter = CompositeDelegateAdapter.Builder()
.add(ImageDelegateAdapter(onImageClick))
.add(TextDelegateAdapter())
.build()
recyclerView.layoutManager = LinearLayoutManager(this)
recyclerView.adapter = adapter
This is all that needs to be done to solve the task. Notice how much less code is compared to the classic implementation. In addition, this approach makes it easy to add new data types and combine DelegateAdapter s with each other.
Let's imagine that there is a requirement to add a new data type (checkbox). What will need to be done?
Create Model:
class CheckViewModel(val title: String, var isChecked: Boolean): IViewModelwrite adapter:
class CheckDelegateAdapter : KDelegateAdapter() {
override fun onBind(item: CheckViewModel, viewHolder: KViewHolder) =
with(viewHolder.check_box) {
text = item.title
isChecked = item.isChecked
setOnCheckedChangeListener { _, isChecked ->
item.isChecked = isChecked
}
}
override fun onRecycled(viewHolder: KViewHolder) {
viewHolder.check_box.setOnCheckedChangeListener(null)
}
override fun isForViewType(items: List<*>, position: Int) =
items[position] is CheckViewModel
override fun getLayoutId(): Int = R.layout.check_item
}
and add a line to the adapter:
val adapter = CompositeDelegateAdapter.Builder()
.add(ImageDelegateAdapter(onImageClick))
.add(TextDelegateAdapter())
.add(CheckDelegateAdapter())
.build()
The new data type in the tape is layout, ViewHolder, and the binding logic. I also like the proposed approach because it is all in the same class. In some projects, ViewHolders and ViewBinders are placed in separate classes, and layout inflating occurs in the main adapter. Imagine the task - you just need to change the font size in one of the data types in the tape. You go to ViewHolder, there you see findViewById (R.id.description). Click on the description, and the Idea offers 35 layouts that have a view with that id. Then you go to the main adapter, then to the ParentAdapter, then to the onCreateViewHolder method, and finally, you need to find the switch you want inside of 40 elements.
In the "problem" section there was a requirement with the creation of another tape. With the delegate adapter, the task becomes trivial - just create a CompositeAdapter and register the necessary types of DelegateAdapter s:
val newAdapter = CompositeDelegateAdapter.Builder()
.add(ImageDelegateAdapter(onImageClick))
.add(CheckDelegateAdapter())
.build()
Those. the adapters are independent of each other and can be easily combined. Another advantage is the convenience of passing handlers (onСlickListener). In the BadAdapter (example above), the handler was passed to the adapter, and the adapter already passed it to the ViewHolder. This increases code connectivity. In the proposed solution, handlers are passed through the constructor only to those classes that need them.
Implementation
For the base implementation (without Kotlin and LayoutContainer), you need 4 classes:
public interface IDelegateAdapter {
@NonNull
RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType);
void onBindViewHolder(@NonNull VH holder,
@NonNull List items,
int position);
void onRecycled(VH holder);
boolean isForViewType(@NonNull List items, int position);
} public class CompositeDelegateAdapter
extends RecyclerView.Adapter {
private static final int FIRST_VIEW_TYPE = 0;
protected final SparseArray typeToAdapterMap;
protected final @NonNull List data = new ArrayList<>();
protected CompositeDelegateAdapter(
@NonNull SparseArray typeToAdapterMap) {
this.typeToAdapterMap = typeToAdapterMap;
}
@Override
public final int getItemViewType(int position) {
for (int i = FIRST_VIEW_TYPE; i < typeToAdapterMap.size(); i++) {
final IDelegateAdapter delegate = typeToAdapterMap.valueAt(i);
//noinspection unchecked
if (delegate.isForViewType(data, position)) {
return typeToAdapterMap.keyAt(i);
}
}
throw new NullPointerException(
"Can not get viewType for position " + position);
}
@Override
public final RecyclerView.ViewHolder onCreateViewHolder(
ViewGroup parent, int viewType) {
return typeToAdapterMap.get(viewType)
.onCreateViewHolder(parent, viewType);
}
@Override
public final void onBindViewHolder(
RecyclerView.ViewHolder holder, int position) {
final IDelegateAdapter delegateAdapter =
typeToAdapterMap.get(getItemViewType(position));
if (delegateAdapter != null) {
//noinspection unchecked
delegateAdapter.onBindViewHolder(holder, data, position);
} else {
throw new NullPointerException(
"can not find adapter for position " + position);
}
}
@Override
public void onViewRecycled(RecyclerView.ViewHolder holder) {
//noinspection unchecked
typeToAdapterMap.get(holder.getItemViewType())
.onRecycled(holder);
}
public void swapData(@NonNull List data) {
this.data.clear();
this.data.addAll(data);
notifyDataSetChanged();
}
@Override
public final int getItemCount() {
return data.size();
}
public static class Builder {
private int count;
private final SparseArray typeToAdapterMap;
public Builder() {
typeToAdapterMap = new SparseArray<>();
}
public Builder add(
@NonNull IDelegateAdapter delegateAdapter) {
typeToAdapterMap.put(count++, delegateAdapter);
return this;
}
public CompositeDelegateAdapter build() {
if (count == 0) {
throw new IllegalArgumentException("Register at least one adapter");
}
return new CompositeDelegateAdapter<>(typeToAdapterMap);
}
}
} As you can see, no magic, just delegate calls to onBind, onCreate, onRecycled (the same as in the implementation of AdapterDelegates by Hans Dorfman).
We will now write the basic ViewHolder and DelegateAdpater to remove a little more “boilerplate”:
public class BaseViewHolder extends RecyclerView.ViewHolder {
private ItemInflateListener listener;
public BaseViewHolder(View parent) {
super(parent);
}
public final void setListener(ItemInflateListener listener) {
this.listener = listener;
}
public final void bind(Object item) {
listener.inflated(item, itemView);
}
interface ItemInflateListener {
void inflated(Object viewType, View view);
}
}public abstract class BaseDelegateAdapter
implements IDelegateAdapter {
abstract protected void onBindViewHolder(
@NonNull View view, @NonNull T item, @NonNull VH viewHolder);
@LayoutRes
abstract protected int getLayoutId();
@NonNull
abstract protected VH createViewHolder(View parent);
@Override
public void onRecycled(VH holder) {
}
@NonNull
@Override
public final RecyclerView.ViewHolder onCreateViewHolder(
@NonNull ViewGroup parent, int viewType) {
final View inflatedView = LayoutInflater
.from(parent.getContext())
.inflate(getLayoutId(), parent, false);
final VH holder = createViewHolder(inflatedView);
holder.setListener(new BaseViewHolder.ItemInflateListener() {
@Override
public void inflated(Object viewType, View view) {
onBindViewHolder(view, (T) viewType, holder);
}
});
return holder;
}
@Override
public final void onBindViewHolder(
@NonNull VH holder,
@NonNull List items,
int position) {
((BaseViewHolder) holder).bind(items.get(position));
}
} Now it will be possible to create adapters, almost as in the example above:
public class TextDelegateAdapter extends
BaseDelegateAdapter {
@Override
protected void onBindViewHolder(@NonNull View view,
@NonNull TextViewModel item,
@NonNull TextViewHolder viewHolder) {
viewHolder.tvTitle.setText(item.title);
viewHolder.tvDescription.setText(item.description);
}
@Override
protected int getLayoutId() {
return R.layout.text_item;
}
@Override
protected TextViewHolder createViewHolder(View parent) {
return new TextViewHolder(parent);
}
@Override
public boolean isForViewType(@NonNull List items, int position) {
return items.get(position) instanceof TextViewModel;
}
final static class TextViewHolder extends BaseViewHolder {
private TextView tvTitle;
private TextView tvDescription;
private TextViewHolder(View parent) {
super(parent);
tvTitle = parent.findViewById(R.id.tv_title);
tvDescription = parent.findViewById(R.id.tv_description);
}
}
} In order for ViewHolders to be created automatically (it will work only on Kotlin), there are 3 things to do:
- Connect plugin for synthetic view links import
apply plugin: 'kotlin-android-extensions' - Allow experimental option for it
androidExtensions { experimental = true } - Implement LayoutContainer interface
By default, links are cached only for Activity and Fragment. More details here .
Now we can write the base class:
abstract class KDelegateAdapter
: BaseDelegateAdapter() {
abstract fun onBind(item: T, viewHolder: KViewHolder)
final override fun onBindViewHolder(view: View, item: T, viewHolder: KViewHolder) {
onBind(item, viewHolder)
}
override fun createViewHolder(parent: View?): KViewHolder {
return KViewHolder(parent)
}
class KViewHolder(override val containerView: View?)
: BaseViewHolder(containerView), LayoutContainer
} disadvantages
- Searching for an adapter when you need to determine viewType takes an average of N / 2, where N is the number of registered adapters. So the solution will work somewhat slower with a large number of adapters.
- There may be a conflict between two adapters subscribing to the same ViewModel.
- Classes are compact only on Kotlin.
Conclusion
This approach has proven itself both for complex lists and for homogeneous ones - writing an adapter turns into literally 10 lines of code, while the architecture allows you to expand and complicate the tape without changing the existing classes.
In case someone needs source codes, I give a link to the project . I will be glad of any feedback.