Back to Home

Delegate Adapter - why and how

android kotlin adapter recyclerview

Delegate Adapter - why and how

    In almost all the projects that I was involved in, I had to display a list of elements (ribbon), and these elements were of different types. Often the task was solved inside the main adapter, determining the type of the element through instanceOf in getItemViewType (). When in a tape of type 2 or 3, it seems that this approach justifies itself ... Or not? What if tomorrow comes the demand to introduce a few more types and even according to some intricate logic?



    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.

    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 {
        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.

    Detailed explanation
    In the decision of Joan, the ViewType needs to be registered:

    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): IViewModel

    write 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:

    interface DelegateAdapter
    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);
    }


    Main adapter
    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”:

    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
         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:

    TextDelegateAdapter example
    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:

    1. Connect plugin for synthetic view links import

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

          androidExtensions {
              experimental = true
          }
    3. 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


    1. 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.
    2. There may be a conflict between two adapters subscribing to the same ViewModel.
    3. 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.

    Read Next