📜 ⬆️ ⬇️

Delegate Adapter - why and how

Practically in all the projects I was involved in, I had to display a list of elements (tape), and these elements were of different types. Often the problem was solved inside the main adapter, defining the type of the item via instanceOf in getItemViewType (). When in a tape of 2 or 3 types, it seems that such an approach justifies itself ... Or not? What if tomorrow comes the requirement to introduce a few more types and even for some intricate logic?



In the article I want to show how the DelegateAdapter pattern allows to solve this problem. Familiar with the pattern, it 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 tape with two data types - a text with a description and a picture.
')
Create models for types.
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; } } 


A typical adapter would look something like this.
 public class BadAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> { private static final int TEXT_VIEW_TYPE = 1; private static final int IMAGE_VIEW_TYPE = 2; private List<IViewModel> items; private View.OnClickListener imageClickListener; public BadAdapter(List<IViewModel> 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 minus of such implementation is in violation of the principles of DRY and SOLID (single responsibility and open closed). To verify this, it is enough to add two requirements: enter a new data type (checkbox) and another tape, where only checkboxes and pictures will be.

We are faced with the choice of using the same adapter for the second tape or creating a new one? Regardless of the decision we choose, we will have to change the code (about the same, 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 there. But if in the future new data types with new logic will be added only to the second tape, the first one 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 lot of duplicate code.

Turnkey solutions


The Delegate Adapter pattern successfully copes with this problem - there is no need to change the code already written, it is easy to reuse the existing adapters.

I first encountered the pattern while reading a series of articles by JoĂŁo Ignacio about writing a project on Kotlin. Realization of JoĂŁo, as well as the solution illuminated on the habre - RendererRecyclerViewAdapter , - I do not like the fact that the knowledge about ViewType is distributed across all adapters and even further.

Detailed explanation
In the decision of JoĂŁo you need to index the ViewType:

 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 with a constant:

 delegateAdapters.put(AdapterConstants.NEWS, NewsDelegateAdapter(listener)) 

Thus, the logic with the data type is spread over three classes (constants, model and place where 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 the solution with the RendererRecyclerViewAdapter:

 class SomeModel implements ItemModel { public static final int TYPE = 0; //  0   -  ? @NonNull private final String mTitle; ... @Override public int getType() { return TYPE; } } 


Both approaches described are based on the Hans Dorfman AdapterDelegates library, which I like more, although I see a lack of the need to create an adapter. This part is the "boilerplate", without which it would be possible to do.

Another solution


The code speaks better than words for itself. Let's try to implement the same tape with two data types (text and image). I will write implementation on Kotlin with use of LayoutContainer (I will tell in more detail below).

Writing an adapter for text:

 class TxtDelegateAdapter : KDelegateAdapter<TextViewModel>() { 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 } 

picture adapter:

 class ImageDelegateAdapter(private val clickListener: View.OnClickListener) : KDelegateAdapter<ImageViewModel>() { 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<IViewModel>() .add(ImageDelegateAdapter(onImageClick)) .add(TextDelegateAdapter()) .build() recyclerView.layoutManager = LinearLayoutManager(this) recyclerView.adapter = adapter 

This is all you need to do to solve the problem. Notice how much less code there is compared to the classic implementation. In addition, this approach allows you to easily add new data types and combine DelegateAdapters with each other.

Let's imagine that there is a requirement to add a new data type (checkbox). What needs to be done?

Create Model:

 class CheckViewModel(val title: String, var isChecked: Boolean): IViewModel 

write adapter:

 class CheckDelegateAdapter : KDelegateAdapter<CheckViewModel>() { 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 creation of the adapter:

  val adapter = CompositeDelegateAdapter.Builder<IViewModel>() .add(ImageDelegateAdapter(onImageClick)) .add(TextDelegateAdapter()) .add(CheckDelegateAdapter()) .build() 

A new data type in the ribbon is layout, ViewHolder, and the logic of banding. I like the proposed approach because it is all in the same class. In some projects, ViewHolders and ViewBinders are placed into separate classes, and the layout is inflated in the main adapter. Imagine the task - you just need to change the font size in one of the data types in the ribbon. You go to the 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 necessary switch inside the 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 DelegateAdapters:

 val newAdapter = CompositeDelegateAdapter.Builder<IViewModel>() .add(ImageDelegateAdapter(onImageClick)) .add(CheckDelegateAdapter()) .build() 

Those. adapters are independent of each other and can be easily combined. Another advantage is the convenience of transferring handlers (onClickListener). In BadAdapter (example above), the handler was passed to the adapter, and he already passed it to the ViewHolder. This increases the connectivity of the code. In the proposed solution, handlers are passed through the constructor only to those classes that need them.

Implementation


For the basic implementation (without Kotlin and LayoutContainer), you need 4 classes:

interface DelegateAdapter
 public interface IDelegateAdapter<VH extends RecyclerView.ViewHolder, T> { @NonNull RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType); void onBindViewHolder(@NonNull VH holder, @NonNull List<T> items, int position); void onRecycled(VH holder); boolean isForViewType(@NonNull List<?> items, int position); } 


Main adapter
 public class CompositeDelegateAdapter<T> extends RecyclerView.Adapter<RecyclerView.ViewHolder> { private static final int FIRST_VIEW_TYPE = 0; protected final SparseArray<IDelegateAdapter> typeToAdapterMap; protected final @NonNull List<T> data = new ArrayList<>(); protected CompositeDelegateAdapter( @NonNull SparseArray<IDelegateAdapter> 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<T> data) { this.data.clear(); this.data.addAll(data); notifyDataSetChanged(); } @Override public final int getItemCount() { return data.size(); } public static class Builder<T> { private int count; private final SparseArray<IDelegateAdapter> typeToAdapterMap; public Builder() { typeToAdapterMap = new SparseArray<>(); } public Builder<T> add( @NonNull IDelegateAdapter<?, ? extends T> delegateAdapter) { typeToAdapterMap.put(count++, delegateAdapter); return this; } public CompositeDelegateAdapter<T> build() { if (count == 0) { throw new IllegalArgumentException("Register at least one adapter"); } return new CompositeDelegateAdapter<>(typeToAdapterMap); } } } 


As you can see, no magic, just delegating calls to onBind, onCreate, onRecycled (just like in the implementation of AdapterDelegates of Hans Dorfman).

We now write the base ViewHolder and DelegateAdpater to remove some more “boilerplate”:

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


BaseDelegateAdapter
 public abstract class BaseDelegateAdapter <VH extends BaseViewHolder, T> implements IDelegateAdapter<VH,T> { 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<T> items, int position) { ((BaseViewHolder) holder).bind(items.get(position)); } } 


Now you can create adapters, almost as in the example above:

TextDelegateAdapter example
 public class TextDelegateAdapter extends BaseDelegateAdapter<TextDelegateAdapter.TextViewHolder, TextViewModel> { @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 (will work only on Kotlin), you need to do 3 things:

  1. Connect plugin for synthetic import of View links

     apply plugin: 'kotlin-android-extensions' 
  2. Allow the experimental option for it

      androidExtensions { experimental = true } 
  3. Implement LayoutContainer Interface
    By default, links are cached only for Activity and Fragment. Read more here .

Now we can write the base class:

 abstract class KDelegateAdapter<T> : BaseDelegateAdapter<KDelegateAdapter.KViewHolder, T>() { 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


  1. Looking for an adapter when you need to determine the viewType, on average, N / 2 is spent, where N is the number of registered adapters. So the solution will work somewhat slower with a large number of adapters.
  2. A conflict between two adapters that subscribe to the same ViewModel is possible.
  3. Classes are compact only on Kotlin.

Conclusion


This approach has worked well for both complex and homogeneous lists — writing the adapter literally turns into 10 lines of code, while the architecture allows the tape to be expanded and complicated without changing the existing classes.

In case someone needs the source code, I give a link to the project . I would appreciate any feedback.

Source: https://habr.com/ru/post/341738/


All Articles