Recycleview with nested expandable lists in header

Viewed 1480

I am designing a relatively complex UI, i have searched stackoverflow and haven't found similar design. There could be many approaches to this, but i would like to ask expert opionions on how to achieve this and i would like to share my approach and make sure i am doing it the right way. My approach is that i have created a recycleview with header and inside header recycleview i am using an expandable recycleview library developed by h6ah4i (taken from github). Please let me know if there's a better approach to this.

The following image preview is a live mockup of final result i would like to get. It's not the actual screen. My question is what is the best way to achieve this, should i use expandable recycleview or expandable listview in recycleview header. I appreciate any answer as approaches or libraries it doesn't have to be similar to my code. Any suggestions are welcomed. I hope this post will also help other people like me in search of similar solution.

RecycleView Adapter

public class RecycleAdapterPlantSearch extends RecyclerView.Adapter<RecyclerView.ViewHolder> {

    private static final int TYPE_HEADER = 0;
    private static final int TYPE_ITEM = 1;
    private List<Plants> plantsList;
    private Context context;
    private OnItemClickListener onItemClickListener;


    public RecycleAdapterPlantSearch(Context context, List<Plants> plantsList, OnItemClickListener onClickListener) {
        this.context = context;
        this.plantsList = plantsList;
        onItemClickListener = onClickListener;
    }

    @Override
    public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
        if (viewType == TYPE_ITEM) {
            // Here Inflating your recyclerview item layout
            View itemView = LayoutInflater.from(parent.getContext()).inflate(R.layout.recycler_view_plant_search_plant_item, parent, false);
            return new ItemViewHolder(itemView, onItemClickListener);
        } else if (viewType == TYPE_HEADER) {
            // Here Inflating your header view
            View itemView = LayoutInflater.from(parent.getContext()).inflate(R.layout.recycler_view_plant_search_header, parent, false);
            return new HeaderViewHolder(itemView, onItemClickListener);
        } else return null;
    }

    @Override
    public void onBindViewHolder(final RecyclerView.ViewHolder holder, int position) {
        /*
        position 0 is for header
        */

        if (holder instanceof HeaderViewHolder) {
            // setheadersdata_flag = true;
            HeaderViewHolder headerViewHolder = (HeaderViewHolder) holder;
            // You have to set your header items values with the help of model class and you can modify as per your needs

            // Setup expandable feature and RecyclerView
            RecyclerViewExpandableItemManager expMgr = new RecyclerViewExpandableItemManager(null);

            SimpleDemoExpandableItemAdapter.OnListItemClickMessageListener clickListener = message -> {
                Toast.makeText(context, message, Toast.LENGTH_SHORT).show();

            };
            List<BadgesVM> badgesVMList = null;

            badgesVMList = new ArrayList() {{
                add(new BadgesVM("447", "Bienenfreundlich", "bienenfreundlich", false));
                add(new BadgesVM("320,322", "Vogelfreundlich", "vogelfreundlich", false));
                add(new BadgesVM("321", "Insektenfreundlich", "insektenfreundlich", false));
                add(new BadgesVM("445", "Ökologisch wertvoll", "oekologisch", false));
                add(new BadgesVM("531", "Schmetterlings freundlich", "schmetterlings", false));
                add(new BadgesVM("530", "Heimische Pflanze'", "heimische Pflanze'", false));
            }};

            // Create wrapped adapter:  MyItemAdapter -> expMgr.createWrappedAdapter -> MyHeaderFooterAdapter
            RecyclerView.Adapter adapter;
            adapter = new SimpleDemoExpandableItemAdapter(context, expMgr,badgesVMList, clickListener);
            adapter = expMgr.createWrappedAdapter(adapter);
            //adapter = new DemoHeaderFooterAdapter(adapter, null);

            headerViewHolder.recyclerViewExpandable.setAdapter(adapter);

            headerViewHolder.recyclerViewExpandable.setLayoutManager(new LinearLayoutManager(context));

            // NOTE: need to disable change animations to ripple effect work properly
            ((SimpleItemAnimator) headerViewHolder.recyclerViewExpandable.getItemAnimator()).setSupportsChangeAnimations(false);

            expMgr.attachRecyclerView(headerViewHolder.recyclerViewExpandable);


        } else if (holder instanceof ItemViewHolder) {

            final ItemViewHolder itemViewHolder = (ItemViewHolder) holder;

            itemViewHolder.plantDescText.setText(plantsList.get(position - 1).getDescription());

            RequestOptions options = new RequestOptions()
                    .centerCrop()
                    .placeholder(R.drawable.background_small);
            String imageUrl = APP_URL.BASE_ROUTE_INTERN + plantsList.get(position - 1).getImages().get(0).getSrcAttr();

            Glide.with(context).load(imageUrl).apply(options).into(itemViewHolder.plantImg);

        }
    }

    @Override
    public int getItemViewType(int position) {
        if (position == 0) {
            return TYPE_HEADER;
        }
        return TYPE_ITEM;
    }

    @Override
    public long getItemId(int position) {
        return position;
    }

    // getItemCount increasing the position to 1. This will be the row of header
    @Override
    public int getItemCount() {
        return plantsList.size() + 1;
    }


    public interface OnItemClickListener {
        void OnItemClickListener(View view, int position);

        void RecycleViewExtraDetails(ChipGroup chipGroup);

        void nestedRecycleViewsSpecialOdd(RecyclerView nestedRecycleView);
    }

    private class HeaderViewHolder extends RecyclerView.ViewHolder implements View.OnClickListener {

        private TextView searchNameTxt, searchFamilyTxt, plantGroupTxt, plantFamilySearchTxt, ecologyFilterTxt,
                frostSearchTxt;
        private ChipGroup chipGroup;
        private Button filterSearchBtn;
        private CardView ecologyCv;
        private CardView detailSearchCv;
        private RecyclerView recyclerViewExpandable;

        public HeaderViewHolder(View headerView, OnItemClickListener onItemClickListener) {
            super(headerView);
            searchNameTxt = headerView.findViewById(R.id.textView_plant_search_header_plant_search);
            searchFamilyTxt = headerView.findViewById(R.id.textView_plant_search_header_plant_search);
            ecologyCv = headerView.findViewById(R.id.cardView_plant_search_header_ecology);
            detailSearchCv = headerView.findViewById(R.id.cardView_plant_search_header_detail_search);
            plantGroupTxt = headerView.findViewById(R.id.textView_plant_search_header_plant_group);
            plantFamilySearchTxt = headerView.findViewById(R.id.textView_plant_search_header_plant_family);
            ecologyFilterTxt = headerView.findViewById(R.id.textView_plant_search_header_ecology_filter);
            frostSearchTxt = headerView.findViewById(R.id.textView_plant_search_header_frost_filter);
            chipGroup = headerView.findViewById(R.id.chip_group_plant_search_header);
            filterSearchBtn = headerView.findViewById(R.id.button_plant_search_header_filter_search);
            recyclerViewExpandable = headerView.findViewById(R.id.expandable_list_view_plant_search);

            searchNameTxt.setOnClickListener(this);
            searchFamilyTxt.setOnClickListener(this);
            ecologyCv.setOnClickListener(this);
            detailSearchCv.setOnClickListener(this);
            plantGroupTxt.setOnClickListener(this);
            plantFamilySearchTxt.setOnClickListener(this);
            ecologyFilterTxt.setOnClickListener(this);
            filterSearchBtn.setOnClickListener(this);
            frostSearchTxt.setOnClickListener(this);
        }

        @Override
        public void onClick(View view) {
            onItemClickListener.OnItemClickListener(view, getAdapterPosition());
            onItemClickListener.RecycleViewExtraDetails(chipGroup);

        }

    }

    public class ItemViewHolder extends RecyclerView.ViewHolder implements View.OnClickListener {

        private Button readMoreBtn;
        private TextView plantDescText;
        private ImageView plantImg;

        public ItemViewHolder(View itemView, OnItemClickListener onItemClickListener) {
            super(itemView);

            plantDescText = itemView.findViewById(R.id.textView_plant_search_plants_item_description_text);
            readMoreBtn = itemView.findViewById(R.id.button_plant_search_plant_item_read_more);
            plantImg = itemView.findViewById(R.id.imageView_plant_search_plants_item_plant_image);

            readMoreBtn.setOnClickListener(this);

        }

        @Override
        public void onClick(View view) {
            onItemClickListener.OnItemClickListener(view, getAdapterPosition() - 1);
        }
    }


}

nested header recycleview

public class SimpleDemoExpandableItemAdapter extends AbstractExpandableItemAdapter<SimpleDemoExpandableItemAdapter.MyGroupViewHolder,
        SimpleDemoExpandableItemAdapter.MyChildViewHolder> implements View.OnClickListener {
    RecyclerViewExpandableItemManager mExpandableItemManager;
    List<MyBaseItem> mItems;
    OnListItemClickMessageListener mOnItemClickListener;
    List<BadgesVM> badgesVMList;
    Context context;

    static class MyBaseItem {
        public final int id;
        public final String text;

        public MyBaseItem(int id, String text) {
            this.id = id;
            this.text = text;
        }
    }

    static abstract class MyBaseViewHolder extends AbstractExpandableItemViewHolder {
        TextView textView;
        Slider frostSlider;
        RecyclerView detailRecycleView;

        public MyBaseViewHolder(View itemView) {
            super(itemView);
            textView = itemView.findViewById(android.R.id.text1);
            frostSlider = itemView.findViewById(R.id.slider_plant_search_expandable);
            detailRecycleView = itemView.findViewById(R.id.recycle_view_plant_search_detail_search);
        }
    }

    static class MyGroupViewHolder extends MyBaseViewHolder {
        public MyGroupViewHolder(View itemView) {
            super(itemView);
        }
    }

    static class MyChildViewHolder extends MyBaseViewHolder {
        public MyChildViewHolder(View itemView) {
            super(itemView);
        }
    }

    public SimpleDemoExpandableItemAdapter(Context context, RecyclerViewExpandableItemManager expMgr, List<BadgesVM> badgesVMList, OnListItemClickMessageListener clickListener) {
        mExpandableItemManager = expMgr;
        mOnItemClickListener = clickListener;
        this.badgesVMList = badgesVMList;
        this.context = context;

        setHasStableIds(true); // this is required for expandable feature.

        mItems = new ArrayList<>();
        mItems.add(new MyBaseItem(0, "Filter nach ökologischen Kriterien"));
        mItems.add(new MyBaseItem(1, "Frosthärte"));
        mItems.add(new MyBaseItem(2, "Detailsuche"));

    }

    @Override
    public int getGroupCount() {
        return mItems.size();
    }

    @Override
    public int getChildCount(int groupPosition) {
        int childCount = 0;
        int groupId = mItems.get(groupPosition).id;
        if (groupId == 0) {
            childCount = badgesVMList.size();
        } else if (groupId == 1) {
            childCount = 1; //contains only one item
        } else if (groupId == 2) {
            childCount = 1; //contains only one item
        }
        return childCount;
    }

    @Override
    public long getGroupId(int groupPosition) {
        // This method need to return unique value within all group items.
        return mItems.get(groupPosition).id;
    }

    @Override
    public long getChildId(int groupPosition, int childPosition) {
        // This method need to return unique value within the group.
        int groupId = mItems.get(groupPosition).id;
        int childId = 0;

        if (groupId == 0) {
            badgesVMList.get(childPosition).getId();
        } else if (groupId == 1) {
            childId = 0;
        } else if (groupId == 2) {
            childId = 0;
        }
        return childId;
    }

    @Override
    @NonNull
    public MyGroupViewHolder onCreateGroupViewHolder(@NonNull ViewGroup parent, int viewType) {
        View v = LayoutInflater.from(parent.getContext()).inflate(R.layout.list_group_item_for_expandable_minimal, parent, false);
        MyGroupViewHolder vh = new MyGroupViewHolder(v);
        vh.itemView.setOnClickListener(this);
        return vh;
    }

    @Override
    @NonNull
    public MyChildViewHolder onCreateChildViewHolder(@NonNull ViewGroup parent, int viewType) {
        View v = LayoutInflater.from(parent.getContext()).inflate(R.layout.list_child_item_for_expandable_minimal, parent, false);
        MyChildViewHolder vh = new MyChildViewHolder(v);
        vh.itemView.setOnClickListener(this);
        return vh;
    }

    @Override
    public void onBindGroupViewHolder(@NonNull MyGroupViewHolder holder, int groupPosition, int viewType) {
        MyBaseItem group = mItems.get(groupPosition);
        holder.textView.setText(group.text);
    }

    @Override
    public void onBindChildViewHolder(@NonNull MyChildViewHolder holder, int groupPosition, int childPosition, int viewType) {

        int groupId = mItems.get(groupPosition).id;
        if (groupId == 0) {
            BadgesVM badgesVM = badgesVMList.get(childPosition);
            holder.textView.setVisibility(View.VISIBLE);
            holder.frostSlider.setVisibility(View.GONE);
            holder.detailRecycleView.setVisibility(View.GONE);
            holder.textView.setText(badgesVM.getName());
        } else if (groupId == 1) {
            holder.textView.setVisibility(View.GONE);
            holder.frostSlider.setVisibility(View.VISIBLE);
            holder.detailRecycleView.setVisibility(View.GONE);
        } else if (groupId == 2) {
            holder.textView.setVisibility(View.GONE);
            holder.frostSlider.setVisibility(View.GONE);
            holder.detailRecycleView.setVisibility(View.VISIBLE);

            // Setup expandable feature and RecyclerView
            RecyclerViewExpandableItemManager expMgr = new RecyclerViewExpandableItemManager(null);

            DetailSearchExpandableItemAdapter.OnListItemClickMessageListener clickListener = message -> {
                Toast.makeText(context, message, Toast.LENGTH_SHORT).show();

            };
            List<BadgesVM> badgesVMList = null;

            badgesVMList = new ArrayList() {{
                add(new BadgesVM("447", "Bienenfreundlich", "bienenfreundlich", false));
                add(new BadgesVM("320,322", "Vogelfreundlich", "vogelfreundlich", false));
                add(new BadgesVM("321", "Insektenfreundlich", "insektenfreundlich", false));
                add(new BadgesVM("445", "Ökologisch wertvoll", "oekologisch", false));
                add(new BadgesVM("531", "Schmetterlings freundlich", "schmetterlings", false));
                add(new BadgesVM("530", "Heimische Pflanze'", "heimische Pflanze'", false));
            }};

            // Create wrapped adapter:  MyItemAdapter -> expMgr.createWrappedAdapter -> MyHeaderFooterAdapter
            RecyclerView.Adapter adapter2;
            adapter2 = new DetailSearchExpandableItemAdapter(context, expMgr, badgesVMList, clickListener);
            adapter2 = expMgr.createWrappedAdapter(adapter2);
            //adapter = new DemoHeaderFooterAdapter(adapter, null);

            holder.detailRecycleView.setAdapter(adapter2);

            holder.detailRecycleView.setLayoutManager(new LinearLayoutManager(context));

            // NOTE: need to disable change animations to ripple effect work properly
            ((SimpleItemAnimator) holder.detailRecycleView.getItemAnimator()).setSupportsChangeAnimations(false);

            expMgr.attachRecyclerView(holder.detailRecycleView);

        }


    }

    @Override
    public boolean onCheckCanExpandOrCollapseGroup(@NonNull MyGroupViewHolder holder, int groupPosition, int x, int y, boolean expand) {
        // handles click event manually (to show Snackbar message)
        return false;
    }

    @Override
    public void onClick(View v) {
        RecyclerView rv = RecyclerViewAdapterUtils.getParentRecyclerView(v);
        RecyclerView.ViewHolder vh = rv.findContainingViewHolder(v);

        int rootPosition = vh.getAdapterPosition();
        if (rootPosition == RecyclerView.NO_POSITION) {
            return;
        }

        // need to determine adapter local flat position like this:
        RecyclerView.Adapter rootAdapter = rv.getAdapter();
        int localFlatPosition = WrapperAdapterUtils.unwrapPosition(rootAdapter, this, rootPosition);

        long expandablePosition = mExpandableItemManager.getExpandablePosition(localFlatPosition);
        int groupPosition = RecyclerViewExpandableItemManager.getPackedPositionGroup(expandablePosition);
        int childPosition = RecyclerViewExpandableItemManager.getPackedPositionChild(expandablePosition);

        String message;
        if (childPosition == RecyclerView.NO_POSITION) {
            // Clicked item is a group!

            // toggle expand/collapse
            if (mExpandableItemManager.isGroupExpanded(groupPosition)) {
                mExpandableItemManager.collapseGroup(groupPosition);
                message = "COLLAPSE: Group " + groupPosition;
            } else {
                mExpandableItemManager.expandGroup(groupPosition);
                message = "EXPAND: Group " + groupPosition;
            }
        } else {
            // Clicked item is a child!

            message = "CLICKED: Child " + groupPosition + "-" + childPosition;
        }

        mOnItemClickListener.onItemClicked(message);
    }

    public interface OnListItemClickMessageListener {
        void onItemClicked(String message);
    }

}

enter image description here

2 Answers

You were right to search a library that does most of the work for you, but I don't like the library you picked. It does not seem very flexible and concise. I suggest to take a look at Groupie, its API is pretty clean. Also check Reddit for some discussion on libraries.

If you want to write it yourself I think you can solve it without nested Adapter's. Just create an 'expandable group' item type. Then in getItemCount() you count all items and their nested items (when expanded). Take a look at the Groupie source code.

Some additional feedback on your code:

  1. I would explicitly add the header to the list of items you give to your adapter. So instead of a List<Plants>, you rather provide a List<Item> and have a HeaderItem and PlantsItem. This way you have a clear separation between your domain models (Plants) and view models (the items) in your adapter.
  2. Your onBindViewHolder() method does way too much. Let your ViewHolder subclasses take care of that. Create an abstract ViewHolder with an abstract bindTo(Item item) method. Then in your HeaderViewHolder subclass it and do the actual work (after an instanceof check).
  3. Have a look at view binding, it can make your code more concise. (So does Kotlin.)

You can use ConcatAdapter to have multiple adapters with ViewHolders that hold different type of layouts even with the ones that contain RecyclerViews, i used in my last project and it works fine, you can check it out here, dashboard module uses multiple adapters to have different type of layouts.

You can also use the approach they used in Google iosched app to have one adapter with multiple layouts in better way where you move logic from adapter to ViewHolders and their wrapper class ViewBinders. ViewBinder is responsible of calling onViewHolder, onCreateViewHolder and bind data type to a ViewBinder and ViewBinder to a layout. There is an article about how to use it in medium, i will post the link if i can find it. You can also check out this sample i created for animations but used ViewBinders in a simple form to create different type of layouts.

Below is the type of data and layout i wish to show in GridLayout and in which order

val data = mutableListOf<Any>().apply {

            // Add Vector Drawables
            add(HeaderModel("Animated Vector Drawable"))
            add(AVDModel(R.drawable.avd_likes))
            add(AVDModel(R.drawable.avd_settings))

            add(HeaderModel("Seekable Vector Drawable"))
            add(SeekableVDModel(R.drawable.avd_compass_rotation))
            add(SeekableVDModel(R.drawable.avd_views))
            add(SeekableVDModel(R.drawable.avd_hourglass))

            add(HeaderModel("Clocks"))
            add(AVDModel(R.drawable.avd_clock_alarm))
            add(AVDModel(R.drawable.avd_clock_clock))
            add(AVDModel(R.drawable.avd_clock_stopwatch))
}

These are correspond type of data i want to use in my RecyclerView, it's the types and binding to ViewHolder and layout in these classes.

private fun createViewBinders(): HashMap<ItemClazz, MappableItemBinder> {

    val avdViewBinder = AVDViewBinder()
    val seekableVDViewBinder = SeekableVDViewBinder()
    val headViewBinder = HeaderViewBinder()

    return HashMap<ItemClazz, MappableItemBinder>()
        .apply {

            put(
                avdViewBinder.modelClazz,
                avdViewBinder as MappableItemBinder
            )

            put(
                seekableVDViewBinder.modelClazz,
                seekableVDViewBinder as MappableItemBinder
            )

            put(
                headViewBinder.modelClazz,
                headViewBinder as MappableItemBinder
            )
        }
}

And set the data List to adapter and let adapter call corresponding layout that is bound to data

   val dataList:List<Any> = getVectorDrawableItemList()

        val recyclerView = findViewById<RecyclerView>(R.id.recyclerView)

        val adapter = MultipleViewBinderListAdapter(
            createViewBinders(),
            RecyclerView.Adapter.StateRestorationPolicy.ALLOW
        ).apply {
            submitList(dataList)
        }

For the expandable list, iosched app good way of doing it, there is video about how to animate expandable items in RecyclerVİew here. You can set state in ViewHolder and even use MotionLayout for animating from collapsed to expandable state. All can be done without any third party library and very clean way.

Related