GroupieでSticky Headerを作る

# この記事は

[Groupie](https://github.com/lisawray/groupie)を使ってリスト表示しているときの、上に吸い付くヘッダー - スティッキーヘッダーの作り方を解説します。
Slackのような社内チャットアプリを題材とします。ヘッダーはメッセージが投稿された日付になります。

![sticky_header_normal_3.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/334798/b3f8c324-f610-c617-5376-5a6b82fc317b.gif)

結論としては、[RecyclerViewのLayoutManager](https://developer.android.com/reference/androidx/recyclerview/widget/RecyclerView#setLayoutManager(androidx.recyclerview.widget.RecyclerView.LayoutManager))に[Doist/RecyclerViewExtensions](https://github.com/Doist/RecyclerViewExtensions)の[StickyHeadersLinearLayoutManager](https://github.com/Doist/RecyclerViewExtensions/tree/master/StickyHeaders)を設定します。

記事の後半では、ヘッダーに透明部分があるケースの特殊な対応について解説しています。それを行うとSlack(PC版)の日付ヘッダーを真似することもできます。

![device-2020-12-21-023618.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/334798/f26d96ad-d319-61d9-226b-a724e76e62d2.gif)

## サンプルコード

Githubに全体ソースコードを公開しています。
[tfandkusu/groupie_sticky_header_sample](https://github.com/tfandkusu/groupie_sticky_header_sample)


## 使用素材

- アイコンは[いらすとやさん](https://www.irasutoya.com/)の画像を使用しています。
- 氏名は[moove-it/fakeit](https://github.com/moove-it/fakeit)で乱数生成しています。


# 日付ヘッダー付きリスト表示を作成する

まずは上に吸い付かない日付ヘッダー付きのリストを作成します。

## 表示元データ

日付1-メッセージ多のデータを作成します。

メッセージのdata classです。

```Message.kt
data class Message(
    val id: Long, val name: String, @DrawableRes val iconResId: Int, val time: Long,
    val body: String
)
```

日付とその日に属するメッセージ一覧のdata classです。

```DayWithMessages.kt
data class DayWithMessages(val day: YMD, val messages: List<Message>)
```

日付のdata classです。

```YMD.kt
data class YMD(val year: Int, val month: Int, val day: Int)
```

データをYAML形式で表すとこのようなデータになります。

```yaml
- day:
    day: 11
    month: 12
    year: 2020
  messages:
  - body: Mornin
    iconResId: 2131230815
    id: 1
    name: Wehner
    time: 1607647860000
  - body: Start working
    iconResId: 2131230826
    id: 2
    name: Kertzmann
    time: 1607647920000
  - body: Good morning.
    iconResId: 2131230814
    id: 3
    name: Casper
    time: 1607647920000
  - body: I do the work
    iconResId: 2131230813
    id: 4
    name: Batz
    time: 1607648100000
# 略
- day:
    day: 14
    month: 12
    year: 2020
  messages:
  - body: I do the work
    iconResId: 2131230814
    id: 13
    name: Casper
    time: 1607907000000
  - body: Good morning.
    iconResId: 2131230815
    id: 14
    name: Wehner
    time: 1607907240000
  - body: Mornin
    iconResId: 2131230826
    id: 15
    name: Kertzmann
    time: 1607907360000
# 略
```

## リスト項目のレイアウトを作成する

日付ヘッダーのレイアウトファイルです。

```list_item_day.xml
<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/day"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:background="@color/light_gray"
    android:padding="16dp"
    android:textColor="@color/text"
    tools:text="12月25日（金）" />
```

メッセージのレイアウトファイルです。

```list_item_message.xml
<?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="wrap_content"
    android:padding="16dp">

    <ImageView
        android:id="@+id/icon"
        android:layout_width="40dp"
        android:layout_height="40dp"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        tools:ignore="ContentDescription" />

    <TextView
        android:id="@+id/name"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="16dp"
        android:maxLines="1"
        android:textColor="@color/text"
        android:textSize="14sp"
        android:textStyle="bold"
        app:layout_constraintStart_toEndOf="@+id/icon"
        app:layout_constraintTop_toTopOf="@+id/icon"
        tools:text="Member name" />

    <TextView
        android:id="@+id/time"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginStart="4dp"
        android:maxLines="1"
        android:textSize="12sp"
        app:layout_constraintBottom_toBottomOf="@+id/name"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toEndOf="@+id/name"
        tools:text="12:34" />

    <TextView
        android:id="@+id/body"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginTop="4dp"
        android:textColor="@color/text"
        android:textSize="14sp"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="@+id/name"
        app:layout_constraintTop_toBottomOf="@+id/name" />

</androidx.constraintlayout.widget.ConstraintLayout>
```

## Groupieライブラリを取り込む


```app/build.gradle
dependencies {
    // 追加
    def groupie_version = '2.8.1'
    implementation "com.xwray:groupie:$groupie_version"
    implementation "com.xwray:groupie-viewbinding:$groupie_version"
}

```

## GroupieのBindableItemを作成する。

日付ヘッダーのBindableItemです。

```DayGroupieItem.kt

class DayBindableItem(private val day: YMD) : BindableItem<ListItemDayBinding>() {
    private val sdf = SimpleDateFormat("MM月dd日（EEE）", Locale.JAPAN)

    override fun bind(viewBinding: ListItemDayBinding, position: Int) {
        // javaのDateに変換してからSimpleDateFormatクラスを使って文字列にする。
        viewBinding.day.text = sdf.format(day.toDate())
    }

    override fun getLayout() = R.layout.list_item_day

    override fun initializeViewBinding(view: View): ListItemDayBinding {
        return ListItemDayBinding.bind(view)
    }

    override fun isSameAs(other: Item<*>): Boolean {
        return if (other is DayBindableItem) {
            day == other.day
        } else {
            false
        }
    }

    override fun hasSameContentAs(other: Item<*>): Boolean {
        return if (other is DayBindableItem) {
            day == other.day
        } else {
            false
        }
    }
}
```

メッセージのBindableItemです。

```MessageGroupieItem.kt
class MessageBindableItem(private val message: Message) : BindableItem<ListItemMessageBinding>() {

    private val sdf = SimpleDateFormat("HH:mm", Locale.JAPAN)

    override fun bind(viewBinding: ListItemMessageBinding, position: Int) {
        viewBinding.icon.setImageResource(message.iconResId)
        viewBinding.name.text = message.name
        viewBinding.time.text = sdf.format(Date(message.time))
        viewBinding.body.text = message.body
    }

    override fun getLayout() = R.layout.list_item_message

    override fun initializeViewBinding(view: View): ListItemMessageBinding {
        return ListItemMessageBinding.bind(view)
    }
    
    override fun isSameAs(other: Item<*>): Boolean {
        return if (other is MessageBindableItem) {
            message.id == other.message.id
        } else {
            false
        }
    }

    override fun hasSameContentAs(other: Item<*>): Boolean {
        return if (other is MessageBindableItem) {
            message == other.message
        } else {
            false
        }
    }
}
```

## RecyclerViewの設定を行う

`viewModel.dayList` LiveDataには表示元データが格納されています。

```MainActivity.kt
val adapter = GroupAdapter<GroupieViewHolder>()
recyclerView.adapter = adapter
recyclerView.layoutManager = LinearLayoutManager(this)
recyclerView.setHasFixedSize(true)
viewModel.dayList.observe(this) { dayList ->
    val items = dayList.flatMap { dayWithMessages ->
        // 日付ヘッダーのBindableItem
        listOf(DayBindableItem(dayWithMessages.day)) +
                dayWithMessages.messages.map { message ->
                    // メッセージのBindableItem
                    MessageBindableItem(message)
                }
    }
    adapter.update(items)
    // 最後にスクロールする
    // 実プロダクトの場合は最初の1回だけスクロールする
    recyclerView.scrollToPosition(items.size - 1)
}
```

## 上に吸い付かないリスト表示はできあがり

![device-2020-12-22-041724.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/334798/80b13fc9-1984-5cfa-aa8d-c164da8253b3.gif)

# スティッキーヘッダーにする

GitHubの[Doist/RecyclerViewExtensions](https://github.com/Doist/RecyclerViewExtensions)にある、[こちらの2クラス](https://github.com/Doist/RecyclerViewExtensions/tree/master/StickyHeaders/src/main/java/io/doist/recyclerviewext/sticky_headers)をプロジェクトにコピーします。

![スクリーンショット 2020-12-21 0.20.55.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/334798/4e2814d1-b4b4-480f-c950-1ad0f736deb4.png)


StickyHeadersインターフェースをRecyclerViewのAdapterに実装することで、どれがStickyHeaderなのかを教えます。

```StickyHeaderGroupAdapter.kt
class StickyHeaderGroupAdapter : GroupAdapter<GroupieViewHolder>(), StickyHeaders {
    override fun isStickyHeader(position: Int): Boolean {
        return getItem(position) is DayBindableItem
    }
}
```

RecyclerViewのadapterとLayoutManagerに上記StickyHeaderGroupAdapterとStickyHeadersLinearLayoutManagerを設定します。


```MainActivity.kt
val adapter = StickyHeaderGroupAdapter()
recyclerView.adapter = adapter
recyclerView.layoutManager =
    StickyHeadersLinearLayoutManager<StickyHeaderGroupAdapter>(this)
```

これでスティッキーヘッダーになります。

# 透明部分のあるヘッダーに対応する

ここから後半パートです。Slack(PC版)の日付ヘッダーに似たものを作りたくて、このようなヘッダーを作成します。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/334798/62d3fed2-370c-9ea8-128c-d8ecc3dde640.png" width="320px">

## 日付ヘッダーのレイアウト変更

背景画像を[シェイプドローアブル](https://developer.android.com/guide/topics/resources/drawable-resource?hl=ja#Shape)で作成します。

```day_header.xml
<?xml version="1.0" encoding="utf-8"?>
<shape android:shape="rectangle"
    xmlns:android="http://schemas.android.com/apk/res/android">
    <corners android:radius="18dp" />
    <stroke android:width="1dp" android:color="@color/gray" />
    <solid android:color="@color/white" />
</shape>
```

日付ヘッダーのレイアウトを変更します。

```list_item_day.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:gravity="center"
    android:orientation="horizontal"
    android:paddingTop="16dp"
    android:paddingBottom="16dp">

    <TextView
        android:id="@+id/day"
        android:layout_width="wrap_content"
        android:layout_height="32dp"
        android:background="@drawable/day_header"
        android:paddingStart="16dp"
        android:paddingEnd="16dp"
        android:textColor="@color/text"
        android:gravity="center"
        tools:text="12月25日（金）" />
</LinearLayout>
```

## 問題点

しかしこれだけでは問題があります。

上に吸い付いているヘッダーと吸い付いてないヘッダーの両方が表示されてしまい、不自然な多重表示になってしまいます。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/334798/debf1c69-5505-48ef-18e4-e0bf8cd4400e.png" width="320px">

透明部分の無いヘッダーならば吸い付いていないヘッダーは裏に隠れるから不自然ではないです。透明部分のあるヘッダーは想定されていないようです。

## 解決方法

[StickyHeadersLinearLayoutManager](https://github.com/Doist/RecyclerViewExtensions/blob/master/StickyHeaders/src/main/java/io/doist/recyclerviewext/sticky_headers/StickyHeadersLinearLayoutManager.java)を改造します。しかし、このクラスは796行あり、その前提知識である[RecyclerView.LayoutManager](https://developer.android.com/reference/androidx/recyclerview/widget/RecyclerView.LayoutManager)クラスを継承して独自のLayoutManagerを作る方法を理解することは短時間では難しそうです。一応、情報としてはこちらがあります。

[RecyclerView.LayoutManagerの実装方法](https://blog.moyuru.io/post/tech/2018/11/layoutmanager/)

今回はStickyHeadersLinearLayoutManagerに追加の処理を加えることで目的の実装を作成しました。追加の処理とは**同じ日付のヘッダーが2つあったときは上にめり込んでいる方を消す**です。

### ヘッダーのViewにTagを設定する

まず、ヘッダーのViewにユニークなTagを設定します。[ViewにはTagという任意のオブジェクトを設定できるフィールドがあります](https://developer.android.com/reference/android/view/View#tags)。現代では使われていないと思いますが、かつてはRecyclerViewの前身であるListViewでViewHolderを保持する先として主に使われていました。


```DayBindableItem.kt
class DayBindableItem(private val day: YMD) : BindableItem<ListItemDayBinding>() {
    private val sdf = SimpleDateFormat("MM月dd日（EEE）", Locale.JAPAN)

    override fun bind(viewBinding: ListItemDayBinding, position: Int) {
        val text = sdf.format(day.toDate())
        viewBinding.day.text = text
        // tagにも表示textを設定
        viewBinding.root.tag = text
    }

    // 略
}
```

### StickyHeadersLinearLayoutManagerを改造する

RecyclerViewをスクロールするたびに[StickyHeadersLinearLayoutManagerクラスのupdateStickyHeaderメソッド](https://github.com/Doist/RecyclerViewExtensions/blob/7fb737e1f0fabd032c1c0a9717c3ab76adccfb99/StickyHeaders/src/main/java/io/doist/recyclerviewext/sticky_headers/StickyHeadersLinearLayoutManager.java#L319)が呼ばれていました。よってそこの最後に処理を追加します。

まずは追加する処理である`fixHeaderViews`メソッドを作成しました。

```StickyHeadersLinearLayoutManager.java
private void fixHeaderViews() {
    // タグ文字列とViewリストのマップ
    Map<String, List<View>> map = new HashMap<>();
    // すべての子Viewに対して
    int childCount = getChildCount();
    for (int i = 0; i < childCount; i++) {
        View child = getChildAt(i);
        if (child != null) {
            if (child.getTag() instanceof String) {
                // Stringクラスのインスタンスのタグを持っている時は
                String tag = (String) child.getTag();
                // いったん表示にして
                child.setVisibility(View.VISIBLE);
                // マップに追加する
                if (!map.containsKey(tag)) {
                    // キーがなければ空配列を設定
                    map.put(tag, new ArrayList<>());
                }
                List<View> views = map.get(tag);
                if (views != null) {
                    views.add(child);
                }
            }
        }
    }
    // タグ毎にViewリストを確認する
    for (List<View> views : map.values()) {
        if (views.size() >= 2) {
            // おなじタグのViewが2個ある時は
            for (View view : views) {
                // 上にめり込んでいる方を非表示にする
                if (view.getTop() < 0) {
                    view.setVisibility(View.INVISIBLE);
                }
            }
        }
    }
}
```

それを`updateStickyHeader`メソッドが終わるところ - `return`の前と終端から呼ばれるようにしました。

```StickyHeadersLinearLayoutManager.java
private void updateStickyHeader(RecyclerView.Recycler recycler, boolean layout) {
    // 省略
    if (headerCount > 0 && childCount > 0) {
        // 省略
        if (anchorView != null && anchorPos != -1) {
                    && (headerPos != anchorPos || isViewOnBoundary(anchorView))
                    && nextHeaderPos != headerPos + 1) {
                // 省略
                mStickyHeader.setTranslationX(getX(mStickyHeader, nextHeaderView));
                mStickyHeader.setTranslationY(getY(mStickyHeader, nextHeaderView));
                // 追加
                fixHeaderViews();
                return;
            }
        }
    }

    if (mStickyHeader != null) {
        scrapStickyHeader(recycler);
    }
    // 追加
    fixHeaderViews();
}

```

多重にならなくなり、自然な表示になりました。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/334798/f9927610-8d47-8360-c635-fdde75c73b56.png" width="320px">

# 補足

## Epoxyの場合

[Expxy](https://github.com/airbnb/epoxy)の場合は標準でスティッキーヘッダーがあります。

**参考記事**
[［Android］Epoxy が StickyHeader に対応しているらしいので試してみた](https://qiita.com/kaleidot725/items/3797b9b5d77c94661757)

[Epoxyの中にあるStickyHeaderLinearLayoutManager](https://github.com/airbnb/epoxy/blob/master/epoxy-adapter/src/main/java/com/airbnb/epoxy/stickyheader/StickyHeaderLinearLayoutManager.kt)を使用します。
そしてそれは[Doist/RecyclerViewExtensions](https://github.com/Doist/RecyclerViewExtensions)の[StickyHeadersLinearLayoutManager](https://github.com/Doist/RecyclerViewExtensions/blob/master/StickyHeaders/src/main/java/io/doist/recyclerviewext/sticky_headers/StickyHeadersLinearLayoutManager.java)と同様に透明部分があるヘッダーだと多重に表示されてしまいますが、同様の対処方法で解決できます。StickyHeaderLinearLayoutManagerを改造するためには自分のプロジェクトにパッケージ名が被らないようにEpoxyの中にあるそれをコピーしてから行います。[こちらのプルリク](https://github.com/tfandkusu/groupie_sticky_header_sample/pull/7)に実装例を作成しました。