(Android × RecyclerView) Epoxy等のライブラリを使用せずSticky Headerを実装する

こんにちは。

投資戦略システム事業部のAndroidエンジニア、中園です。

昨年の10月に中途採用で入社し、そろそろ5ヶ月になります。

今回は業務で用いたSticky Headerについて書かせていただきます。

はじめに

Sticky Headerとは以下のような動きをするヘッダーです。

f:id:teco_nakazono:20210224164656g:plain

Sticky Headerを実装するにあたり、ライブラリをいくつか試してみましたが上手く行きませんでした。

よって今回はライブラリは使用せず、RecyclerViewのItemDecorationクラスを使用します。

まずは通常のRecyclerViewの実装です。 0から100までの整数を表示させるだけの至ってシンプルなRecylerViewです。

また10の倍数のセルをSticky Header用のセルとして着色します。

実装

MainActivity.kt

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

        // RecyclerView用のアイテム取得
        val numberList = makeNumberList()

        val recyclerView = findViewById<RecyclerView>(R.id.recyclerView)
        val adapter = StickyHeaderRecyclerViewAdapter(numberList)
        // adapterのセット
        recyclerView.adapter = adapter
        recyclerView.layoutManager = LinearLayoutManager(this)
        recyclerView.setHasFixedSize(true)
    }
    
    /** RecyclerView用リストの作成 */
    private fun makeNumberList():List<Item>{
        var list = mutableListOf<Item>()
        for(index in 0 .. 100){
            val item = Item()
            // 10の倍数であればSticky Header用のリストとして扱う
            item.isHeader = (index % 10) == 0
            item.number = index
            list.add(item)
        }
        return list
    }
}

StickyHeaderRecyclerViewAdapter.kt (アダプタークラス)

class StickyHeaderRecyclerViewAdapter(private val itemList:List<Item>): RecyclerView.Adapter<StickyHeaderRecyclerViewAdapter.ViewHolder>() {

     /** 使用するレイアウトタイプを管理する */
    enum class ListViewType(val type : Int) {
        /** StickyHeader用 */
        STICKY_HEADER(0),
        /** 通常のリスト */
        NORMAL(1)
    }

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

    /**
     * ViewHolder機構(子クラス)
     * 通常のリスト用
     */
    class NormalListViewHolder(itemView: View): ViewHolder(itemView){
        val numberText: TextView = itemView.findViewById(R.id.numberText)
    }

    /**
     * ViewHolder機構(子クラス)
     * stickyHeader用
     */
    class HeaderViewHolder(view: View) : ViewHolder(view) {
        val stickyHeaderNumberText : TextView = itemView.findViewById(R.id.stickyHeaderNumberText)
    }

    /**
     * viewTypeによって作成するviewHolderを分ける
     * */
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        return when (viewType) {
            ListViewType.STICKY_HEADER.type -> {
                val view = LayoutInflater.from(parent.context).inflate(R.layout.sticky_hedaer_layout, parent,false)
                HeaderViewHolder(view)
            }

            ListViewType.NORMAL.type-> {
                val view = LayoutInflater.from(parent.context).inflate(R.layout.list_item, parent,false)
                NormalListViewHolder(view)
            }

            else -> {
                val view = LayoutInflater.from(parent.context).inflate(R.layout.sticky_hedaer_layout, parent,false)
                HeaderViewHolder(view)
            }
        }
    }

    override fun getItemCount(): Int {
        return itemList.size
    }

    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        when (holder) {
            is HeaderViewHolder ->{
                holder.stickyHeaderNumberText.text = itemList[position].number.toString()
            }
            is NormalListViewHolder ->{
                holder.numberText.text = itemList[position].number.toString()
            }
        }
    }
}

activity_main

<?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="wrap_content"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        />

</androidx.constraintlayout.widget.ConstraintLayout>

normal_type_list_layout (通常タイプのレイアウト)

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
    xmlns:card_view="http://schemas.android.com/apk/res-auto"
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:orientation="vertical">
<androidx.cardview.widget.CardView
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_margin="4dp"
    card_view:cardElevation="2dp"
    android:foreground="?android:attr/selectableItemBackground">
        <TextView
            android:padding="10dp"
            android:id="@+id/numberText"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content" />
</androidx.cardview.widget.CardView>
</LinearLayout>

sticky_hedaer_type_list_layout (Headerタイプのレイアウト)

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
    xmlns:card_view="http://schemas.android.com/apk/res-auto"
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:orientation="vertical">
<androidx.cardview.widget.CardView
    android:id="@+id/stickyHeader"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_margin="4dp"
    card_view:cardElevation="2dp"
    android:backgroundTint="@color/teal_700">
    <LinearLayout
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:orientation="horizontal">
        <TextView
            android:padding="10dp"
            android:id="@+id/stickyHeaderNumberText"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:textSize="20sp"
            android:textColor="@color/white"/>
        <TextView
            android:padding="10dp"
            android:id="@+id/stickyHeaderText"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:textSize="16sp"
            android:textColor="@color/white"/>
    </LinearLayout>
</androidx.cardview.widget.CardView>
</LinearLayout>

Item.kt

/** RecyclerViewの各リストを管理するクラス */
 class Item {
    /** Sticky Headerかどうか */
    var isHeader: Boolean = false
    /** 番号 */
    var number: Int = 0
}
f:id:teco_nakazono:20210224144521p:plain

この段階では、10の倍数を着色したリストを表示させてるだけにすぎません。 この10の倍数のセルをRecyclerViewの上部に固定させるのが今回のゴールです。

まずSticky Header用のインターフェースを作成します。

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

次にItemDecorationクラスの実装をします。

/**
 * RecyclerViewにStickyHeaderを付与するクラス
 * */
class StickyHeaderItemDecoration(stickyHeaderListener: StickyHeaderHandler) : RecyclerView.ItemDecoration() {

    private var mStickyHeaderListener: StickyHeaderHandler? = null

    /** 現在のStickyHeaderを管理するview */
    private var currentHeader: View? = null

    init {
        mStickyHeaderListener = stickyHeaderListener
    }

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

        // ユーザーに表示されてるリストで一番上のview(リスト)
        val topChild = parent.getChildAt(0)
            ?: return  // RecyclerViewの中身がない

        // ユーザーに表示されてるリストで一番上のview(リスト)のポジション
        val topChildPosition = parent.getChildAdapterPosition(topChild)

        if (topChildPosition == RecyclerView.NO_POSITION) {
            return
        }
        val prevHeaderPosition = mStickyHeaderListener!!.getHeaderPositionForItem(topChildPosition)
        if (prevHeaderPosition == -1) {
            return
        }

        // 現在のヘッダー取得
        currentHeader = getHeaderViewForItem(topChildPosition, parent)
        // 現在のヘッダーのレイアウトサイズを取得
        fixLayoutSize(parent, currentHeader)
        val contactPoint = currentHeader!!.bottom
        // 次のセルを取得
        val childInContact = getChildInContact(parent, contactPoint)
            ?: return  // 次のセルがない

        // 次のセルがヘッダーtypeだった場合、そのセルをStickyHeaderとする
        if (mStickyHeaderListener!!.isHeader(parent.getChildAdapterPosition(childInContact))) {
            // 既存のStickyヘッダーを押し上げる
            moveHeader(c, currentHeader, childInContact)
            return
        }

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

    // Stickyヘッダービューの取得
    private fun getHeaderViewForItem(itemPosition: Int, parent: RecyclerView): View? {
        val headerPosition = mStickyHeaderListener!!.getHeaderPositionForItem(itemPosition)
        val layoutResId = mStickyHeaderListener!!.getHeaderLayout(headerPosition)
        // Stickyヘッダーレイアウトをinflateする
        val header = LayoutInflater.from(parent.context).inflate(layoutResId, parent, false)
        // Stickyレイアウトにデータバインドする
        mStickyHeaderListener!!.bindHeaderData(header, headerPosition)
        return header
    }

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

    // Stickyヘッダーを動かす
    private fun moveHeader(c: Canvas, currentHeader: View?, nextHeader: View) {
        c.save()
        c.translate(0F, (nextHeader.top - currentHeader!!.height).toFloat())
        currentHeader.draw(c)
        c.restore()
    }

    // 座標から次のRecyclerViewのセル位置を取得
    private fun getChildInContact(parent: RecyclerView, contactPoint: Int): View? {
        var childInContact: View? = null
        for (i in 0 until parent.childCount) {
            val child = parent.getChildAt(i)
            if (child.bottom > contactPoint) {
                if (child.top <= contactPoint) {
                    childInContact = child
                    break
                }
            }
        }
        return childInContact
    }

    // Stickyヘッダーのレイアウトサイズを取得
    private fun fixLayoutSize(parent: ViewGroup, headerView: View?) {

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

        // headersの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)
    }
}

次にアダプタークラスに作成したインターフェースを継承させ、Sticky Headerの実装を行います。

class StickyHeaderRecyclerViewAdapter(private val itemList:List<Item>): RecyclerView.Adapter<StickyHeaderRecyclerViewAdapter.ViewHolder>(),StickyHeaderHandler {

    /** 使用するレイアウトタイプを管理する */
    enum class ListViewType(val type : Int) {
        /** StickyHeader用 */
        STICKY_HEADER(0),
        /** 通常のリスト */
        NORMAL(1)
    }

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

    /**
     * ViewHolder機構(子クラス)
     * 通常のリスト用
     */
    class NormalListViewHolder(itemView: View): ViewHolder(itemView){
        val numberText: TextView = itemView.findViewById(R.id.numberText)
    }

    /**
     * ViewHolder機構(子クラス)
     * stickyHeader用
     */
    class HeaderViewHolder(view: View) : ViewHolder(view) {
        val stickyHeaderNumberText : TextView = itemView.findViewById(R.id.stickyHeaderNumberText)
    }

    /**
     * 各リストの種類(どのViewを使うかのタイプ)を設定できる
     */
    override fun getItemViewType(i: Int): Int {
        val item = itemList[i]
        return if(item.isHeader){
            ListViewType.STICKY_HEADER.type
        }else{
            ListViewType.NORMAL.type
        }
    }

    /**
     * viewTypeによって作成するviewHolderを分ける
     * */
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        return when (viewType) {
            ListViewType.STICKY_HEADER.type -> {
                val view = LayoutInflater.from(parent.context).inflate(R.layout.sticky_hedaer_type_list_layout, parent,false)
                HeaderViewHolder(view)
            }

            ListViewType.NORMAL.type-> {
                val view = LayoutInflater.from(parent.context).inflate(R.layout.normal_type_list_layout, parent,false)
                NormalListViewHolder(view)
            }

            else -> {
                val view = LayoutInflater.from(parent.context).inflate(R.layout.sticky_hedaer_type_list_layout, parent,false)
                HeaderViewHolder(view)
            }
        }
    }

    override fun getItemCount(): Int {
        return itemList.size
    }

    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        when (holder) {
            is HeaderViewHolder ->{
                holder.stickyHeaderNumberText.text = itemList[position].number.toString()
            }
            is NormalListViewHolder ->{
                holder.numberText.text = itemList[position].number.toString()
            }
        }

    }


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

    // StickyHeaderのポジションを返す
    override fun getHeaderPositionForItem(itemPosition: Int): Int {
        var headerPosition = -1
        var itemPosition = itemPosition
        do {
            if (isHeader(itemPosition)) {
                headerPosition = itemPosition
                break
            }
            itemPosition -= 1
        } while (itemPosition >= 0)
        return headerPosition
    }

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

    // StickyHeaderと判定されたリストにデータを渡す
    override fun bindHeaderData(header: View?, headerPosition: Int) {
        val headerItem = itemList[headerPosition]
        if (headerItem.isHeader) {
            val stickyHeaderText = header!!.findViewById(R.id.stickyHeaderText) as TextView
            val stickyHeaderNumberText = header!!.findViewById(R.id.stickyHeaderNumberText) as TextView
            stickyHeaderText.text = "Sticky Headerだよ!"
            stickyHeaderNumberText.text = headerItem.number.toString()

        }
    }

    // 各リストがヘッダーかどうかを判定する
    override fun isHeader(itemPosition: Int): Boolean {
        val item = itemList[itemPosition]
        if(item.isHeader){
            return true
        }
        return false
    }
}

最後にMainActivityにItemDecorationの実装を追加します

MainActivity.kt

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

        // RecyclerView用のアイテム取得
        val numberList = makeNumberList()

        val recyclerView = findViewById<RecyclerView>(R.id.recyclerView)
        val adapter = StickyHeaderRecyclerViewAdapter(numberList)
        // adapterのセット
        recyclerView.adapter = adapter
        recyclerView.layoutManager = LinearLayoutManager(this)
        recyclerView.setHasFixedSize(true)

       // ItemDecorationのセット ↓↓↓  追加
        val itemDecoration = StickyHeaderItemDecoration(adapter)
        recyclerView.addItemDecoration(itemDecoration)
    }
    
    /** RecyclerView用リストの作成 */
    private fun makeNumberList():List<Item>{
        var list = mutableListOf<Item>()
        for(index in 0 .. 100){
            val item = Item()
            // 10の倍数であればSticky Header用のリストとして扱う
            item.isHeader = (index % 10) == 0
            item.number = index
            list.add(item)
        }
        return list
    }
}

f:id:teco_nakazono:20210224164656g:plain

できました!

通常のRecylerViewの上に無理やりviewを重ねて描画しているだけですね。

おわりに

いかがでしたでしょうか?

RecylerViewのItemDecorationを用いれば、ライブラリを使用しなくても、簡単にSticky Headerを実装できます。

ぜひ試してみてください!

www.tecotec.co.jp