9
6

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.

この記事は

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の中にあるそれをコピーしてから行います。こちらのプルリクに実装例を作成しました。

9
6
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
9
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?