Last active
February 26, 2025 18:20
-
Star
(131)
You must be signed in to star a gist -
Fork
(16)
You must be signed in to fork a gist
-
-
Save filipkowicz/1a769001fae407b8813ab4387c42fcbd to your computer and use it in GitHub Desktop.
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
package com.filipkowicz.headeritemdecorator | |
/* | |
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 | |
- support for clipToPadding=false | |
Source: | |
https://stackoverflow.com/questions/32949971/how-can-i-make-sticky-headers-in-recyclerview-without-external-lib/44327350#44327350 | |
*/ | |
import android.graphics.* | |
import android.view.MotionEvent | |
import android.view.View | |
import android.view.ViewGroup | |
import androidx.recyclerview.widget.RecyclerView | |
class HeaderItemDecoration( | |
parent: RecyclerView, | |
private val shouldFadeOutHeader: Boolean = false, | |
private val isHeader: (itemPosition: Int) -> Boolean | |
) : RecyclerView.ItemDecoration() { | |
private var currentHeader: Pair<Int, RecyclerView.ViewHolder>? = null | |
init { | |
parent.adapter?.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() { | |
override fun onChanged() { | |
// clear saved header as it can be outdated now | |
currentHeader = null | |
} | |
}) | |
parent.doOnEachNextLayout { | |
// clear saved layout as it may need layout update | |
currentHeader = null | |
} | |
// handle click on sticky header | |
parent.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 topChild = parent.findChildViewUnder( | |
parent.paddingLeft.toFloat(), | |
parent.paddingTop.toFloat() /*+ (currentHeader?.second?.itemView?.height ?: 0 )*/ | |
) ?: return | |
val topChildPosition = parent.getChildAdapterPosition(topChild) | |
if (topChildPosition == RecyclerView.NO_POSITION) { | |
return | |
} | |
val headerView = getHeaderViewForItem(topChildPosition, parent) ?: return | |
val contactPoint = headerView.bottom + parent.paddingTop | |
val childInContact = getChildInContact(parent, contactPoint) ?: return | |
if (isHeader(parent.getChildAdapterPosition(childInContact))) { | |
moveHeader(c, headerView, childInContact, parent.paddingTop) | |
return | |
} | |
drawHeader(c, headerView, parent.paddingTop) | |
} | |
private fun getHeaderViewForItem(itemPosition: Int, parent: RecyclerView): View? { | |
if (parent.adapter == null) { | |
return null | |
} | |
val headerPosition = getHeaderPositionForItem(itemPosition) | |
if (headerPosition == RecyclerView.NO_POSITION) 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, paddingTop: Int) { | |
c.save() | |
c.translate(0f, paddingTop.toFloat()) | |
header.draw(c) | |
c.restore() | |
} | |
private fun moveHeader(c: Canvas, currentHeader: View, nextHeader: View, paddingTop: Int) { | |
c.save() | |
if (!shouldFadeOutHeader) { | |
c.clipRect(0, paddingTop, c.width, paddingTop + currentHeader.height) | |
} else { | |
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.LOLLIPOP) { | |
c.saveLayerAlpha( | |
RectF(0f, 0f, c.width.toFloat(), c.height.toFloat()), | |
(((nextHeader.top - paddingTop) / nextHeader.height.toFloat()) * 255).toInt() | |
) | |
} else { | |
c.saveLayerAlpha( | |
0f, 0f, c.width.toFloat(), c.height.toFloat(), | |
(((nextHeader.top - paddingTop) / nextHeader.height.toFloat()) * 255).toInt(), | |
Canvas.ALL_SAVE_FLAG | |
) | |
} | |
} | |
c.translate(0f, (nextHeader.top - currentHeader.height).toFloat() /*+ paddingTop*/) | |
currentHeader.draw(c) | |
if (shouldFadeOutHeader) { | |
c.restore() | |
} | |
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 = RecyclerView.NO_POSITION | |
var currentPosition = itemPosition | |
do { | |
if (isHeader(currentPosition)) { | |
headerPosition = currentPosition | |
break | |
} | |
currentPosition -= 1 | |
} while (currentPosition >= 0) | |
return headerPosition | |
} | |
} | |
inline fun View.doOnEachNextLayout(crossinline action: (view: View) -> Unit) { | |
addOnLayoutChangeListener { view, _, _, _, _, _, _, _, _ -> | |
action( | |
view | |
) | |
} | |
} |
How can I solve this?
@jahirfiquitiva It's because of padding, I solved this problem by rewriting some parts of the code. At the same time I had to get rid of shouldFadeOutHeader.
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.computeVerticalScrollOffset() > parent.paddingTop.px) {
if (isHeader(parent.getChildAdapterPosition(childInContact))) {
moveHeader(c, headerView, childInContact, 0)
return
}
drawHeader(c, headerView, 0)
}
}
private fun moveHeader(c: Canvas, currentHeader: View, nextHeader: View, paddingTop: Int) {
c.save()
c.translate(0f, max(0f, (nextHeader.top - currentHeader.height).toFloat()))
currentHeader.draw(c)
c.restore()
}
Hi, I wanted to point out to a bug just in case some one else struggles with like me.
There is a bug at addOnItemTouchListener(). It will intercept all touch events on top of the sticky header around same height as the sticky header, even if the event is not on the sticky header.
To replicate the bug, you can scroll the RecyclerView and then try to click on the RecyclerView item on top of the sticky header.
This seems to be due to wrong calculation of the touch event position.
Removing parent.addOnItemTouchListener foxes this issue.
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Hi, there is an expandable adapter, this adapter is successful with HeaderItemDecoration and sticky structure. When I click on the header, I cannot get the click event in the adapter. It works in non-sticky state, but not on the continuous side.