Android Data Binding : Dynamic RecyclerView Adapter

One of the most common list design patterns that I encounter as Android developer is a list with a variation of the following:

  • Multiple row types. 
  • Expandable/Collapsible rows or sections. 
  • Load more/Scroll to load more. 
  • Delete rows/Sections. 

And all of these can be implemented in a generic manner with Android Data Binding library. You can write one adapter for all the RecycleViews in your app and use it to render all the common design patterns. Check out the source for the example in this tutorial here.

Final result:

Lets begin by defining our adapter:

public class RecyclerViewBindingAdapter extends RecyclerView.Adapter<RecyclerViewBindingAdapter.BindingViewHolder> {
    private ObservableList<AdapterDataItem> data;

    public RecyclerViewBindingAdapter(ObservableList<AdapterDataItem> data) {
        this.data = data;
        data.addOnListChangedCallback(new ObservableListCallback());
    }

    @Override
    public BindingViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
        return new BindingViewHolder(DataBindingUtil.inflate(LayoutInflater.from(parent.getContext()),viewType,parent,false));
    }

    @Override
    public void onBindViewHolder(BindingViewHolder holder, int position) {
        AdapterDataItem dataItem = data.get(position);
        for(Pair<Integer, Object> idObjectPair: dataItem.idModelPairs){
            holder.bind(idObjectPair.first, idObjectPair.second);
        }
        holder.binding.executePendingBindings();
    }

    @Override
    public int getItemCount() {
        return data.size();
    }

    @Override
    public int getItemViewType(int position) {
        return data.get(position).layoutId;
    }


    private class ObservableListCallback extends  ObservableList.OnListChangedCallback<ObservableList<RecyclerViewBindingAdapter.AdapterDataItem>>{...}


    public class BindingViewHolder extends RecyclerView.ViewHolder{...}

    public static class AdapterDataItem {...}
}

The RecyclerViewBindingAdapter simply inflates each view type that is a layoutId. Next it binds all the variables to the appropriate models.


public class AdapterDataItem {
    public int layoutId;
    List<Pair<Integer, Object>> idModelPairs;
    ...
}

The AdapterDataItem model contains layout id field and a list of integer - object pairs. The reason for this is because any layout with data binding can have multiple variables and for dynamic variable bindings we need to know those variable ids for each layout in order to pass the data models from the code.


    public class BindingViewHolder extends RecyclerView.ViewHolder{
        private ViewDataBinding binding;

        public BindingViewHolder(ViewDataBinding binding) {
            super(binding.getRoot());
            this.binding=binding;
        }

        public void bind(int varId, Object obj){
            this.binding.setVariable(varId, obj);
        }
    }

The BindingViewHolder class simply holds reference of the binding and provides the bind() method to bind the layout variables to models.


    private class ObservableListCallback extends  ObservableList.OnListChangedCallback<ObservableList<RecyclerViewBindingAdapter.AdapterDataItem>>{

        @Override
        public void onChanged(ObservableList<AdapterDataItem> sender) {
            notifyDataSetChanged();
        }

        @Override
        public void onItemRangeChanged(ObservableList<AdapterDataItem> sender, int positionStart, int itemCount) {
            notifyItemRangeChanged(positionStart, itemCount);
        }

        @Override
        public void onItemRangeInserted(ObservableList<AdapterDataItem> sender, int positionStart, int itemCount) {
            notifyItemRangeInserted(positionStart, itemCount);
        }

        @Override
        public void onItemRangeMoved(ObservableList<AdapterDataItem> sender, int fromPosition, int toPosition, int itemCount) {
            notifyDataSetChanged(); // not sure how to notify adapter of this event
        }

        @Override
        public void onItemRangeRemoved(ObservableList<AdapterDataItem> sender, int positionStart, int itemCount) {
            notifyItemRangeRemoved(positionStart, itemCount);
        }
    }

The ObservableListCallback class is a wrapper to automatically provide notification of change in our data to the adapter.


Next lets create an Presenter interface to help us keep the event handling separate from ui logic.

public interface ListItemsPresenter {
    void onClick(ItemModel itemModel);
    void onClick(SubItemModel subItemModel);
    void onDeleteClick(ItemModel itemModel);
    void onExpandClick(ItemModel itemModel);
    void onLoadMoreClick();
}


Next up lets create some custom attributes for RecyclerView:
public class AttributeBindingsAddapter {
    @BindingAdapter({"bind:list","bind:layoutManager","bind:itemAnimator"})
    public static void setList(RecyclerView rv, ObservableList dataItems, RecyclerView.LayoutManager layoutManager, RecyclerView.ItemAnimator itemAnimator){
        if(rv.getLayoutManager()==null)
            rv.setLayoutManager(layoutManager);
        if(rv.getAdapter() ==null)
            rv.setAdapter(new RecyclerViewBindingAdapter(dataItems));
        if(rv.getItemAnimator() == null)
            rv.setItemAnimator(itemAnimator);
    }
}


Custom attributes for RecyclerView allow us to set the adapter, layout manager and item animator in the xml binding definition but are not necessary as you can just set these properties normally from code.

Lets take a look at a couple of our layouts:
activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">
    <data>
        <variable
            name="modelList"
            type="android.databinding.ObservableList"/>
        <variable
            name="listLayoutManager"
            type="android.support.v7.widget.RecyclerView.LayoutManager"/>
        <variable
            name="itemAnimator"
            type="android.support.v7.widget.RecyclerView.ItemAnimator"/>
    </data>
    <android.support.v7.widget.RecyclerView
        ...
        app:list="@{modelList}"
        app:layoutManager="@{listLayoutManager}"
        app:itemAnimator="@{itemAnimator}"/>
</layout>


Here we define the bindings for the RecyclerView's custom attributes.

layout_item.xml
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">
    <data>
        <variable
            name="itemModel"
            type="com.codeprinciples.advancedadapterexample.models.ItemModel"/>
        <variable
            name="itemPresenter"
            type="com.codeprinciples.advancedadapterexample.presenters.ListItemsPresenter"/>
    </data>
    <LinearLayout
        ...
        android:onClick="@{(v) -> itemPresenter.onClick(itemModel)}">
        <TextView
            ...
            android:text="@{itemModel.item}"/>
        <ImageView
            ...
            android:onClick="@{(v) -> itemPresenter.onDeleteClick(itemModel)}"/>
        <ImageView
            android:onClick="@{(v) -> itemPresenter.onExpandClick(itemModel)}"/>

    </LinearLayout>
</layout>

Here we set the data bindings for the row's text content and on click handling. We also have couple more layouts such as header layout, sub item layout, loading layout, and load more layout, that our list will use but they are omitted as you are encouraged to create your own to see the flexibility of our dynamic adapter.

Finally we can create our Activity:

public class MainActivity extends AppCompatActivity implements ListItemsPresenter{
    private ActivityMainBinding mBinding ;
    private ObservableList<RecyclerViewBindingAdapter.AdapterDataItem> listItems;
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        mBinding= DataBindingUtil.setContentView(this, R.layout.activity_main);
        mBinding.setListLayoutManager(new LinearLayoutManager(this,LinearLayoutManager.VERTICAL,false));
        mBinding.setModelList(initList());
        mBinding.setItemAnimator(new DefaultItemAnimator());
        loadDataWithDelay(1500);
    }

    private ObservableList initList() {
        listItems = new ObservableArrayList<>();
        listItems.add(new RecyclerViewBindingAdapter.AdapterDataItem(R.layout.layout_heading, new Pair<Integer, Object>(BR.headingModel, new HeadingModel("Content Heading"))));
        listItems.add(new RecyclerViewBindingAdapter.AdapterDataItem(R.layout.layout_loading));
        return listItems;
    }

    private void loadDataWithDelay(int delayMilli) {
        new Handler().postDelayed(new Runnable() {
            @Override
            public void run() {
                listItems.addAll(listItems.size()-1,getItems());//insert before loading cell
                listItems.remove(listItems.size()-1);//remove loading cell
                listItems.add(new RecyclerViewBindingAdapter.AdapterDataItem(R.layout.layout_load_more, new Pair<Integer, Object>(BR.presenter,MainActivity.this)));
            }
        },delayMilli);
    }
...
}

The MainActivity class initializes the list with a header row and a loading (progress bar) row. Then to simulate network load delay after 1.5 seconds we add some content items below the header and replace loading row to a "Load More" row. All the changes to the ObservableList listItems list are automatically updated in the adapter as it is registered to observe for changes in our collection.

And the ListItemPresenter interface is implemented by MainActivity like so:
public class MainActivity extends AppCompatActivity implements ListItemsPresenter{
    ...
    @Override
    public void onClick(ItemModel itemModel) {
        Toast.makeText(this, "itemModel clicked", Toast.LENGTH_SHORT).show();
    }

    @Override
    public void onClick(SubItemModel subItemModel) {
        Toast.makeText(this, "subItemModel clicked", Toast.LENGTH_SHORT).show();
    }

    @Override
    public void onDeleteClick(ItemModel itemModel) {
        if(itemModel.expanded)
            onExpandClick(itemModel);
        listItems.remove(Utils.convert(itemModel, this));
    }

    @Override
    public void onExpandClick( ItemModel itemModel) {
        itemModel.expanded = !itemModel.expanded;
        RecyclerViewBindingAdapter.AdapterDataItem item = Utils.convert(itemModel, this);
        if(itemModel.expanded) {
            listItems.addAll(listItems.indexOf(item)+1, getSubItems(itemModel));
        }else {
            int index = listItems.indexOf(item)+1;
            listItems.subList(index, index + itemModel.subItemModels.size()).clear();
        }
    }

    @Override
    public void onLoadMoreClick() {
        listItems.remove(listItems.size()-1);//remove load more cell
        listItems.add(new RecyclerViewBindingAdapter.AdapterDataItem(R.layout.layout_loading)); //add loading cell
        loadDataWithDelay(1500);
    }
}

The callbacks of the ListItemPresenter implementation allows us to handle row deletion,expanding rows, and appending more rows simply by inserting or deleting items in our ObservableList listItems and again any list changes are automatically reflected in the adapter.

Results:


Sources and further reading:

Liked this blog post? Want to learn more? 

Take my Udemy course on Android Data Binding here:

course image 


Comments

  1. I'll right away take hold of your rss as I can't find your e-mail subscription hyperlink or newsletter service. Do you have any? Please allow me know in order that I may subscribe. Thanks. americanexpress.com login

    ReplyDelete
  2. Wow, amazing blog format! How lengthy have you been blogging for? you make blogging look easy. The whole look of your site is magnificent, as smartly as the content material!
    sign in hotmail

    ReplyDelete

Post a Comment

Popular posts from this blog

Android Architecture Components : LiveData

Android Data Binding : Tricks and Magic