LoginSignup
5

More than 1 year has passed since last update.

この記事は

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

sticky_header_normal_3.gif

結論としては、RecyclerViewのLayoutManagerDoist/RecyclerViewExtensionsStickyHeadersLinearLayoutManagerを設定します。

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

device-2020-12-21-023618.gif

サンプルコード

Githubに全体ソースコードを公開しています。
tfandkusu/groupie_sticky_header_sample

使用素材

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

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

表示元データ

日付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形式で表すとこのようなデータになります。

- 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

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

GitHubのDoist/RecyclerViewExtensionsにある、こちらの2クラスをプロジェクトにコピーします。

スクリーンショット 2020-12-21 0.20.55.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版)の日付ヘッダーに似たものを作りたくて、このようなヘッダーを作成します。

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

背景画像をシェイプドローアブルで作成します。

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>

問題点

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

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

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

解決方法

StickyHeadersLinearLayoutManagerを改造します。しかし、このクラスは796行あり、その前提知識であるRecyclerView.LayoutManagerクラスを継承して独自のLayoutManagerを作る方法を理解することは短時間では難しそうです。一応、情報としてはこちらがあります。

RecyclerView.LayoutManagerの実装方法

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

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

まず、ヘッダーのViewにユニークなTagを設定します。ViewにはTagという任意のオブジェクトを設定できるフィールドがあります。現代では使われていないと思いますが、かつては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メソッドが呼ばれていました。よってそこの最後に処理を追加します。

まずは追加する処理である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();
}

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

補足

Epoxyの場合

Expxyの場合は標準でスティッキーヘッダーがあります。

参考記事
[Android]Epoxy が StickyHeader に対応しているらしいので試してみた

Epoxyの中にあるStickyHeaderLinearLayoutManagerを使用します。
そしてそれはDoist/RecyclerViewExtensionsStickyHeadersLinearLayoutManagerと同様に透明部分があるヘッダーだと多重に表示されてしまいますが、同様の対処方法で解決できます。StickyHeaderLinearLayoutManagerを改造するためには自分のプロジェクトにパッケージ名が被らないようにEpoxyの中にあるそれをコピーしてから行います。こちらのプルリクに実装例を作成しました。

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
What you can do with signing up
5