Skip to content

Instantly share code, notes, and snippets.

@filipkowicz
Last active February 26, 2025 18:20
Show Gist options
  • Save filipkowicz/1a769001fae407b8813ab4387c42fcbd to your computer and use it in GitHub Desktop.
Save filipkowicz/1a769001fae407b8813ab4387c42fcbd to your computer and use it in GitHub Desktop.
Item Decorator for sticky headers in Kotlin
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
)
}
}
@mfadiilahrio
Copy link

Cool

@yankauskas
Copy link

Thank you! Extremely useful!

@antanas-radzevicius-tg
Copy link

Thanks!

@moffpage
Copy link

Thanks for the library! Could you please move it to MavenCentral since jcenter is gonna be closed for publishing soon? And after a year, fetching from.

@GWCangie
Copy link

Hi, has it been glitching for anyone? I'll scroll up and sometimes my items will be in front of the sticky title.

@james04gr
Copy link

james04gr commented Sep 7, 2021

Excellent work, thanks a lot! I have a question though.
I want to have a paddingTop on every "Header" item except the First one. So what i did is to use the getItemOffsets

`override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State
) {
super.getItemOffsets(outRect, view, parent, state)
val itemPosition = parent.getChildAdapterPosition(view)
if (itemPosition == RecyclerView.NO_POSITION) return

    val itemCount = state.itemCount
    val paddingTop = 100

    if (itemCount > 0 && itemPosition != 0
        && parent.adapter!!.getItemViewType(itemPosition) == VIEW_TYPE_HEADER)
            outRect.set(0, paddingTop,0,0)
}`

Although i get what i want and the headers have a Padding on top (except the first Header item), when a header meets the above one then the Padding space swap with the above Header and i get a mess.
Instead i get "Header1 - Padding - Header2" suddenly i have "Padding - Header1 - Header2"

Why is this happening and how can i solve that?

@SSilence
Copy link

SSilence commented Apr 6, 2022

Thanks a lot, very helpful!!

@jahirfiquitiva
Copy link

jahirfiquitiva commented May 9, 2022

I'm facing this issue where the item view is not exactly sticky, there's a view the size of the expected view, but the actual view is not there

would someone mind helping with this issue? cc @filipkowicz

Shot 2022-05-09 at 16-06-51

@hafizmdyasir
Copy link

@filipkowicz Hi, It works great, but the problem I am facing is that when I click on topmost header then the items in the background getting clicked. It is happening for the top header only. Help me if u understand it.

@SaharshPandey I know I am late to the party but. Try setting the clickable attribute in your header view to true.

headerView.clickable = true

In xml android:clickable="true"

If that doesn't work, also try setting android:focusable="true"

@Reejesh-PK
Copy link

If any one is looking for isHeader implementation, can refer
https://stackoverflow.com/a/33402863/14784590

@harunkor
Copy link

harunkor commented Mar 1, 2023

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.

@harunkor
Copy link

harunkor commented Mar 1, 2023

How can I solve this?

@Suifuto
Copy link

Suifuto commented Apr 19, 2024

@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()
    }

@NLLAPPS
Copy link

NLLAPPS commented Feb 26, 2025

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