oligazar
11/23/2017 - 11:52 AM

Multi Item RecyclerView Adapter

DataBinder pattern allows to manage multiple item layouts in a recyclerView

  • BaseClasses
  • Implementation Example
  • Pagination with loading footer
class ScrollPaginationAdapter<out M>(private val models: ArrayList<M>,
                                     dataBinder: DataBinder<RecyclerView.ViewHolder, M>,
                                     val perPage: Int = PER_PAGE,
                                     private val offset: Int = OFFSET,
                                     private val onLoadMore: () -> Unit)
                                : DataBinderAdapter<RecyclerView.ViewHolder, M>() {

    init { addDataBinders(mapOf(FOOTER_VIEW_TYPE to FooterDataBinder(), BINDER_VIEW_TYPE to dataBinder)) }
    private val _tag = ScrollPaginationAdapter::class.java.simpleName

    var isLoading = false // expose
    var currentPage = 1 // expose
    private var isLastPage = false
    private var previousModelsSize = 0  // used to check if is real data inserted to react to the event

    override fun onAttachedToRecyclerView(recyclerView: RecyclerView?) {
        super.onAttachedToRecyclerView(recyclerView)
        recyclerView?.addOnScrollListener(rvOnScrollListener)
        registerAdapterDataObserver(dataObserver)
    }

    override fun onDetachedFromRecyclerView(recyclerView: RecyclerView?) {
        super.onDetachedFromRecyclerView(recyclerView)
        recyclerView?.removeOnScrollListener(rvOnScrollListener)
        unregisterAdapterDataObserver(dataObserver)
    }


    private  val dataObserver = object: RecyclerView.AdapterDataObserver() {
        override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
            Log.e(_tag, "onItemRangeInserted, previousModelsSize: $previousModelsSize, models.size: ${models.size}")
            Log.e(_tag, "onItemRangeInserted, positionStart: $positionStart, itemCount: $itemCount")
            if (previousModelsSize == models.size) return   // when footer inserted don't react to the event
            else previousModelsSize = models.size

            Log.e(_tag, "onItemRangeInserted, itemCount: ${getItemCount()}, models.size: ${models.size}")
            //            if (getItemCount() > models.size) return

            isLoading = false
            //                        products.clear()
            if (isFooterVisible) hideFooter()
            Log.e(_tag, "onItemRangeInserted, showFooter: ${itemCount >= perPage}, isLastPage: $isLastPage")

            if (itemCount >= perPage) {
                showFooter()
            } else {
                isLastPage = true
            }
        }
    }

    private val rvOnScrollListener = object : RecyclerView.OnScrollListener() {

        override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
            super.onScrolled(recyclerView, dx, dy)
            val layoutMngr = recyclerView.layoutManager as? LinearLayoutManager ?: return
            val visibleItemCount = layoutMngr.childCount
            val totalItemCount = layoutMngr.itemCount
            val firstVisibleItemPosition = layoutMngr.findFirstVisibleItemPosition()

            if (!isLoading && !isLastPage) {
//                Log.e(_tag, "onScrolled, $visibleItemCount + $firstVisibleItemPosition >= $totalItemCount - $offset")
                if (visibleItemCount + firstVisibleItemPosition >= totalItemCount - offset
                        && firstVisibleItemPosition >= 0
                        && totalItemCount >= perPage) {
                    Log.e(_tag, "onScrolled, inside on load more")
                    isLoading = true
                    currentPage += 1
                    onLoadMore()
                }
            }
        }
    }

    /*************************************************************/

    private var isFooterVisible = false

    override fun getModel(position: Int) = if (position < models.size) models[position] else models[0]

    override fun getItemViewType(position: Int): Int {
        return if (isFooterVisible && position >= models.size) FOOTER_VIEW_TYPE
        else BINDER_VIEW_TYPE
    }

    override fun getItemCount() = if (isFooterVisible) models.size + 1 else models.size

    fun showFooter() {
        Log.e(_tag, "showFooter")
        isFooterVisible = true
        notifyItemInserted(models.size + 1)
    }

    /** The footer hides automatically when onItemRangeInserted detected.
     * If load more function returns empty collection - it has to be called manually */
    fun hideFooter() {
        Log.e(_tag, "hideFooter")
        isFooterVisible = false
        notifyItemRemoved(models.size + 1)
    }

    companion object {
        const val FOOTER_VIEW_TYPE = -1
        const val BINDER_VIEW_TYPE = 0
        const val PER_PAGE = 20
        const val OFFSET = 10
    }
}
private fun setupRecycler(products: ArrayList<Product>) {
        val categoryId = arguments?.getInt(ARG_CATEGORY_ID) ?: return

        val binder = ProductDataBinder(::openProduct, ::addToCart)
        rvAdapter = ScrollPaginationAdapter(products, binder) {
            fetchProducts(categoryId)
        }

        rvLayoutManager = GridLayoutManager(context, 2).apply {
            spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
                override fun getSpanSize(position: Int): Int =
                        when (rvAdapter.getItemViewType(position)) {
                            ScrollPaginationAdapter.BINDER_VIEW_TYPE -> 1
                            ScrollPaginationAdapter.FOOTER_VIEW_TYPE -> 2 //number of columns of the grid
                            else -> -1
                        }
            }
        }

        recycler.apply {
            adapter = rvAdapter
            layoutManager = rvLayoutManager
            addItemDecoration(GridItemDecorator(2, 8))
        }
        fetchProducts(categoryId)
    }

    private fun openProduct(product: Product) {
        parentFragment?.childFragmentManager?.inTransaction {
            replace(R.id.container_fragment, ProductDetailsFragment.newInstance(product.id, product.categoryName))
            addToBackStack(null)
        }
    }

    private fun addToCart(product: Product) {
        Log.d(_tag, "setupRecycler, isInBag: ${product.isInBag}")
        realm.switchBag(product)
        rvAdapter.notifyItemChanged(products.indexOf(product))
    }

    private fun fetchProducts(categoryId: Int) {
        val mContext = context ?: return

        if (rvAdapter.currentPage == 1) showProgress()

        val disposable = RetrofitManager.createService(mContext, IctService::class.java)
                .getProductsPages(authHeader(), rvAdapter.currentPage, rvAdapter.perPage, categoryId)
                .subscribeOn(Schedulers.newThread())
                .observeOn(AndroidSchedulers.mainThread())
                .subscribe({
                    if (rvAdapter.currentPage == 1) hideProgress() // hide spinner in activity

                    if (it.isEmpty()) {
                        if (rvAdapter.currentPage == 1) showMessage(tvMessage, getString(R.string.msg_empty_posts))
                        else rvAdapter.hideFooter() // if no items returned and currentPage > 1, hide the circle spinner in adapter
                    } else {
                        // how to deal with duplicates?
                        val positionStart = products.size
                        products.addAll(it)
                        rvAdapter.notifyItemRangeInserted(positionStart, it.size)

                        if (fragmentTitle.isEmpty()) {
                            fragmentTitle = it.getOrNull(0)?.categories?.getOrNull(0)?.name ?: ""
                            appCompatActivity { title = fragmentTitle }
                        }
                    }
                    Log.d(_tag, "products: $it")

                }, { error ->
                    Log.e(_tag, "error: ${error.stackTrace}")

                    rvAdapter.hideFooter()
                    showErrorDialog { fetchProducts(categoryId) }
                })
        compositeDisposable.add(disposable)
    }
    
    /* Adapter *************************************************************/
    
    class ScrollPaginationAdapter<out M>(private val models: ArrayList<M>,
                                     dataBinder: DataBinder<RecyclerView.ViewHolder, M>,
                                     val perPage: Int = PER_PAGE,
                                     private val offset: Int = OFFSET,
                                     private val onLoadMore: () -> Unit)
                                : DataBinderAdapter<RecyclerView.ViewHolder, M>() {

    var isLoading = false // expose
    var currentPage = 1 // expose
    private var isLastPage = false
    private var previousModelsSize = 0  // used to check if is real data inserted to react to the event

    override fun onAttachedToRecyclerView(recyclerView: RecyclerView?) {
        super.onAttachedToRecyclerView(recyclerView)
        recyclerView?.addOnScrollListener(rvOnScrollListener)
        registerAdapterDataObserver(dataObserver)
    }

    override fun onDetachedFromRecyclerView(recyclerView: RecyclerView?) {
        super.onDetachedFromRecyclerView(recyclerView)
        recyclerView?.removeOnScrollListener(rvOnScrollListener)
        unregisterAdapterDataObserver(dataObserver)
    }


    private  val dataObserver = object: RecyclerView.AdapterDataObserver() {
        override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {

            if (previousModelsSize == models.size) return   // when footer inserted don't react to the event
            else previousModelsSize = models.size

            isLoading = false
            //                        products.clear()
            if (isFooterVisible) hideFooter()

            if (itemCount >= perPage) {
                showFooter()
            } else {
                isLastPage = true
            }
        }
    }

    private val rvOnScrollListener = object : RecyclerView.OnScrollListener() {

        override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
            super.onScrolled(recyclerView, dx, dy)
            val layoutMngr = recyclerView.layoutManager as? LinearLayoutManager ?: return
            val visibleItemCount = layoutMngr.childCount
            val totalItemCount = layoutMngr.itemCount
            val firstVisibleItemPosition = layoutMngr.findFirstVisibleItemPosition()

            if (!isLoading && !isLastPage) {
                if (visibleItemCount + firstVisibleItemPosition >= totalItemCount - offset
                        && firstVisibleItemPosition >= 0
                        && totalItemCount >= perPage) {
                    isLoading = true
                    currentPage += 1
                    onLoadMore()
                }
            }
        }
    }

    /*************************************************************/

    private var isFooterVisible = false

    init { addDataBinders(mapOf(FOOTER_VIEW_TYPE to FooterDataBinder(), BINDER_VIEW_TYPE to dataBinder)) }

    override fun getModel(position: Int) = if (position < models.size) models[position] else models[0]

    override fun getItemViewType(position: Int): Int {
        return if (isFooterVisible && position >= models.size) FOOTER_VIEW_TYPE
        else BINDER_VIEW_TYPE
    }

    override fun getItemCount() = if (isFooterVisible) models.size + 1 else models.size

    fun showFooter() {
        isFooterVisible = true
        notifyItemInserted(models.size + 1)
    }

    fun hideFooter() {
        isFooterVisible = false
        notifyItemRemoved(models.size + 1)
    }

    companion object {
        const val FOOTER_VIEW_TYPE = -1
        const val BINDER_VIEW_TYPE = 0
        const val PER_PAGE = 20
        const val OFFSET = 10
    }
}
class PaginationAdapter<out M>(private val models: ArrayList<M>,
                               dataBinder: DataBinder<RecyclerView.ViewHolder, M>)
                                : DataBinderAdapter<RecyclerView.ViewHolder, M>() {

    // todo: GridLayoutManager loader footer?
    private var isDataLoading = false
    private val footerViewType = -1
    private val binderViewType = 0
    init { addDataBinders(mapOf(footerViewType to FooterDataBinder(), binderViewType to dataBinder)) }

    override fun getModel(position: Int) = if (position < models.size) models[position] else models[0]

    override fun getItemViewType(position: Int): Int {
        return if (isDataLoading && position >= models.size) footerViewType
        else binderViewType
    }

    override fun getItemCount() = if (isDataLoading) models.size + 1 else models.size

    fun showFooter() {
        isDataLoading = true
        notifyItemInserted(models.size + 1)
    }

    fun hideFooter() {
        isDataLoading = false
        notifyItemRemoved(models.size + 1)
    }

}

class FooterDataBinder<in M> : DataBinder<RecyclerView.ViewHolder, M> {

    override fun newViewHolder(parent: ViewGroup): RecyclerView.ViewHolder {
        val view = LayoutInflater.from(parent.context)
            .inflate(R.layout.item_footer, parent, false)
        return FooterVH(view)
    }

    override fun bindViewHolder(holder: RecyclerView.ViewHolder, model: M) { /* no-op */ }
}

class FooterVH(view: View): RecyclerView.ViewHolder(view)


/// in Fragment
private fun setupRecycler(products: ArrayList<Product>) {
        val categoryId = arguments?.getInt(ARG_CATEGORY_ID) ?: return
        fetchProducts(categoryId)

        val binder = ProductDataBinder({ product ->
                        activity?.supportFragmentManager?.inTransaction {
                replace(R.id.container, ProductDetailsFragment.newInstance(product.id, product.name))
                addToBackStack(null)
            }
        }, { product ->
            activity?.supportFragmentManager?.inTransaction {
                replace(R.id.container, ProductDetailsFragment.newInstance(product.id, product.name))
                addToBackStack(null)
            }
        })
        rvAdapter = PaginationAdapter(products, binder)
        rvLayoutManager = GridLayoutManager(context, 2).apply {
            spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
                override fun getSpanSize(position: Int): Int =
                        when (rvAdapter.getItemViewType(position)) {
                            rvAdapter.binderViewType -> 1
                            rvAdapter.footerViewType -> 2 //number of columns of the grid
                            else -> -1
                        }
            }
        }

        recycler.apply {
            adapter = rvAdapter
            layoutManager = rvLayoutManager
            addItemDecoration(GridItemDecorator(2, top = 16, bottom = 32))
            addOnScrollListener(rvOnScrollListener)
            setHasFixedSize(true)
        }
    }

    private fun fetchProducts(categoryId: Int) {
        val mContext = context ?: return
        if (isLastPage) return

        if (currentPage == 1) showProgress()

        val disposable = RetrofitManager.createService(mContext, IctService::class.java)
                .getProductsPages(authHeader(), currentPage, perPage)
                .subscribeOn(Schedulers.newThread())
                .observeOn(AndroidSchedulers.mainThread())
                .subscribe({
                    if (it.isEmpty()) showMessage(tvMessage, getString(R.string.msg_empty_posts))    // todo: why is the tvMessage null?
                    else {
                        isLoading = false
//                        products.clear()
                        if (currentPage == 1) hideProgress()
                        else rvAdapter.hideFooter()

                        products.addAll(it)
                        rvAdapter.notifyDataSetChanged()

                        if (it.size >= perPage) {
                            rvAdapter.showFooter()
                        } else {
                            isLastPage = true
                        }

                        if (fragmentTitle.isEmpty()) {
                            fragmentTitle = it.getOrNull(0)?.categories?.getOrNull(0) ?: ""
                            appCompatActivity { title = fragmentTitle }
                        }
                    }
                    Log.d(_tag, "products: $it")

                }, { error ->
                    Log.e(_tag, "error: ${error.stackTrace}")

                    rvAdapter.hideFooter()
                    showErrorDialog { fetchProducts(categoryId) }
                })
        compositeDisposable?.add(disposable)
    }

    private val perPage = 16
    private var currentPage = 1
    private var isLoading = false
    private var isLastPage = false

    private val rvOnScrollListener = object : RecyclerView.OnScrollListener() {

        override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
            super.onScrolled(recyclerView, dx, dy)
            val visibleItemCount = rvLayoutManager.childCount
            val totalItemCount = rvLayoutManager.itemCount
            val firstVisibleItemPosition = rvLayoutManager.findFirstVisibleItemPosition()

            if (!isLoading && !isLastPage) {
                if (visibleItemCount + firstVisibleItemPosition >= totalItemCount
                        && firstVisibleItemPosition >= 0
                        && totalItemCount >= perPage) {
                    loadMoreItems()
                }
            }
        }
    }

    private fun loadMoreItems() {
        isLoading = true
        currentPage += 1

        val categoryId = arguments?.getInt(ARG_CATEGORY_ID) ?: return
        fetchProducts(categoryId)
    }
    
// ProductDataBinder

class ProductDataBinder(private val openProduct: (Product) -> Unit,
                        private val addToCart: (Product) -> Unit) : DataBinder<RecyclerView.ViewHolder, Product> {

    override fun newViewHolder(parent: ViewGroup): RecyclerView.ViewHolder {
        val view = LayoutInflater.from(parent.context)
                .inflate(R.layout.item_product, parent, false)
        return ProductVH(view)
    }

    override fun bindViewHolder(holder: RecyclerView.ViewHolder, model: Product) {
        holder as? ProductVH ?: return
        val context = holder.itemView.context

        holder.tvProductName.text = model.name
        holder.tvPrice.text = context.getString(R.string.format_price, model.price)
        holder.itemView.setOnClickListener {
            openProduct(model)
        }
        holder.btnCart.setOnClickListener {
            addToCart(model)
        }

        Glide.with(holder.itemView.context)
                .load(model.imgSrc)
                .apply(RequestOptions()
                        .centerCrop())
                .transition(DrawableTransitionOptions.withCrossFade())
                .into(holder.ivProduct)
    }
}
open class BaseModel(open val viewType: Int)

class NewsfeedAdapter(private val modelList: ArrayList<BaseModel>)
                        : DataBindAdapter<RecyclerView.ViewHolder, BaseModel>(modelList) {

    override fun getItemCount() = modelList.count()

    override fun getItemViewType(position: Int): Int = modelList[position].viewType

    override fun getModel(position: Int): BaseModel = modelList[position]
}

class MessageDataBinder() : DataBinder<RecyclerView.ViewHolder, BaseModel> {

    override fun newViewHolder(parent: ViewGroup): RecyclerView.ViewHolder {

        val view = LayoutInflater.from(parent.context).inflate(
                R.layout.card_message, parent, false)
        return MessageViewHolder(view)
    }

    override fun bindViewHolder(holder: RecyclerView.ViewHolder, model: BaseModel) {

        val messageViewHolder = holder as? MessageViewHolder
        val messageModel = model as? BaseModel

        messageViewHolder?.message?.setText(messageModel.message)
        messageViewHolder?.moreBtn?.setOnClickListener { view -> showDetails(view.context, model) }
    }

    inner class MessageViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {

        var message: TextView = itemView.findViewById(R.id.card_message) as TextView
        var moreBtn: Button = itemView.findViewById(R.id.more_btn) as Button
    }
}
/**
 * Base class for Data Binders. Possibly might be an interface.
 * http://stackoverflow.com/questions/26245139/how-to-create-recyclerview-with-multiple-view-type
 */

interface DataBinder<VH : RecyclerView.ViewHolder, in M> {

    fun newViewHolder(parent: ViewGroup): VH

    fun bindViewHolder(holder: VH, model: M)
}

/**
 * Base class to extend in DataBinder pattern
 */
abstract class DataBindAdapter<VH: RecyclerView.ViewHolder, out M>(private val modelList: ArrayList<M>, 
                                                                   private val dataBinders: MutableMap<Int, DataBinder<VH, M>>): RecyclerView.Adapter<VH>() {

    abstract fun getModel(position: Int): M

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH =
            getDataBinder(viewType).newViewHolder(parent)

    override fun onBindViewHolder(viewHolder: VH, position: Int) {
        getDataBinder(viewHolder.itemViewType)
                .bindViewHolder(viewHolder, getModel(position))
    }

    private fun getDataBinder(viewType: Int): DataBinder<VH, M> = dataBinders[viewType]
                    ?: if (dataBinders.isNotEmpty()) throw IllegalArgumentException("incorrect viewType provided")
                    else throw IllegalStateException("dataBinders map is empty. Please add binders!")
}