この記事は
Groupieを使ってリスト表示しているときの、上に吸い付くヘッダー - スティッキーヘッダーの作り方を解説します。
Slackのような社内チャットアプリを題材とします。ヘッダーはメッセージが投稿された日付になります。
結論としては、RecyclerViewのLayoutManagerにDoist/RecyclerViewExtensionsのStickyHeadersLinearLayoutManagerを設定します。
記事の後半では、ヘッダーに透明部分があるケースの特殊な対応について解説しています。それを行うとSlack(PC版)の日付ヘッダーを真似することもできます。
サンプルコード
Githubに全体ソースコードを公開しています。
tfandkusu/groupie_sticky_header_sample
使用素材
- アイコンはいらすとやさんの画像を使用しています。
- 氏名はmoove-it/fakeitで乱数生成しています。
日付ヘッダー付きリスト表示を作成する
まずは上に吸い付かない日付ヘッダー付きのリストを作成します。
表示元データ
日付1-メッセージ多のデータを作成します。
メッセージのdata classです。
data class Message(
val id: Long, val name: String, @DrawableRes val iconResId: Int, val time: Long,
val body: String
)
日付とその日に属するメッセージ一覧のdata classです。
data class DayWithMessages(val day: YMD, val messages: List<Message>)
日付のdata classです。
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
# 略
リスト項目のレイアウトを作成する
日付ヘッダーのレイアウトファイルです。
<?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日(金)" />
メッセージのレイアウトファイルです。
<?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ライブラリを取り込む
dependencies {
// 追加
def groupie_version = '2.8.1'
implementation "com.xwray:groupie:$groupie_version"
implementation "com.xwray:groupie-viewbinding:$groupie_version"
}
GroupieのBindableItemを作成する。
日付ヘッダーのBindableItemです。
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です。
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には表示元データが格納されています。
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)
}
上に吸い付かないリスト表示はできあがり
スティッキーヘッダーにする
GitHubのDoist/RecyclerViewExtensionsにある、こちらの2クラスをプロジェクトにコピーします。
StickyHeadersインターフェースをRecyclerViewのAdapterに実装することで、どれがStickyHeaderなのかを教えます。
class StickyHeaderGroupAdapter : GroupAdapter<GroupieViewHolder>(), StickyHeaders {
override fun isStickyHeader(position: Int): Boolean {
return getItem(position) is DayBindableItem
}
}
RecyclerViewのadapterとLayoutManagerに上記StickyHeaderGroupAdapterとStickyHeadersLinearLayoutManagerを設定します。
val adapter = StickyHeaderGroupAdapter()
recyclerView.adapter = adapter
recyclerView.layoutManager =
StickyHeadersLinearLayoutManager<StickyHeaderGroupAdapter>(this)
これでスティッキーヘッダーになります。
透明部分のあるヘッダーに対応する
ここから後半パートです。Slack(PC版)の日付ヘッダーに似たものを作りたくて、このようなヘッダーを作成します。
日付ヘッダーのレイアウト変更
背景画像をシェイプドローアブルで作成します。
<?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>
日付ヘッダーのレイアウトを変更します。
<?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を保持する先として主に使われていました。
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
メソッドを作成しました。
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
の前と終端から呼ばれるようにしました。
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/RecyclerViewExtensionsのStickyHeadersLinearLayoutManagerと同様に透明部分があるヘッダーだと多重に表示されてしまいますが、同様の対処方法で解決できます。StickyHeaderLinearLayoutManagerを改造するためには自分のプロジェクトにパッケージ名が被らないようにEpoxyの中にあるそれをコピーしてから行います。こちらのプルリクに実装例を作成しました。