Created
February 17, 2022 07:59
-
-
Save XanderZhu/19b3794634fcb9229dc0bc114fb5552a to your computer and use it in GitHub Desktop.
StickyHeaderItemDecoration
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 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