Skip to content

Instantly share code, notes, and snippets.

@XanderZhu
Created February 17, 2022 07:59
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save XanderZhu/19b3794634fcb9229dc0bc114fb5552a to your computer and use it in GitHub Desktop.
Save XanderZhu/19b3794634fcb9229dc0bc114fb5552a to your computer and use it in GitHub Desktop.
StickyHeaderItemDecoration
/**
Solution based on Sevastyan answer on StackOverflow
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.annotation.DimenRes
import androidx.core.graphics.withSave
import androidx.core.graphics.withTranslation
import androidx.core.view.updatePadding
import androidx.recyclerview.widget.RecyclerView
class StickyHeaderItemDecoration(
parent: RecyclerView,
@DimenRes horizontalHeaderOffset: Int,
@DimenRes topHeaderOffset: Int? = null,
@DimenRes bottomHeaderOffset: Int? = null,
private val isHeader: (itemPosition: Int) -> Boolean
) : RecyclerView.ItemDecoration() {
private var currentHeader: Pair<Int, RecyclerView.ViewHolder>? = null
private val horizontalOffset = parent.context.resources.getDimensionPixelSize(horizontalHeaderOffset)
private val topHeaderOffset = topHeaderOffset?.let {
parent.context.resources.getDimensionPixelOffset(topHeaderOffset)
} ?: 0
private val bottomHeaderOffset = bottomHeaderOffset?.let {
parent.context.resources.getDimensionPixelOffset(bottomHeaderOffset)
} ?: 0
init {
parent.adapter?.onDataChanged {
// clear saved header as it can be outdated now
currentHeader = null
}
parent.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
currentHeader = null
}
// handle click on sticky header
parent.interceptTouchEvent { _, motionEvent ->
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 topChildPosition = parent.getChildAt(0)?.let { topChild ->
parent.getChildAdapterPosition(topChild).safe()
} ?: return
val headerView = parent.getHeaderViewForItem(topChildPosition) ?: return
val contactPoint = headerView.bottom + parent.paddingTop
val nextHeader = parent.getChildInContact(contactPoint) ?: return
val nextHeaderPosition = parent.getChildAdapterPosition(nextHeader).safe() ?: return
if (isHeader(nextHeaderPosition)) {
c.moveHeader(headerView, nextHeader)
} else {
c.drawHeader(headerView)
}
}
override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
val position = parent.getChildAdapterPosition(view).safe() ?: return
if(isHeader(position)) {
view.updatePadding(
left = horizontalOffset,
top = topHeaderOffset,
right = horizontalOffset,
bottom = bottomHeaderOffset
)
}
}
private fun RecyclerView.getHeaderViewForItem(itemPosition: Int): View? {
val adapter = adapter ?: return null
val headerPosition = getHeaderPositionForItem(itemPosition).safe() ?: return null
val headerType = adapter.getItemViewType(headerPosition)
// if match reuse viewHolder
if (currentHeader?.first == headerPosition && currentHeader?.second?.itemViewType == headerType) {
return currentHeader?.second?.itemView
}
val headerHolder = adapter.createViewHolder(this, headerType)
adapter.onBindViewHolder(headerHolder, headerPosition)
headerHolder.itemView.updatePadding(
left = horizontalOffset,
top = topHeaderOffset,
right = horizontalOffset,
bottom = bottomHeaderOffset
)
headerHolder.itemView.measuresAndLayoutsTheTopStickyHeader(this)
// save for next draw
currentHeader = headerPosition to headerHolder
return headerHolder.itemView
}
private fun Canvas.drawHeader(header: View) = withSave {
header.draw(this)
}
private fun Canvas.moveHeader(currentHeader: View, nextHeader: View) {
val verticalDistanceToTranslate = (nextHeader.top - currentHeader.height).toFloat()
withTranslation(y = verticalDistanceToTranslate) {
currentHeader.draw(this)
}
}
private fun RecyclerView.getChildInContact(contactPoint: Int): View? {
var childInContact: View? = null
for (i in 0 until childCount) {
val child = getChildAt(i)
val bounds = Rect()
getDecoratedBoundsWithMargins(child, bounds)
if (bounds.bottom > contactPoint) {
if (bounds.top <= contactPoint) {
// This child overlaps the contactPoint
childInContact = child
break
}
}
}
return childInContact
}
/**
* [parent] ViewGroup: RecyclerView in this case.
*/
private fun View.measuresAndLayoutsTheTopStickyHeader(parent: ViewGroup) {
// 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,
layoutParams.width
)
val childHeightSpec = ViewGroup.getChildMeasureSpec(
heightSpec,
parent.paddingTop + parent.paddingBottom,
layoutParams.height
)
measure(childWidthSpec, childHeightSpec)
layout(0, 0, measuredWidth, measuredHeight)
}
private fun getHeaderPositionForItem(itemPosition: Int): Int {
var headerPosition = RecyclerView.NO_POSITION
var currentPosition = itemPosition
do {
if (isHeader(currentPosition)) {
headerPosition = currentPosition
break
}
currentPosition -= 1
} while (currentPosition >= 0)
return headerPosition
}
private fun Int.safe(): Int? = takeIf { it != RecyclerView.NO_POSITION }
}
fun RecyclerView.Adapter<*>.onDataChanged(action: () -> Unit) {
registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() {
override fun onChanged() {
action()
}
})
}
fun RecyclerView.interceptTouchEvent(interceptAction: (RecyclerView, MotionEvent) -> Boolean) {
addOnItemTouchListener(object : RecyclerView.SimpleOnItemTouchListener() {
override fun onInterceptTouchEvent(
recyclerView: RecyclerView,
motionEvent: MotionEvent
): Boolean = interceptAction(recyclerView, motionEvent)
})
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment