Epoxyを使った画面でSticky Headerをなんとか実現する

【追記】
Epoxy v3.10.0から正式にSticky Headerがサポートされましたので、そちらを使うことをおすすめします。

---

この記事では、Epoxyを使った画面でSticky Header（スティッキーヘッダー）をなんとか実現する話を記載したいと思います。

『iOSと同じ動きにしたい！』とプロデューサーから言われたけど、どうすればいいか困っている人などの参考になれば幸いです。


↓ちなみに、こんな感じになります。
<img src="https://qiita-image-store.s3.amazonaws.com/0/995/20a1e040-eed6-d75f-29b4-da75f0156049.gif" width="200" />

# 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

公式でサポートされるのが１番ですが、何とか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に移行している必要があります。

```gradle:app/build.gradle
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'
```

## レイアウトファイル

ヘッダーとコンテンツのレイアウトファイルを作成します。

ヘッダーのレイアウトファイル。

```xml:item_header.xml
<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>
```

コンテンツのレイアウトファイル。

```xml:item_content.xml
<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_` が生成されます。

```java:package-info.java
@EpoxyDataBindingLayouts({R.layout.item_header, R.layout.item_content})
package com.example.epoxystickyheader;

import com.airbnb.epoxy.EpoxyDataBindingLayouts;
```

## コード

ドカッと貼りますが、 `MainActivity.kt` のコードになります。
それぞれの解説は後述していきます。

```kotlin: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でのリストの生成部分です。
ヘッダーがあり、その下にコンテンツが複数ある、という感じになっています。

```kotlin
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` を追加します。

```kotlin
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_` かどうかを判別します。

```kotlin
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` を返すだけです。

```kotlin
override fun getHeaderLayout(position: Int?): Int {
    return R.layout.item_header
}
```


#### ・ `getHeaderPositionForItem(position: Int?): Int`

渡された `position` に対するヘッダーの位置を返します。

今回のようなシンプルなリストの構成だと、
・ `position` がヘッダーなら、その `position` を返す。
・ `position` がヘッダーではないなら、`position` をデクリメントして、デクリメントした位置がヘッダーかどうかの判別を行う。
で対応可能です。

今回の場合はコンテンツに対してヘッダーが必ず存在するリストの構成なので、シンプルに済みましたが、複雑なリストの構造の場合は、 `getHeaderPositionForItem` 内の処理も複雑化することになりそうです。

```kotlin
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()` を呼んでいます。

```kotlin
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をなんとか実現できました。 :smile:
~~ほぼほぼKM-Recyclerview-Sticky-Headerの使い方を説明しているだけ感~~
<img src="https://qiita-image-store.s3.amazonaws.com/0/995/20a1e040-eed6-d75f-29b4-da75f0156049.gif" width="200" />

※ちなみに固定されるヘッダーのViewは、 `KM-Recyclerview-Sticky-Header` のソースコードを読む限り、同じレイアウトファイルから新規に生成されたViewになるため、アニメーションの同期などは難しい感じになっています。

# まとめ
Epoxyを使った画面でのSticky Headerの実現方法を記載しました。
シンプルなリスト構造の場合なら、意外に簡単に実現できるのでは、と感じました。

なんとか実現した感はありますが、何かの参考になれば幸いです。