Forked from filipkowicz/HeaderItemDecoration.kt
Last active
October 7, 2019 03:23
Item Decorator for sticky headers in Kotlin
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* | |
solution based on - based on Sevastyan answer on StackOverflow | |
changes: | |
- take to account views offsets | |
- transformed to Kotlin | |
- now works on viewHolders | |
- try to cache viewHolders between draw's | |
Source: | |
https://stackoverflow.com/questions/32949971/how-can-i-make-sticky-headers-in-recyclerview-without-external-lib/44327350#44327350 | |
*/ | |
import android.graphics.Canvas | |
import android.graphics.Rect | |
import android.view.MotionEvent | |
import android.view.View | |
import android.view.ViewGroup | |
import androidx.recyclerview.widget.RecyclerView | |
/** | |
* HeaderItemDecoration | |
* | |
* RecyclerView Adapter should implement HasHeaderItem | |
* to identify header position | |
* | |
* | |
*/ | |
class HeaderItemDecoration(private val recyclerView: RecyclerView) : RecyclerView.ItemDecoration() { | |
private var currentHeader: Pair<Int, RecyclerView.ViewHolder>? = null | |
private val recyclerViewAdapter: RecyclerView.Adapter<RecyclerView.ViewHolder> | |
get() { | |
recyclerView.adapter?.let { adapter -> | |
if ((adapter is HasHeaderItem).not()) { | |
throw HasHeaderItemNotFoundException("RecyclerView Adapter should implement HasHeaderItem.") | |
} | |
return adapter | |
} ?: run { | |
throw HasHeaderItemNotFoundException("RecyclerView Adapter is null.") | |
} | |
} | |
init { | |
recyclerViewAdapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() { | |
override fun onChanged() { | |
// clear saved header as it can be outdated now | |
currentHeader = null | |
} | |
}) | |
recyclerView.doOnEachNextLayout { | |
// clear saved layout as it may need layout update | |
currentHeader = null | |
} | |
// handle click on sticky header | |
recyclerView.addOnItemTouchListener(object : RecyclerView.SimpleOnItemTouchListener() { | |
override fun onInterceptTouchEvent(recyclerView: RecyclerView, motionEvent: MotionEvent): Boolean { | |
return if (motionEvent.action == MotionEvent.ACTION_DOWN) { | |
motionEvent.y <= currentHeader?.second?.itemView?.bottom ?: 0 | |
} else false | |
} | |
}) | |
} | |
override fun onDrawOver(c: Canvas, parent: RecyclerView, state: RecyclerView.State) { | |
super.onDrawOver(c, parent, state) | |
val topChild = parent.getChildAt(0) ?: return | |
val topChildPosition = parent.getChildAdapterPosition(topChild) | |
if (topChildPosition == RecyclerView.NO_POSITION) { | |
return | |
} | |
val headerView = getHeaderViewForItem(topChildPosition, parent) ?: return | |
val contactPoint = headerView.bottom | |
val childInContact = getChildInContact(parent, contactPoint) ?: return | |
if ((parent.adapter as? HasHeaderItem)?.isHeaderItem(parent.getChildAdapterPosition(childInContact)) == true) { | |
moveHeader(c, headerView, childInContact) | |
return | |
} | |
drawHeader(c, headerView) | |
} | |
private fun getHeaderViewForItem(itemPosition: Int, parent: RecyclerView): View? { | |
if (parent.adapter == null) { | |
return null | |
} | |
val headerPosition = getHeaderPositionForItem(itemPosition) | |
if (headerPosition == -1) { | |
return null | |
} | |
val headerType = parent.adapter?.getItemViewType(headerPosition) ?: return null | |
// if match reuse viewHolder | |
if (currentHeader?.first == headerPosition && currentHeader?.second?.itemViewType == headerType) { | |
return currentHeader?.second?.itemView | |
} | |
val headerHolder = parent.adapter?.createViewHolder(parent, headerType) | |
if (headerHolder != null) { | |
parent.adapter?.onBindViewHolder(headerHolder, headerPosition) | |
fixLayoutSize(parent, headerHolder.itemView) | |
// save for next draw | |
currentHeader = headerPosition to headerHolder | |
} | |
return headerHolder?.itemView | |
} | |
private fun drawHeader(c: Canvas, header: View) { | |
c.save() | |
c.translate(0f, 0f) | |
header.draw(c) | |
c.restore() | |
} | |
private fun moveHeader(c: Canvas, currentHeader: View, nextHeader: View) { | |
c.save() | |
c.translate(0f, (nextHeader.top - currentHeader.height).toFloat()) | |
currentHeader.draw(c) | |
c.restore() | |
} | |
private fun getChildInContact(parent: RecyclerView, contactPoint: Int): View? { | |
var childInContact: View? = null | |
for (i in 0 until parent.childCount) { | |
val child = parent.getChildAt(i) | |
val mBounds = Rect() | |
parent.getDecoratedBoundsWithMargins(child, mBounds) | |
if (mBounds.bottom > contactPoint) { | |
if (mBounds.top <= contactPoint) { | |
// This child overlaps the contactPoint | |
childInContact = child | |
break | |
} | |
} | |
} | |
return childInContact | |
} | |
/** | |
* Properly measures and layouts the top sticky header. | |
* | |
* @param parent ViewGroup: RecyclerView in this case. | |
*/ | |
private fun fixLayoutSize(parent: ViewGroup, view: View) { | |
// Specs for parent (RecyclerView) | |
val widthSpec = View.MeasureSpec.makeMeasureSpec(parent.width, View.MeasureSpec.EXACTLY) | |
val heightSpec = View.MeasureSpec.makeMeasureSpec(parent.height, View.MeasureSpec.UNSPECIFIED) | |
// Specs for children (headers) | |
val childWidthSpec = ViewGroup.getChildMeasureSpec(widthSpec, parent.paddingLeft + parent.paddingRight, view.layoutParams.width) | |
val childHeightSpec = ViewGroup.getChildMeasureSpec(heightSpec, parent.paddingTop + parent.paddingBottom, view.layoutParams.height) | |
view.measure(childWidthSpec, childHeightSpec) | |
view.layout(0, 0, view.measuredWidth, view.measuredHeight) | |
} | |
private fun getHeaderPositionForItem(itemPosition: Int): Int { | |
var headerPosition = -1 | |
var currentPosition = itemPosition | |
do { | |
if ((recyclerViewAdapter as? HasHeaderItem)?.isHeaderItem(currentPosition) == true) { | |
headerPosition = currentPosition | |
break | |
} | |
currentPosition -= 1 | |
} while (currentPosition >= 0) | |
return headerPosition | |
} | |
interface HasHeaderItem { | |
fun isHeaderItem(position: Int): Boolean | |
} | |
class HasHeaderItemNotFoundException(message: String) : Exception(message) | |
} | |
inline fun View.doOnEachNextLayout(crossinline action: (view: View) -> Unit) { | |
addOnLayoutChangeListener { view, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom -> action(view) } | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment