Skip to content

Instantly share code, notes, and snippets.

@filipkowicz
Last active April 19, 2024 15:37
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
)
}
}
@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?

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

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