티스토리 뷰
StickyHeader + RecyclerView
build.gradle (Module: app)
dependencies {
// RecyclerView
implementation 'androidx.recyclerview:recyclerview:1.1.0'
}
activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:context=".MainActivity">
<TextView
android:layout_width="match_parent"
android:layout_height="80dp"
android:text="StickyHeader + RecyclerView"
android:gravity="center"
android:textSize="14dp"
android:textStyle="bold|italic"
android:background="#77FF"/>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvSticky"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_margin="20dp"
android:layout_marginTop="20dp"
android:overScrollMode="never"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" />
</LinearLayout>
sticky_item.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:id="@+id/tvCountry"
android:layout_width="match_parent"
android:layout_height="80dp"
android:text="Country"
android:textSize="30dp"
android:textColor="#FFFFFF"
android:textStyle="bold|italic"
android:gravity="center|left"
android:padding="20dp"
android:background="#000000"/>
</LinearLayout>
normal_item.xml
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="10dp"
android:background="#BBBBBB">
<TextView
android:id="@+id/tvName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:hint="James Kim"
android:textSize="30dp"
android:textStyle="bold"
android:layout_alignParentLeft="true"
android:layout_marginLeft="50dp"/>
<TextView
android:id="@+id/tvPhone"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:hint="010-1234-5678"
android:textSize="22dp"
android:textStyle="italic"
android:layout_below="@+id/tvName"
android:layout_marginLeft="50dp"/>
<TextView
android:id="@+id/tvAge"
android:layout_width="65dp"
android:layout_height="65dp"
android:hint="30"
android:textSize="30dp"
android:gravity="center"
android:layout_alignParentRight="true"
android:layout_centerVertical="true"
android:layout_marginRight="10dp"
android:background="#999999"
/>
</RelativeLayout>
StickyItem.kt
package com.jwsoft.kotlinproject
data class StickyItem (
var country: String,
var normalItem: NormalItem,
var isSticky: Boolean
)
NormalItem.kt
package com.jwsoft.kotlinproject
data class NormalItem (
var name: String,
var phone: String,
var age: Int
)
Utils.kt
package com.jwsoft.kotlinproject
import android.content.res.Resources
fun dpToPx(dp: Float): Int {
return (dp * Resources.getSystem().displayMetrics.density).toInt()
}
StickyHeader.kt
package com.jwsoft.kotlinproject
/**
* This interface must be implemented by the RecyclerView.ViewHolder.
*
* The returned stickyId **must** be unique for the set of StickyHeaders, and should be the same for
* every call.
*
* For example if the StickyHeader represents a date, it is appropriate to return the timestamp or String
* representation of that date
*
*/
interface StickyHeader {
val stickyId: Any
}
StickyHeaderDecoration.kt
package com.jwsoft.kotlinproject
import android.graphics.Canvas
import android.util.Log
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import java.util.*
/**
* RecyclerView item decoration that draws sticky headers over the content. The only thing that has to be done is to
* implement the [StickyHeader] interface in the [RecyclerView.ViewHolder]s that have to become sticky.
*
* Only one sticky header at a time is supported for now.
*
* **Note** When removal of the [StickyHeaderDecoration] is needed, **always** use [StickyHeaderDecoration.release]
* to clear touch and scroll listeners needed for the sticky creation to work
*
* Although not necessary usage of DiverseRecyclerAdapter simplifies the creation of sticky headers.
*/
class StickyHeaderDecoration : RecyclerView.ItemDecoration() {
private val stickyHeadersMap: MutableMap<Any, RecyclerView.ViewHolder?> = linkedMapOf()
// Used for optimisation, creating new instances during draw calls is dangerous
private val stickyOffsets: MutableMap<Any, Int> = linkedMapOf()
private val adapterPositionsMap: MutableMap<Any, Int> = linkedMapOf()
private val onScrollListener: RecyclerView.OnScrollListener = OnScrollListener()
private val onItemTouchListener: ItemTouchListener = ItemTouchListener()
private var adapterObserver: AdapterObserver? = null
private var recyclerView: RecyclerView? = null
private var adapter: RecyclerView.Adapter<*>? = null
private var stickyItemViewType = -1
private var currentStickyId: Any? = null
private var scrollDeltaY: Int = 0
private val stickiesStack = Stack<Any>()
/**
* Call this when you need to update the sticky headers without notifying the [RecyclerView.Adapter]
* for changes
*/
fun updateStickyHeaders() {
stickyHeadersMap.forEach { entry: Map.Entry<Any, RecyclerView.ViewHolder?> ->
val viewHolder = entry.value ?: return@forEach
val adapterPosition = adapterPositionsMap[entry.key] ?: return@forEach
try {
updateStickyHeader(viewHolder, adapterPosition)
} catch (e: ClassCastException) {
Log.e(javaClass.simpleName, e.message)
}
}
}
/**
* Clears the sticky headers, not that they will not be redrawn until they appear on screen again
*/
fun clear() {
stickyHeadersMap.clear()
stickyOffsets.clear()
adapterPositionsMap.clear()
stickiesStack.clear()
}
override fun onDrawOver(canvas: Canvas, recyclerView: RecyclerView, state: RecyclerView.State) {
if (this.recyclerView == null) {
this.recyclerView = recyclerView
}
if (adapter !== recyclerView.adapter) {
if (adapter != null) {
release()
}
adapter = recyclerView.adapter
registerListeners()
}
stickyOffsets.clear()
// Reversed order to try to catch views that can disappear before we reach them
((recyclerView.childCount - 1) downTo 0).asSequence()
// Index to View
.map { recyclerView.getChildAt(it) }
// We don't need null views
.filterNotNull()
// Pair the view to its ViewHolder and try to cast to StickyHeader
.map { it to recyclerView.findContainingViewHolder(it) as? StickyHeader }
.forEach { viewToViewHolderPair ->
val view = viewToViewHolderPair.first
val stickyHeaderViewHolder = viewToViewHolderPair.second ?: return@forEach
val stickyId = stickyHeaderViewHolder.stickyId
val viewTop = view.top
stickyOffsets[stickyId] = view.top
val adapterPosition = (stickyHeaderViewHolder as RecyclerView.ViewHolder).adapterPosition
// New Sticky incoming
if (viewTop < STICKY_THRESHOLD || (scrollDeltaY > viewTop && recyclerView.canScrollVertically(1))) {
if (stickiesStack.isEmpty() || stickiesStack.peek() != stickyId) {
stickiesStack.push(stickyId)
}
if (adapterPosition != -1) {
createOrUpdateStickyHeader(view, stickyHeaderViewHolder, recyclerView)
}
} else if (stickiesStack.isNotEmpty() && stickiesStack.peek() == stickyId && viewTop >= 0) {
stickiesStack.pop()
}
currentStickyId = if (stickiesStack.isNotEmpty()) stickiesStack.peek() else null
if (adapterPosition != -1) {
adapterPositionsMap[stickyId] = adapterPosition
}
}
currentStickyId?.let { currentStickyId ->
val currentStickyView = getStickyView(currentStickyId) ?: return
val currentStickyHeight = currentStickyView.height
val candidateTop = stickyOffsets.asSequence().lastOrNull { it.value in 0..currentStickyHeight }
if (candidateTop != null) {
val currentMargin = (currentStickyHeight - candidateTop.value).toFloat()
if (currentMargin < currentStickyHeight) {
canvas.translate(0f, -currentMargin)
}
}
currentStickyView.draw(canvas)
}
}
private fun registerListeners() {
recyclerView?.apply {
addOnItemTouchListener(onItemTouchListener)
addOnScrollListener(onScrollListener)
}
adapter?.apply {
if (adapterObserver == null) {
registerAdapterDataObserver(AdapterObserver().also {
adapterObserver = it
})
}
}
}
private fun getStickyView(stickyId: Any): View? = stickyHeadersMap[stickyId]?.itemView
private fun createOrUpdateStickyHeader(stickyView: View, stickyViewHolder: StickyHeader, recyclerView: RecyclerView) {
val stickyId = stickyViewHolder.stickyId
val stickyItemViewType = (stickyViewHolder as RecyclerView.ViewHolder).itemViewType
this.stickyItemViewType = stickyItemViewType
val adapterPosition = (stickyViewHolder as RecyclerView.ViewHolder).adapterPosition
stickyHeadersMap.getOrPut(stickyId) {
val adapter = recyclerView.adapter!!
val newStickyViewHolder = adapter.onCreateViewHolder(recyclerView, stickyItemViewType)
adapter.onBindViewHolder(newStickyViewHolder, adapterPosition)
val widthSpec = View.MeasureSpec.makeMeasureSpec(recyclerView.measuredWidth, View.MeasureSpec.EXACTLY)
val heightSpec = View.MeasureSpec.makeMeasureSpec(recyclerView.measuredHeight, View.MeasureSpec.UNSPECIFIED)
val viewWidth = ViewGroup.getChildMeasureSpec(widthSpec,
recyclerView.paddingLeft + recyclerView.paddingRight, stickyView.layoutParams?.width
?: 0)
val viewHeight = ViewGroup.getChildMeasureSpec(heightSpec,
recyclerView.paddingTop + recyclerView.paddingBottom, stickyView.layoutParams?.height
?: 0)
val newStickyItemView = newStickyViewHolder.itemView
newStickyItemView.measure(viewWidth, viewHeight)
newStickyItemView.layout(0, 0, newStickyItemView.measuredWidth, newStickyItemView.measuredHeight)
newStickyViewHolder
}
}
private fun updateStickyHeader(viewHolder: RecyclerView.ViewHolder, adapterPosition: Int) {
val recyclerView = this.recyclerView ?: return
val adapter = recyclerView.adapter ?: return
val viewHolderByPosition = recyclerView.findViewHolderForAdapterPosition(adapterPosition)
if (viewHolderByPosition !is StickyHeader) {
return
}
if (viewHolderByPosition.stickyId == (viewHolder as StickyHeader).stickyId) {
adapter.onBindViewHolder(viewHolder, adapterPosition)
}
}
private inner class ItemTouchListener : RecyclerView.SimpleOnItemTouchListener() {
override fun onInterceptTouchEvent(recyclerView: RecyclerView, event: MotionEvent): Boolean {
if (event.action == MotionEvent.ACTION_MOVE ||
(event.action == MotionEvent.ACTION_UP && recyclerView.scrollState == RecyclerView.SCROLL_STATE_DRAGGING)) {
return false
}
currentStickyId?.let {
val currentStickyViewHeight = getStickyView(it)?.height ?: 0
val eventY = event.y
return eventY <= currentStickyViewHeight
}
return false
}
}
private inner class OnScrollListener : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
scrollDeltaY = dy
}
}
private inner class AdapterObserver : RecyclerView.AdapterDataObserver() {
override fun onItemRangeChanged(positionStart: Int, itemCount: Int) {
val changedRange = positionStart..(positionStart + itemCount)
stickyHeadersMap.forEach { entry ->
val viewHolder = entry.value ?: return@forEach
val adapterPosition = adapterPositionsMap[entry.key] ?: return@forEach
if (adapterPosition in changedRange) {
updateStickyHeader(viewHolder, adapterPosition)
}
}
updateStickyHeaders()
}
override fun onItemRangeRemoved(positionStart: Int, itemCount: Int) {
stickyHeadersMap.forEach { entry ->
val adapterPosition = adapterPositionsMap[entry.key] ?: return@forEach
if (positionStart <= adapterPosition) {
adapterPositionsMap[entry.key] = adapterPosition - itemCount
}
}
updateStickyHeaders()
}
override fun onItemRangeMoved(fromPosition: Int, toPosition: Int, itemCount: Int) {
stickyHeadersMap.forEach { entry ->
val adapterPosition = adapterPositionsMap[entry.key] ?: return@forEach
// onItemRangeMoved implementation of RecyclerView doesn't support moves for itemCount > 1
if (adapterPosition == fromPosition) {
adapterPositionsMap[entry.key] = toPosition
}
}
updateStickyHeaders()
}
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
stickyHeadersMap.forEach { entry ->
val adapterPosition = adapterPositionsMap[entry.key] ?: return@forEach
if (positionStart <= adapterPosition) {
adapterPositionsMap[entry.key] = adapterPosition + itemCount
}
}
updateStickyHeaders()
}
override fun onChanged() {
clear()
updateStickyHeaders()
}
}
companion object {
private val STICKY_THRESHOLD = dpToPx(2f)
/**
* Use this to remove the [StickyHeaderDecoration] from the [recyclerView], also clears [RecyclerView] listeners
* previously set.
*/
@JvmStatic
fun StickyHeaderDecoration.release() {
recyclerView?.also {
it.removeItemDecoration(this)
it.removeOnScrollListener(this.onScrollListener)
it.removeOnItemTouchListener(this.onItemTouchListener)
val adapterObserver = this.adapterObserver ?: return
it.adapter?.unregisterAdapterDataObserver(adapterObserver)
}
}
}
}
Adapter.kt
package com.jwsoft.kotlinproject
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
class Adapter(var items: List<StickyItem>) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
companion object {
val STICKY_TYPE = 0
val NORMAL_TYPE = 1
}
inner class StickyViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView), StickyHeader {
var tvCountry = itemView.findViewById<TextView>(R.id.tvCountry)
fun bind(position: Int) {
tvCountry.text = items[position].country
}
override val stickyId: String
get() = items[adapterPosition].country
}
inner class NormalViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
var tvName = itemView.findViewById<TextView>(R.id.tvName)
var tvPhone = itemView.findViewById<TextView>(R.id.tvPhone)
var tvAge = itemView.findViewById<TextView>(R.id.tvAge)
fun bind(position: Int) {
tvName.text = items[position].normalItem.name
tvPhone.text = items[position].normalItem.phone
tvAge.text = items[position].normalItem.age.toString()
}
}
override fun getItemViewType(position: Int): Int {
return if (items[position].isSticky) {
STICKY_TYPE
} else {
NORMAL_TYPE
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val context = parent.context
val inflater = context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater
return if (viewType == STICKY_TYPE) {
StickyViewHolder(inflater.inflate(R.layout.sticky_item, parent, false))
} else {
NormalViewHolder(inflater.inflate(R.layout.normal_item, parent, false))
}
}
override fun getItemCount(): Int {
return items.size
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
if (holder is NormalViewHolder) {
holder.bind(position)
return
}
if (holder is StickyViewHolder) {
holder.bind(position)
}
}
}
MainActivity.kt
package com.jwsoft.kotlinproject
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.widget.LinearLayout
import androidx.recyclerview.widget.DividerItemDecoration
import kotlinx.android.synthetic.main.activity_main.*
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
rvSticky.adapter = Adapter(getItems())
rvSticky.addItemDecoration(StickyHeaderDecoration())
rvSticky.addItemDecoration(DividerItemDecoration(this, LinearLayout.VERTICAL))
}
fun getItems(): ArrayList<StickyItem> {
val items: ArrayList<StickyItem> = ArrayList()
items.add(StickyItem("Korea", NormalItem("James Kim", "010-1234-5678", 30), true))
for (i in 1..10) {
items.add(StickyItem("", NormalItem("James Kim", "010-1234-5678", 30), false))
}
items.add(StickyItem("China", NormalItem("Kevin Jang", "010-1234-5678", 30), true))
for (i in 1..10) {
items.add(StickyItem("", NormalItem("Kevin Jang", "010-1234-5678", 30), false))
}
items.add(StickyItem("Japan", NormalItem("Jason Park", "010-1234-5678", 30), true))
for (i in 1..10) {
items.add(StickyItem("", NormalItem("Jason Park", "010-1234-5678", 30), false))
}
return items
}
}
'Android > Kotlin' 카테고리의 다른 글
[Kotlin] SeekBar (0) | 2020.08.15 |
---|---|
[Kotlin] @JvmOverloads + CustomView (0) | 2020.08.14 |
[Kotlin] @JvmOverloads (0) | 2020.08.12 |
[Kotlin] Glide (0) | 2020.08.10 |
[Kotlin] NestedScrollView (0) | 2020.08.10 |
댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
- Total
- Today
- Yesterday
링크
TAG
- 안드로이드 #코틀린 #Android #Kotlin
- ViewPager2
- fragment
- JSONObject
- ViewModel
- Architecture Pattern
- ArrayList
- MVVM
- View
- java
- CoordinatorLayout
- activity
- Livedata
- Vue.js #Vue.js + javascript
- Kotlin
- JSONArray
- Intent
- TabLayout
- recyclerview
- XML
- DataBinding
- handler
- coroutine
- Design Pattern
- 코틀린
- 안드로이드
- Android
- 혀가 길지 않은 개발자
- James Kim
- 자바
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | |||
5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 | 27 | 28 | 29 | 30 | 31 |
글 보관함