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