-
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.
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 | |
) | |
} | |
} |
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?
//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.
Thank you for this!
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.
//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 thefindChildViewUnder
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 ..
how to make sticky header clickable?
@fjr619 it should already be clickable - please make sure you have onclick method overriden - see line 41
@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
Hi.. I need to add this Itemdecorated for recycler view which is already having headers. please help me .. I am new in android developement
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
It is fine, but when there is click listener in the header and scrolls up it stops working :(
How to do double sticky header in this? I mean header with sub header?
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.
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
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
Cool
Thank you! Extremely useful!
Thanks!
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.
Hi, has it been glitching for anyone? I'll scroll up and sometimes my items will be in front of the sticky title.
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?
Thanks a lot, very helpful!!
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
@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"
If any one is looking for isHeader implementation, can refer
https://stackoverflow.com/a/33402863/14784590
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.
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.
@TylerMcCraw in my case, I use the ItemDecoration to decor the sticky headers and spacing between items.