4
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

[Android] RecyclerViewを用いてSticky Headerを実装する

Last updated at Posted at 2021-08-05

##はじめに
RecyclerViewとItemDecorationクラスを用いて、Sticky Headerを実装します。

Epoxyなどのライブラリを利用することで、Sticky Headerの実装は可能ですが、今回は使い慣れてるRecyclerViewを用いて実装します。
ライブラリを使うより、描画のロジックが分かりやすいため、できればこちらの方法で実装することをお勧めします。

理解して頂くためにも、ソースコードにはなるべくコメント残していますので、最後まで見て頂ける嬉しいです。

ソースコードはGitHubに上げております。

##そもそもSticky Headerとは?
Sticky Headerとはこのような動きをするリストです。
2021-08-01_22_24_09.gif

## 全体の流れ

RecyclerViewとRecyclerViewのItemDecorationクラスを用いて実装します。

  1. 各リストを管理するItemクラスを作成
  2. StickyHeader描画用のインターフェースの定義
  • RecyclerViewのAdapterクラスに上記インターフェースを継承させる
  • Adapterクラスのインスタンスから、RecyclerViewのItemDecorationを作成し、ItemDecorationのonDrawOverメソッド内で、Sticky Headerの描画ロジックを作成
  • RecyclerViewに上記のItemDecorationをセット

以上が全体の流れになります。

# 実装
まずは各リストを管理するItemクラスを作成します。
このItemクラスでは日付を管理するString型のdateと、Headerかどうかを判断するBoolean型のisHeaderの二つの変数を持たせます。

Item.kt
data class Item(var date: String, var isHeader: Boolean)

通常のRecyclerViewの実装から始める

一度に書いてしまうと混乱するため、まずは通常のRecyclerViewの実装します。

MainActivity

MainActivityではAdapterのセットと、日付を格納したリストを作成します。

MainActivity.kt
class MainActivity : AppCompatActivity() {

    companion object {
    const val STICKY_HEADER_SAMPLE = "STICKY_HEADER_SAMPLE"
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val recyclerView = findViewById<RecyclerView>(R.id.recyclerView)

        // adapterのセット
        val adapter =  MyAdapter(makeItems())
        recyclerView.adapter = adapter
    }


    // 2021年5月1日から今日までの日付を格納したリストを作成
    // 10の倍数をStickyHeaderとする
    private fun makeItems(): MutableList<Item>{
        val list = mutableListOf<Item>()

        // 現在の日付を元にカレンダーのインスタンスを取得
        val calender = Calendar.getInstance()

        // 2021年5月1日
        val pastCalender = Calendar.getInstance().apply {
            set(2021,5,1)
        }

        while (calender.after(pastCalender)){
            val day = calender.get(Calendar.DATE)
            val month = calender.get(Calendar.MONTH).plus(1)
            val date = "${month}月${day}日"

            // 10の倍数であればSticky Headerとする
            val isHeader = (day % 10) == 0

            val item = Item(date = date, isHeader = isHeader)
            list.add(item)

            calender.add(Calendar.DATE,-1)
        }

        return list
    }
}
activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
    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"
    tools:context=".MainActivity">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recyclerView"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"/>

</androidx.constraintlayout.widget.ConstraintLayout>

Adapter

Headerタイプと通常タイプの2種類のレイアウトを使用しています。

MyAdapter.kt
class MyAdapter(private val mItemList: List<Item>) : RecyclerView.Adapter<MyAdapter.ParentViewHolder>(){

    enum class ListStyle(val type: Int){
        HeaderType(0),
        NormalType(1)
    }

    // ViewHolder機構(親クラス)
    open class ParentViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView)

    // ViewHolder機構 Header Type(子クラス)
    class HeaderViewHolder(view: View) : MyAdapter.ParentViewHolder(view) {
         val headerDate: TextView = view.findViewById(R.id.headerDate)
    }

    // ViewHolder機構 Normal type(子クラス)
    class NormalViewHolder(view: View) : MyAdapter.ParentViewHolder(view) {
         val date: TextView = view.findViewById(R.id.date)
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ParentViewHolder {
         return when(viewType){
            // HeaderType
            ListStyle.HeaderType.type -> {
                val view =  LayoutInflater.from(parent.context).inflate(R.layout.header_layout, parent,false)
                HeaderViewHolder(view)
            }
            // NormalType
            else -> {
                val view =  LayoutInflater.from(parent.context).inflate(R.layout.normal_layout, parent,false)
                NormalViewHolder(view)
            }
        }
    }

    override fun onBindViewHolder(holder: ParentViewHolder, position: Int) {
        val item = mItemList[position]
        when(holder){
            // HeaderType
            is HeaderViewHolder -> {
                holder.headerDate.text =  item.date
            }
            // NormalType
            is  NormalViewHolder -> {
                holder.date.text = item.date
            }
        }
    }

    override fun getItemCount(): Int = mItemList.size

    override fun getItemViewType(position: Int): Int {
        val item = mItemList[position]
        return if(item.isHeader){
            ListStyle.HeaderType.type
        }else{
            ListStyle.NormalType.type
        }
    }
}
header_layout.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
    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="wrap_content"
    tools:context=".MainActivity"
    android:background="#eee"
    android:paddingTop="25dp"
    android:paddingBottom="25dp">

    <TextView
        android:id="@+id/headerDate"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:textColor="@color/black"
        android:textSize="18sp"
        android:textStyle="bold"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>
normal_layout.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
    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="wrap_content"
    android:background="@drawable/normal_layout_bg"
    tools:context=".MainActivity"
    android:paddingTop="25dp"
    android:paddingBottom="25dp">

    <TextView
        android:id="@+id/date"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:textColor="@color/black"
        android:textSize="18sp"
        android:textStyle="bold"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

以上が通常のRecyclerViewの実装です。
10の倍数の日付のセルに着色させた、至って普通のリストですね。

こんな感じでできました。
2021-08-01_23_00_45.gif

では次にこのヘッダー用のセルをSticky Headerへと変えて行きます。

まずRecycleraViewのItemDecorationクラスの作成とSticky Headerの作成に必要なメソッドを定義したンターフェースを作成します。

StickyHeaderHandlerインターフェースの作成

StickyHeaderHandler.kt
interface StickyHeaderHandler {
    companion object {
        const val HEADER_POSITION_NOT_FOUND = -1
    }

    /** StickyHeaderのポジションを返す */
    fun getHeaderPosition(itemPosition: Int): Int
    /** StickyHeaderのレイアウトIDを返す */
    fun getHeaderLayout(headerPosition: Int): Int
    /** StickyHeaderにデーターを渡す */
    fun bindHeaderData(header: View?, headerPosition: Int)
    /** リストがヘッダーかどうかを判定する */
    fun isHeader(itemPosition: Int): Boolean
}

実装先である、Adapterクラスにて具体的な処理内容を記述します。

MyAdapter.kt
class MyAdapter(private val mItemList: List<Item>) : RecyclerView.Adapter<MyAdapter.ParentViewHolder>(),
StickyHeaderHandler  // 追加{ 

    enum class ListStyle(val type: Int){
        HeaderType(0),
        NormalType(1)
    }

    // ViewHolder機構(親クラス)
    open class ParentViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView)

    // ViewHolder機構 Header Type(子クラス)
    class HeaderViewHolder(view: View) : MyAdapter.ParentViewHolder(view) {
         val headerDate: TextView = view.findViewById(R.id.headerDate)
    }

    // ViewHolder機構 Normal type(子クラス)
    class NormalViewHolder(view: View) : MyAdapter.ParentViewHolder(view) {
         val date: TextView = view.findViewById(R.id.date)
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ParentViewHolder {
         return when(viewType){
            // HeaderType
            ListStyle.HeaderType.type -> {
                val view =  LayoutInflater.from(parent.context).inflate(R.layout.header_layout, parent,false)
                HeaderViewHolder(view)
            }
            // NormalType
            else -> {
                val view =  LayoutInflater.from(parent.context).inflate(R.layout.normal_layout, parent,false)
                NormalViewHolder(view)
            }
        }
    }


    override fun onBindViewHolder(holder: ParentViewHolder, position: Int) {
        val item = mItemList[position]
        when(holder){
            // HeaderType
            is HeaderViewHolder -> {
                holder.headerDate.text =  item.date
            }
            // NormalType
            is  NormalViewHolder -> {
                holder.date.text = item.date
            }
        }
    }

    override fun getItemCount(): Int = mItemList.size

    override fun getItemViewType(position: Int): Int {
        val item = mItemList[position]
        return if(item.isHeader){
            ListStyle.HeaderType.type
        }else{
            ListStyle.NormalType.type
        }
    }

     // ここから下を追加 ---------

    // 表示中の一番上のセルからリストの先頭(一番上)まで遡り、ヘッダーがあればそのインデックスを、なければ -1を返す
    override fun getHeaderPosition(itemPosition: Int): Int {
        var headerPosition = StickyHeaderHandler.HEADER_POSITION_NOT_FOUND

        var targetItemPosition = itemPosition
        do {
            if (isHeader(targetItemPosition)) {
                headerPosition = targetItemPosition
                break
            }
            targetItemPosition -= 1
        } while (targetItemPosition >= 0)
        return headerPosition
    }

    // Header用のレイアウトを返す
    override fun getHeaderLayout(headerPosition: Int): Int {
        return R.layout.header_layout
    }

    // Headerセルにてデータをbindする
    override fun bindHeaderData(header: View?, headerPosition: Int) {
        header?:return

        val headerItem = mItemList[headerPosition]
        if (headerItem.isHeader) {
            val headerDate = header.findViewById(R.id.headerDate) as TextView
            headerDate.text = headerItem.date
        }
    }

    override fun isHeader(itemPosition: Int): Boolean {
        val item = mItemList[itemPosition]
        return item.isHeader
    }
}

ItemDecorationクラスの実装

このItemDecorationクラス内で先ほど作成したインターフェースのメソッドを使い、Sticky Headerの描画を行います。

何をしてるかざっくり解説

・画面上にSticky Headerが既に描画されている場合
そのSticky Headerの次のセル(下のセル)がSticky Headerタイプのセルかどうかを判定し、Sticky Headerタイプであれば既存のSticky Headeを押し上げ、新たなSticky Headerを描画。Sticky Headerタイプでなければ、既存のSticky Headerをそのまま同じ位置で最描画。
・画面上にSticky Headerが描画されていない場合
表示中の一番上のセルがSticky Headerタイプであれば、そのセルを新たにSticky Headerとして描画。通常のセルであれば何もしない。
MyItemDecoration.kt
class MyItemDecoration(private val mStickyHeaderListener: StickyHeaderHandler) : RecyclerView.ItemDecoration() {

    // Header View
    private var mCurrentHeaderView: View? = null

    // RecyclerViewのセルが表示される度に呼ばれる
    override fun onDrawOver(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
        super.onDrawOver(c, parent, state)

        // 表示中のリスト内のトップViewを取得
        val topChildView: View = parent.getChildAt(0)?:return

        // topChildViewのインデックス
        val topChildViewPosition = parent.getChildAdapterPosition(topChildView)

        // topChildViewPosition取得失敗時はこれ以降の処理を行わない
        if (topChildViewPosition == RecyclerView.NO_POSITION) return

        // 直近のHeaderPositionのインデックスを取得
        val prevHeaderPosition = mStickyHeaderListener.getHeaderPosition(topChildViewPosition)

        // 直近にHeaderが存在しない場合はこれ以降の処理を行わない
        if(prevHeaderPosition == StickyHeaderHandler.HEADER_POSITION_NOT_FOUND) return

        // 直近にHeaderが存在する → topChildViewPositionにHeaderが存在する
        mCurrentHeaderView = getHeaderView(topChildViewPosition, parent)

        // 現在のHeaderレイアウトのセット
        fixLayoutSize(parent, mCurrentHeaderView)

        // 現在のHeaderのBottom Positionを取得 (親Viewからの相対距離)
        val contactPoint = mCurrentHeaderView!!.bottom

        // Headerの次のセルを取得
        val nextCell = getNextCellToHeader(parent, contactPoint) ?: return  // 次のセルがない
        // nextCellのインデックスを取得
        val nextCellPosition = parent.getChildAdapterPosition(nextCell)
        if(nextCellPosition == StickyHeaderHandler.HEADER_POSITION_NOT_FOUND) return

        // nextCellがHeaderかどうかの判定
        if (mStickyHeaderListener.isHeader(nextCellPosition)) {
            // 既存のStickyヘッダーを押し上げる
            moveHeader(c, mCurrentHeaderView, nextCell)
            return
        }

        // Stickyヘッダーの描画
        drawHeader(c, mCurrentHeaderView)
    }

    // HeaderのViewを取得
    private fun getHeaderView(itemPosition: Int, parent: RecyclerView): View? {
        val headerPosition = mStickyHeaderListener.getHeaderPosition(itemPosition)
        val layoutResId = mStickyHeaderListener.getHeaderLayout(headerPosition)
        // Headerレイアウトをinflate
        val headerView = LayoutInflater.from(parent.context).inflate(layoutResId, parent, false)
        // データバインディング
        mStickyHeaderListener.bindHeaderData(headerView, headerPosition)
        return headerView
    }

    // Headerレイアウトのセット
    private fun fixLayoutSize(parent: ViewGroup, headerView: View?) {
        headerView?:return

        // RecyclerViewのSpecを取得
        val widthSpec = View.MeasureSpec.makeMeasureSpec(parent.width, View.MeasureSpec.EXACTLY)
        val heightSpec = View.MeasureSpec.makeMeasureSpec(parent.height, View.MeasureSpec.UNSPECIFIED)

        // Header ViewのSpecを取得
        val headerWidthSpec = ViewGroup.getChildMeasureSpec(
                widthSpec,
                parent.paddingLeft + parent.paddingRight,
                headerView.layoutParams.width
        )
        val headerHeightSpec = ViewGroup.getChildMeasureSpec(
                heightSpec,
                parent.paddingTop + parent.paddingBottom,
                headerView.layoutParams.height
        )
        headerView.measure(headerWidthSpec, headerHeightSpec)
        headerView.layout(0, 0, headerView.measuredWidth, headerView.measuredHeight)
    }


    // Headerの次のセルを取得
    private fun getNextCellToHeader(parent: RecyclerView, contactPoint: Int): View? {
        var nextView: View? = null
        for (index in 0 until parent.childCount) {
            val child = parent.getChildAt(index)
            if (child.bottom > contactPoint) {
                if (child.top <= contactPoint) {
                    nextView = child
                    break
                }
            }
        }
        return nextView
    }

    // Stickyヘッダーを動かす
    private fun moveHeader(c: Canvas, currentHeader: View?, nextCell: View) {
        currentHeader?:return

        c.save()
        c.translate(0F, (nextCell.top - currentHeader.height).toFloat())
        currentHeader.draw(c)
        c.restore()
    }

    // Stickyヘッダーを描画する
    private fun drawHeader(c: Canvas, header: View?) {
        c.save()
        c.translate(0F, 0F)
        header!!.draw(c)
        c.restore()
    }
}

あとは上記のMyItemDecorationクラスをRecyclerViewにセットしてやれば終わりです。

2021-08-01_22_24_09.gif

全てのソースコード

Item.kt
data class Item(var date: String, var isHeader: Boolean)
MainActivity.kt
class MainActivity : AppCompatActivity() {

    companion object {
    const val STICKY_HEADER_SAMPLE = "STICKY_HEADER_SAMPLE"
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val recyclerView = findViewById<RecyclerView>(R.id.recyclerView)

        // adapterのセット
        val adapter =  MyAdapter(makeItems())
        recyclerView.adapter = adapter

        // itemDecorationのセット
        val itemDecoration = MyItemDecoration(adapter)
        recyclerView.addItemDecoration(itemDecoration)
    }


    // 2021年5月1日から今日までの日付を格納したリストを作成
    // 10の倍数をStickyHeaderとする
    private fun makeItems(): MutableList<Item>{
        val list = mutableListOf<Item>()

        // 現在の日付を元にカレンダーのインスタンスを取得
        val calender = Calendar.getInstance()

        // 2021年5月1日
        val pastCalender = Calendar.getInstance().apply {
            set(2021,5,1)
        }

        while (calender.after(pastCalender)){
            val day = calender.get(Calendar.DATE)
            val month = calender.get(Calendar.MONTH).plus(1)
            val date = "${month}月${day}日"

            // 10の倍数であればSticky Headerとする
            val isHeader = (day % 10) == 0

            val item = Item(date = date, isHeader = isHeader)
            list.add(item)

            calender.add(Calendar.DATE,-1)
        }

        return list
    }
}
activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
    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"
    tools:context=".MainActivity">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recyclerView"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"/>

</androidx.constraintlayout.widget.ConstraintLayout>
MyAdapter.kt
class MyAdapter(private val mItemList: List<Item>) : RecyclerView.Adapter<MyAdapter.ParentViewHolder>(),StickyHeaderHandler {

    enum class ListStyle(val type: Int){
        HeaderType(0),
        NormalType(1)
    }

    // ViewHolder機構(親クラス)
    open class ParentViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView)

    // ViewHolder機構 Header Type(子クラス)
    class HeaderViewHolder(view: View) : MyAdapter.ParentViewHolder(view) {
         val headerDate: TextView = view.findViewById(R.id.headerDate)
    }

    // ViewHolder機構 Normal type(子クラス)
    class NormalViewHolder(view: View) : MyAdapter.ParentViewHolder(view) {
         val date: TextView = view.findViewById(R.id.date)
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ParentViewHolder {
         return when(viewType){
            // HeaderType
            ListStyle.HeaderType.type -> {
                val view =  LayoutInflater.from(parent.context).inflate(R.layout.header_layout, parent,false)
                HeaderViewHolder(view)
            }
            // NormalType
            else -> {
                val view =  LayoutInflater.from(parent.context).inflate(R.layout.normal_layout, parent,false)
                NormalViewHolder(view)
            }
        }
    }

    override fun onBindViewHolder(holder: ParentViewHolder, position: Int) {
        val item = mItemList[position]
        when(holder){
            // HeaderType
            is HeaderViewHolder -> {
                holder.headerDate.text =  item.date
            }
            // NormalType
            is  NormalViewHolder -> {
                holder.date.text = item.date
            }
        }
    }

    override fun getItemCount(): Int = mItemList.size

    override fun getItemViewType(position: Int): Int {
        val item = mItemList[position]
        return if(item.isHeader){
            ListStyle.HeaderType.type
        }else{
            ListStyle.NormalType.type
        }
    }

    // 表示中の一番上のセルからリストの先頭(一番上)まで遡り、ヘッダーがあればそのインデックスを、なければ -1を返す
    override fun getHeaderPosition(itemPosition: Int): Int {
        var headerPosition = StickyHeaderHandler.HEADER_POSITION_NOT_FOUND

        var targetItemPosition = itemPosition
        do {
            if (isHeader(targetItemPosition)) {
                headerPosition = targetItemPosition
                break
            }
            targetItemPosition -= 1
        } while (targetItemPosition >= 0)
        return headerPosition
    }

    // Header用のレイアウトを返す
    override fun getHeaderLayout(headerPosition: Int): Int {
        return R.layout.header_layout
    }

    // Headerセルにてデータをbindする
    override fun bindHeaderData(header: View?, headerPosition: Int) {
        header?:return

        val headerItem = mItemList[headerPosition]
        if (headerItem.isHeader) {
            val headerDate = header.findViewById(R.id.headerDate) as TextView
            headerDate.text = headerItem.date
        }
    }

    // Headerかどうかの判定
    override fun isHeader(itemPosition: Int): Boolean {
        val item = mItemList[itemPosition]
        return item.isHeader
    }
}
activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
    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"
    tools:context=".MainActivity">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recyclerView"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"/>

</androidx.constraintlayout.widget.ConstraintLayout>
header_layout.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
    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="wrap_content"
    tools:context=".MainActivity"
    android:background="#eee"
    android:paddingTop="25dp"
    android:paddingBottom="25dp">

    <TextView
        android:id="@+id/headerDate"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:textColor="@color/black"
        android:textSize="18sp"
        android:textStyle="bold"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>
normal_layout.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
    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="wrap_content"
    android:background="@drawable/normal_layout_bg"
    tools:context=".MainActivity"
    android:paddingTop="25dp"
    android:paddingBottom="25dp">

    <TextView
        android:id="@+id/date"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:textColor="@color/black"
        android:textSize="18sp"
        android:textStyle="bold"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>
MyItemDecoration.kt
class MyItemDecoration(private val mStickyHeaderListener: StickyHeaderHandler) : RecyclerView.ItemDecoration() {

    // Header View
    private var mCurrentHeaderView: View? = null

    // RecyclerViewのセルが表示される度に呼ばれる
    override fun onDrawOver(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
        super.onDrawOver(c, parent, state)

        // 表示中のリスト内のトップViewを取得
        val topChildView: View = parent.getChildAt(0)?:return

        // topChildViewのインデックス
        val topChildViewPosition = parent.getChildAdapterPosition(topChildView)

        // topChildViewPosition取得失敗時はこれ以降の処理を行わない
        if (topChildViewPosition == RecyclerView.NO_POSITION) return

        // 直近のHeaderPositionのインデックスを取得
        val prevHeaderPosition = mStickyHeaderListener.getHeaderPosition(topChildViewPosition)

        // 直近にHeaderが存在しない場合はこれ以降の処理を行わない
        if(prevHeaderPosition == StickyHeaderHandler.HEADER_POSITION_NOT_FOUND) return

        // 直近にHeaderが存在する → topChildViewPositionにHeaderが存在する
        mCurrentHeaderView = getHeaderView(topChildViewPosition, parent)

        // 現在のHeaderレイアウトのセット
        fixLayoutSize(parent, mCurrentHeaderView)

        // 現在のHeaderのBottom Positionを取得 (親Viewからの相対距離)
        val contactPoint = mCurrentHeaderView!!.bottom

        // Headerの次のセルを取得
        val nextCell = getNextCellToHeader(parent, contactPoint) ?: return  // 次のセルがない
        // nextCellのインデックスを取得
        val nextCellPosition = parent.getChildAdapterPosition(nextCell)
        if(nextCellPosition == StickyHeaderHandler.HEADER_POSITION_NOT_FOUND) return

        // nextCellがHeaderかどうかの判定
        if (mStickyHeaderListener.isHeader(nextCellPosition)) {
            // 既存のStickyヘッダーを押し上げる
            moveHeader(c, mCurrentHeaderView, nextCell)
            return
        }

        // Stickyヘッダーの描画
        drawHeader(c, mCurrentHeaderView)
    }

    // HeaderのViewを取得
    private fun getHeaderView(itemPosition: Int, parent: RecyclerView): View? {
        val headerPosition = mStickyHeaderListener.getHeaderPosition(itemPosition)
        val layoutResId = mStickyHeaderListener.getHeaderLayout(headerPosition)
        // Headerレイアウトをinflate
        val headerView = LayoutInflater.from(parent.context).inflate(layoutResId, parent, false)
        // データバインディング
        mStickyHeaderListener.bindHeaderData(headerView, headerPosition)
        return headerView
    }

    // Headerレイアウトのセット
    private fun fixLayoutSize(parent: ViewGroup, headerView: View?) {
        headerView?:return

        // RecyclerViewのSpecを取得
        val widthSpec = View.MeasureSpec.makeMeasureSpec(parent.width, View.MeasureSpec.EXACTLY)
        val heightSpec = View.MeasureSpec.makeMeasureSpec(parent.height, View.MeasureSpec.UNSPECIFIED)

        // Header ViewのSpecを取得
        val headerWidthSpec = ViewGroup.getChildMeasureSpec(
                widthSpec,
                parent.paddingLeft + parent.paddingRight,
                headerView.layoutParams.width
        )
        val headerHeightSpec = ViewGroup.getChildMeasureSpec(
                heightSpec,
                parent.paddingTop + parent.paddingBottom,
                headerView.layoutParams.height
        )
        headerView.measure(headerWidthSpec, headerHeightSpec)
        headerView.layout(0, 0, headerView.measuredWidth, headerView.measuredHeight)
    }


    // Headerの次のセルを取得
    private fun getNextCellToHeader(parent: RecyclerView, contactPoint: Int): View? {
        var nextView: View? = null
        for (index in 0 until parent.childCount) {
            val child = parent.getChildAt(index)
            if (child.bottom > contactPoint) {
                if (child.top <= contactPoint) {
                    nextView = child
                    break
                }
            }
        }
        return nextView
    }

    // Stickyヘッダーを動かす
    private fun moveHeader(c: Canvas, currentHeader: View?, nextCell: View) {
        currentHeader?:return

        c.save()
        c.translate(0F, (nextCell.top - currentHeader.height).toFloat())
        currentHeader.draw(c)
        c.restore()
    }

    // Stickyヘッダーを描画する
    private fun drawHeader(c: Canvas, header: View?) {
        c.save()
        c.translate(0F, 0F)
        header!!.draw(c)
        c.restore()
    }
}
StickyHeaderHandler.kt
interface StickyHeaderHandler {
    companion object {
        const val HEADER_POSITION_NOT_FOUND = -1
    }

    /** StickyHeaderのポジションを返す */
    fun getHeaderPosition(itemPosition: Int): Int
    /** StickyHeaderのレイアウトIDを返す */
    fun getHeaderLayout(headerPosition: Int): Int
    /** StickyHeaderにデーターを渡す */
    fun bindHeaderData(header: View?, headerPosition: Int)
    /** リストがヘッダーかどうかを判定する */
    fun isHeader(itemPosition: Int): Boolean
}

終わりに

ライブラリを使うより、描画のロジックが分かりやすいはずです。

では!^^

4
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?