Skip to content

Instantly share code, notes, and snippets.

@filipkowicz
Last active February 29, 2024 02:18
Star You must be signed in to star a gist
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
)
}
}
@TylerMcCraw
Copy link

TylerMcCraw commented Mar 13, 2020

Turns out, this Decoration doesn't play well if the RecyclerView has other ItemDecorations applied to it.
I've got another ItemDecoration as follows, which applies some left and right insets:

class InsetItemDecoration(@Dimension private val padding: Int) : RecyclerView.ItemDecoration() {
    override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: State) {
        super.getItemOffsets(outRect, view, parent, state)
        outRect.left = padding
        outRect.right = padding
    }
}

my RecyclerView:

        recyclerView.apply {
            adapter = this@MyFragment.adapter
            addItemDecoration(InsetItemDecoration(padding = resources.getDimensionPixelSize(R.dimen.list_margin)))
            addItemDecoration(
                StickyHeaderItemDecoration(this) { itemPosition ->
                    this@MyFragment.adapter.getItemViewType(itemPosition) == R.layout.my_header_item
                }
            )
        }

@filipkowicz
Copy link
Author

@TylerMcCraw please try to invert order. (will check it might be also line 61 - I'm checking if there is aby view under top right corner (with paddings)
consider replacing your decorator with simple padding added to recycler (if it's not just example)
otherwise you might want to change line 61 to parent.left + parent.width/2 to look for view at top middle instead. Good luck! If I find some free time, will take a look for some generic solution

@hantrungkien
Copy link

@TylerMcCraw in my case, I use the ItemDecoration to decor the sticky headers and spacing between items.

    private val space: Int = parent.context.resources.getDimensionPixelSize(R.dimen._15dp)


    override fun onDrawOver(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) {
        super.onDrawOver(canvas, 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 + parent.paddingTop
        val childInContact = getChildInContact(parent, contactPoint)

        if (childInContact != null && isHeader(parent.getChildAdapterPosition(childInContact))) {
            moveHeader(canvas, headerView, childInContact, parent.paddingTop)
            return
        }
        // withTranslation(...){} is a ext func in AndroidKTX
        canvas.withTranslation(0f, parent.paddingTop.toFloat()) {
            headerView.draw(this)
        }
    }


    override fun getItemOffsets(
        outRect: Rect,
        view: View,
        parent: RecyclerView,
        state: RecyclerView.State
    ) {
        super.getItemOffsets(outRect, view, parent, state)
        with(outRect) {
            val itemPosition = parent.getChildAdapterPosition(view)
            if (itemPosition == RecyclerView.NO_POSITION) {
                return
            }
            if (!isHeader(itemPosition)) {
                left = space
                right = space
            }
            bottom = space
        }
    }


    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 bounds = Rect()
            parent.getDecoratedBoundsWithMargins(child, bounds)
            if (bounds.bottom > contactPoint + space) {
                if (bounds.top <= contactPoint) {
                    // This child overlaps the contactPoint
                    childInContact = child
                    break
                }
            }
        }
        return childInContact
    }

@Madina-S
Copy link

Madina-S commented Apr 16, 2020

If there are two consequent headers, the header below does not wait until the above one moves entirely to the top (both headers are with different sizes). The above header moves partially, then it gets sticked. Then after some scroll the second header gets sticked. How to fix the problem?

@peterdk
Copy link

peterdk commented May 5, 2020

//val topChild = parent.getChildAt(0) ?: return
        val topChild = parent.findChildViewUnder(
            parent.paddingLeft.toFloat(),
            parent.paddingTop.toFloat() /*+ (currentHeader?.second?.itemView?.height ?: 0 )*/
        ) ?: return

I needed to uncomment topChild = parent.getChildAt(0) and comment out the findChildViewUnder line. Without that the header would disappear immediately after the first child and just worked very weird. This fixed it immediately.

@dobrowins
Copy link

Thank you for this!

@pals-ric
Copy link

pals-ric commented Jul 8, 2020

hi @filipkowicz
i have timer in my view holder its getting reset to 0:00 while its sticking to top and after some second when i scroll more header is getting vanished.

@pals-ric
Copy link

pals-ric commented Jul 9, 2020

//val topChild = parent.getChildAt(0) ?: return
        val topChild = parent.findChildViewUnder(
            parent.paddingLeft.toFloat(),
            parent.paddingTop.toFloat() /*+ (currentHeader?.second?.itemView?.height ?: 0 )*/
        ) ?: return

I needed to uncomment topChild = parent.getChildAt(0) and comment out the findChildViewUnder line. Without that the header would disappear immediately after the first child and just worked very weird. This fixed it immediately.

worked for me .. header issues ..

@fjr619
Copy link

fjr619 commented Jul 12, 2020

how to make sticky header clickable?

@filipkowicz
Copy link
Author

@fjr619 it should already be clickable - please make sure you have onclick method overriden - see line 41

@fjr619
Copy link

fjr619 commented Jul 14, 2020

@filipkowicz sorry to make sure, how to override onclick? is it in our fragment/acitivty or in this headeritemdecoration? because i already have onclicklistener for my header view holder, but it didnt trigger in our sticky header, but it trigger in normal header

@ashu2451990
Copy link

ashu2451990 commented Jul 24, 2020

Hi.. I need to add this Itemdecorated for recycler view which is already having headers. please help me .. I am new in android developement

@pals-ric
Copy link

i have changed color problematically but in header neighter timer is updating nor background color has been changed. its creating dummy view in header without any update which i am manipulating run time .. plz suggest changes will be very helpful

@geovannyAndrew
Copy link

geovannyAndrew commented Aug 20, 2020

It is fine, but when there is click listener in the header and scrolls up it stops working :(

@Nikunj2505
Copy link

How to do double sticky header in this? I mean header with sub header?

@filipkowicz
Copy link
Author

@Nikunj2505

How to do double sticky header in this? I mean header with sub header?

no, you can get take a look and do it kind of same way for subheaders but it will be much more complicated.
For such complicated case I would consider using CordinatorLayout with own behaviours.

@Nikunj2505
Copy link

@Nikunj2505

How to do double sticky header in this? I mean header with sub header?

no, you can get take a look and do it kind of same way for subheaders but it will be much more complicated.
For such complicated case I would consider using CordinatorLayout with own behaviours.

Can you please guide me more or can you please create class to make this type of functionality?
I got below references but its deprecated and hard to maintain
https://github.com/Kenber/DoubleStickyHeadersList
https://github.com/ebarrenechea/header-decor

@ManuelEMR
Copy link

I have a header with a button in it, I'm not being able to trigger the onClickListener of the button, how should I forward the motion event intercepted by the touchListener to the headerView itself?
I tried doing:

override fun onTouchEvent(rv: RecyclerView, e: MotionEvent) {
                super.onTouchEvent(rv, e)
                currentHeader?.second?.itemView?.dispatchTouchEvent(e)
            }

But it doesn't work

@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?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment