DataBinder pattern allows to manage multiple item layouts in a recyclerView
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!")
}