こんにちは。
投資戦略システム事業部のAndroidエンジニア、中園です。
昨年の10月に中途採用で入社し、そろそろ5ヶ月になります。
今回は業務で用いたSticky Headerについて書かせていただきます。
はじめに
Sticky Headerとは以下のような動きをするヘッダーです。
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 }
この段階では、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 } }
できました!
通常のRecylerViewの上に無理やりviewを重ねて描画しているだけですね。
おわりに
いかがでしたでしょうか?
RecylerViewのItemDecorationを用いれば、ライブラリを使用しなくても、簡単にSticky Headerを実装できます。
ぜひ試してみてください!