14
4

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.

and factoryAdvent Calendar 2018

Day 12

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

Last updated at Posted at 2018-12-12

【追記】
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に移行している必要があります。

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'

レイアウトファイル

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

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

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>

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

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_ が生成されます。

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

import com.airbnb.epoxy.EpoxyDataBindingLayouts;

コード

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

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に addItemDecorationKmHeaderItemDecoration を追加します。

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をなんとか実現できました。 :smile:
ほぼほぼKM-Recyclerview-Sticky-Headerの使い方を説明しているだけ感

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

まとめ

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

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

14
4
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
14
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?