【追記】
Epoxy v3.10.0から正式にSticky Headerがサポートされましたので、そちらを使うことをおすすめします。
この記事では、Epoxyを使った画面でSticky Header(スティッキーヘッダー)をなんとか実現する話を記載したいと思います。
『iOSと同じ動きにしたい!』とプロデューサーから言われたけど、どうすればいいか困っている人などの参考になれば幸いです。
Epoxy便利
https://github.com/airbnb/epoxy
Epoxy、便利ですよね。
RecyclerViewの実装がかなり楽になりますし、機能も色々と豊富です。
若干多機能すぎてハンドリングが難しいところもありますが…
Sticky Headerには未対応
そんなEpoxyですが、この記事の投稿時点(2018/12/12)ではSticky Headerには対応しておりません。
issueにも登録されているようです。↓
Support for sticky headers
https://github.com/airbnb/epoxy/issues/164
公式でサポートされるのが1番ですが、何とかSticky Headerを実現したい!ということで、なんとかしてみました。
KM-Recyclerview-Sticky-Headerを使う
https://github.com/smhdk/KM-Recyclerview-Sticky-Header
はい。他力本願的な感じですが、ライブラリを使うことでなんとか実現します。
Sticky Headerのライブラリは調べた感じ色々とあるようですが、このライブラリが適しておりました。
実現方法
ここからは冒頭のgifの動きを実現したコードを交え、説明したいと思います。
ライブラリ追加
詳しいところはEpoxy、KM-Recyclerview-Sticky-HeaderそれぞれのREADMEなりをご覧頂きたいですが、 app/build.gradle
にライブラリを追加します。
ちなみにEpoxy 3.0.0からはAndroidXに移行している必要があります。
implementation 'com.airbnb.android:epoxy:3.0.0'
implementation 'com.airbnb.android:epoxy-databinding:3.0.0'
kapt 'com.airbnb.android:epoxy-processor:3.0.0'
implementation 'com.github.smhdk:KM-Recyclerview-Sticky-Header:v1.0.0'
レイアウトファイル
ヘッダーとコンテンツのレイアウトファイルを作成します。
ヘッダーのレイアウトファイル。
<layout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
<data>
<variable name="title" type="String"/>
</data>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="48dp"
android:background="@color/colorAccent">
<TextView
android:text="@{title}"
tools:text="タイトル"
android:gravity="center|start"
android:textSize="18sp"
android:textColor="#FFFFFFFF"
android:textStyle="bold"
android:paddingStart="8dp"
android:layout_width="match_parent"
android:layout_height="match_parent"/>
</FrameLayout>
</layout>
コンテンツのレイアウトファイル。
<layout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
<data>
<variable name="content" type="String"/>
</data>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="32dp">
<TextView
android:text="@{content}"
tools:text="タイトル"
android:gravity="center|start"
android:paddingStart="8dp"
android:textSize="12sp"
android:layout_width="match_parent"
android:layout_height="match_parent"/>
</FrameLayout>
</layout>
Epoxyのデータバインディングモデルが生成されるように、 package-info.java
を作成し、上記のレイアウトファイルを定義しておきます。こうすることで item_header.xml
から ItemHeaderBindingModel_
、 item_content.xml
からは ItemContentBindingModel_
が生成されます。
@EpoxyDataBindingLayouts({R.layout.item_header, R.layout.item_content})
package com.example.epoxystickyheader;
import com.airbnb.epoxy.EpoxyDataBindingLayouts;
コード
ドカッと貼りますが、 MainActivity.kt
のコードになります。
それぞれの解説は後述していきます。
class MainActivity : AppCompatActivity() {
private val activityMainBinding by lazy {
DataBindingUtil.setContentView<ActivityMainBinding>(this, R.layout.activity_main)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val epoxyRecyclerView = activityMainBinding.epoxyRecyclerView
val controller = object : EpoxyController() {
override fun buildModels() {
(1..5).forEach { header ->
ItemHeaderBindingModel_()
.id("month_$header")
.title("ヘッダー No.$header")
.addTo(this)
(1..20).forEach { content ->
ItemContentBindingModel_()
.id("content_${header}_$content")
.content("ヘッダー ${header}のコンテンツ $content")
.addTo(this)
}
}
}
}
// Sticky Headerの処理ここから
epoxyRecyclerView.addItemDecoration(object : KmHeaderItemDecoration(object : KmStickyListener {
override fun isHeader(position: Int?): Boolean {
val model = controller.adapter.getModelAtPosition(position!!)
return model is ItemHeaderBindingModel_
}
override fun getHeaderLayout(position: Int?): Int {
return R.layout.item_header
}
override fun getHeaderPositionForItem(position: Int?): Int {
var counter = position!!
while (!isHeader(counter)) {
counter--
}
return counter
}
override fun bindHeaderData(view: View?, position: Int?) {
view ?: return
position ?: return
val model = controller.adapter.getModelAtPosition(position) as ItemHeaderBindingModel_
val binding = ItemHeaderBinding.bind(view)
binding.title = model.title()
binding.executePendingBindings()
}
}) {})
epoxyRecyclerView.setController(controller)
epoxyRecyclerView.requestModelBuild()
}
}
EpoxyControllerを定義する
Epoxyでのリストの生成部分です。
ヘッダーがあり、その下にコンテンツが複数ある、という感じになっています。
val controller = object : EpoxyController() {
override fun buildModels() {
(1..5).forEach { header ->
ItemHeaderBindingModel_()
.id("month_$header")
.title("ヘッダー No.$header")
.addTo(this)
(1..20).forEach { content ->
ItemContentBindingModel_()
.id("content_${header}_$content")
.content("ヘッダー ${header}のコンテンツ $content")
.addTo(this)
}
}
}
}
↓イメージはこんな感じ。
ヘッダー No.1
コンテンツ 1
コンテンツ 2
...
ヘッダー No.2
コンテンツ 1
コンテンツ 2
...
...
KmHeaderItemDecorationをaddする
EpoxyのRecyclerViewに addItemDecoration
で KmHeaderItemDecoration
を追加します。
epoxyRecyclerView.addItemDecoration(object : KmHeaderItemDecoration(object : KmStickyListener {
...
}) {})
KmStickyListenerを実装する
Sticky Headerを実現する肝の部分です。
isHeader
getHeaderLayout
getHeaderPositionForItem
bindHeaderData
という4つのメソッドを実装する必要があります。
・ isHeader(position: Int?): Boolean
position
のアイテムがヘッダーかどうかを返します。
Epoxyの場合、 getModelAtPosition
というメソッドでModelが取得できるので、その取得したModelがヘッダー用のModelである ItemHeaderBindingModel_
かどうかを判別します。
override fun isHeader(position: Int?): Boolean {
val model = controller.adapter.getModelAtPosition(position!!)
return model is ItemHeaderBindingModel_
}
・ getHeaderLayout(position: Int?): Int
ヘッダーのレイアウトファイルを返します。
今回の場合、ヘッダーは1種類だけなので R.layout.item_header
を返すだけです。
override fun getHeaderLayout(position: Int?): Int {
return R.layout.item_header
}
・ getHeaderPositionForItem(position: Int?): Int
渡された position
に対するヘッダーの位置を返します。
今回のようなシンプルなリストの構成だと、
・ position
がヘッダーなら、その position
を返す。
・ position
がヘッダーではないなら、position
をデクリメントして、デクリメントした位置がヘッダーかどうかの判別を行う。
で対応可能です。
今回の場合はコンテンツに対してヘッダーが必ず存在するリストの構成なので、シンプルに済みましたが、複雑なリストの構造の場合は、 getHeaderPositionForItem
内の処理も複雑化することになりそうです。
override fun getHeaderPositionForItem(position: Int?): Int {
var counter = position!!
while (!isHeader(counter)) {
counter--
}
return counter
}
・ fun bindHeaderData(view: View?, position: Int?)
ヘッダーのレイアウトにデータをバインドします。
position
からModelを取得し、そのModelから title
などのデータをバインドする感じになっています。あまりスマートな書き方ではない気がしますが。
1点補足で、 binding.title = model.title()
だけだと反映されなかったので、 executePendingBindings()
を呼んでいます。
override fun bindHeaderData(view: View?, position: Int?) {
val view = view ?: return
val position = position ?: return
val model = controller.adapter.getModelAtPosition(position) as ItemHeaderBindingModel_
val binding = ItemHeaderBinding.bind(view)
binding.title = model.title()
binding.executePendingBindings() // これがないとtitleが反映されなかった
}
以上の対応で、冒頭のgifのようなSticky Headerをなんとか実現できました。
ほぼほぼKM-Recyclerview-Sticky-Headerの使い方を説明しているだけ感
※ちなみに固定されるヘッダーのViewは、 KM-Recyclerview-Sticky-Header
のソースコードを読む限り、同じレイアウトファイルから新規に生成されたViewになるため、アニメーションの同期などは難しい感じになっています。
まとめ
Epoxyを使った画面でのSticky Headerの実現方法を記載しました。
シンプルなリスト構造の場合なら、意外に簡単に実現できるのでは、と感じました。
なんとか実現した感はありますが、何かの参考になれば幸いです。