Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
Item Decorator for sticky headers in Kotlin
/*
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
Source:
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.recyclerview.widget.RecyclerView
class HeaderItemDecoration(
parent: RecyclerView,
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 == 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 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 (isHeader(parent.getChildAdapterPosition(childInContact))) {
moveHeader(c, headerView, childInContact)
return
}
drawHeader(c, headerView)
}
private fun getHeaderViewForItem(itemPosition: Int, parent: RecyclerView): View? {
if (parent.adapter == null) {
return null
}
val headerPosition = getHeaderPositionForItem(itemPosition)
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) {
c.save()
c.translate(0f, 0f)
header.draw(c)
c.restore()
}
private fun moveHeader(c: Canvas, currentHeader: View, nextHeader: View) {
c.save()
c.translate(0f, (nextHeader.top - currentHeader.height).toFloat())
currentHeader.draw(c)
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 = 0
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, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom -> action(view) }
}
@leodeleon22

This comment has been minimized.

Copy link

leodeleon22 commented Apr 16, 2019

Works like a charm ✌️

@codefury

This comment has been minimized.

Copy link

codefury commented May 18, 2019

Please show adapter implementation for this and how to addItemDecoration?

@filipkowicz

This comment has been minimized.

Copy link
Owner Author

filipkowicz commented Jul 2, 2019

@codefury my adapter implementation will not be usefull as I usually use autogenerated dagger adapters but normal regular adapter which will show headers correctly without being sticky should works here as well

addItemDecoration in Kotlin :

recyclerList.addItemDecoration(HeaderItemDecoration(recyclerList) { itemPosition ->
                    if (itemPosition >= 0 && itemPosition < adapter.itemCount) {
                        // your code to check if item at itemPosition is header
                    } else false
                })
@dkarataiev

This comment has been minimized.

Copy link

dkarataiev commented Jul 2, 2019

Please show adapter implementation for this and how to addItemDecoration?

Something like that:

recyclerView.addItemDecoration(HeaderItemDecoration(recyclerView, isHeader()))
    private fun isHeader(): (itemPosition: Int) -> Boolean {
        return {
            (recyclerView.adapter as yourAdapter).data[it].type == HEADER
        }
    }

And a standard adapter with overwritten: onBindViewHolder, getItemViewType and onCreateViewHolder.

@vburovsoftermii

This comment has been minimized.

Copy link

vburovsoftermii commented Jul 10, 2019

I can't figured out how I can to set bottom padding to header view.

@filipkowicz

This comment has been minimized.

Copy link
Owner Author

filipkowicz commented Jul 12, 2019

@vburovsoftermii could you provide some design, can't understand where you want to have padding in header (as far as header is placed on Top of recycler)

@Timoteohss

This comment has been minimized.

Copy link

Timoteohss commented Sep 30, 2019

Works perfectly, but why are my headers half-transparent?

@frakc

This comment has been minimized.

Copy link

frakc commented Oct 1, 2019

@Timoteohss

Works perfectly, but why are my headers half-transparent?

did you set background for your header view? ;)

@xBlackCat

This comment has been minimized.

Copy link

xBlackCat commented Oct 10, 2019

Is there exist some way to handle stick header events in usual way? I mean stick header don't handle onClick events of its children or itself.

@Timoteohss

This comment has been minimized.

Copy link

Timoteohss commented Oct 15, 2019

@Timoteohss

Works perfectly, but why are my headers half-transparent?

did you set background for your header view? ;)

That was it, I'm kinda stupid lol

@filipkowicz

This comment has been minimized.

Copy link
Owner Author

filipkowicz commented Oct 15, 2019

Is there exist some way to handle stick header events in usual way? I mean stick header don't handle onClick events of its children or itself.

check line 41.
// handle click on sticky header -> here you should decide if you want to handle it by yourself.

@xBlackCat

This comment has been minimized.

Copy link

xBlackCat commented Oct 15, 2019

check line 41.

Yep. This code just handle whole view click but any already registered onClick listeners of the item are not processed. I've tried to pass touch event to 'dispatchOnTouch' method of the item with no luck either.

I mean if the header view is complex layout with several clickable elements they will be ignored until header view is scrolled down into the list

@filipkowicz

This comment has been minimized.

Copy link
Owner Author

filipkowicz commented Oct 16, 2019

@xBlackCat you need to add implementation for onTouchEvent in simple RecyclerView.SimpleOnItemTouchListener and pass event to your header there. Or override Recycler view and dispatchTouchEvent method

@RuslanNelipa

This comment has been minimized.

Copy link

RuslanNelipa commented Nov 6, 2019

Works nice except case when first element is NOT sticky header. It draws first item as sticky then. @filipkowicz can you please suggest a solution?
I mean listof [item, header, item, item...]

I made a possible fix inside onDrawOver(). Added
if (topChildPosition == 0 && !isHeader(0)) return
But that is rather a workaround and looks terrible

@filipkowicz

This comment has been minimized.

Copy link
Owner Author

filipkowicz commented Nov 6, 2019

@RuslanNelipa Please take a look at getHeaderPositionForItem method. headerPosition should be initialised with -1 and then check preformed if value is 0 or bigger. If not, do not proceed with drawing etc.. Will add fix tomorrow. Currently I’m writing from phone so sorry for formatting

@RuslanNelipa

This comment has been minimized.

Copy link

RuslanNelipa commented Nov 14, 2019

so inside getHeaderPositionForItem() make var headerPosition = -1
and inside getHeaderViewForItem() add if (headerPosition < 0) return null
and it works now

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
You can’t perform that action at this time.