티스토리 뷰

Android/Kotlin

[Kotlin]  StickyHeader  +  RecyclerView

혀가 길지 않은 개발자 2020. 8. 14. 14:23

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>

activity_main.xml

 

 

 

 

 

 

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>

sticky_item.xml

 

 

 

 

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>

normal_item.xml

 

 

 

 

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
링크
«   2024/05   »
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
글 보관함