複数のViewTypeをもつRecyclerViewをSealedClassでシンプルに作る

2018/12/17 459hit

この記事はAndroid Advent Calendar 2018の16日めです。
ヘッダーやアイテムなど複数のViewTypeをもつRecyclerViewを作りたいということはよくあります。
その時はgetItemViewTypeでPositionごとのViewTypeをIntで返し、
onCreateViewHolderでviewTypeごとのViewHolderを返し、
onBindViewHolderで各リストの要素に値をセットするという流れになります。

ところが往々にしてViewTypeは増えがちで、ある程度のルール性がないとAdapterがカオス化して、アイテムのコンテンツとViewTypeの一貫性が取れなくなったり不要なバグを生みがち。
そこで、Kotlinのsealed classを使ってコンテンツをまとめて一つの配列に格納すればコードがシンプルになるのではないかと考えてみました。

今回作ってみたもの。


次に示すようなRecyclerViewを作ってみました。
4つのViewTypeを持ち、これらをランダムに混在可能とします。

Header
要素としてStringを持つ

Detail
要素としてStringを持つ

Summary
要素としてStringとItemを持つ。

Border
要素を持たない

ViewTypeごとにコンテンツを格納するクラスを作る

リストのコンテンツを格納するクラスをViewTypeごとに作りsealed classであるListItemを継承させます。
HeaderとDetailのように同じデーター構造を持っていてもViewTypeを変えたい場合は別クラスとして作ります。
Borderのように要素をもたないViewTypeはobjectとして作ることで、単一のインスタンスしかつくられなくなるのでメモリーを節約できます。
ListItem.kt

sealed class ListItem {
class HeaderItem(val label: String) : ListItem()
class DetailItem(val value: String): ListItem()
class SummaryItem(val value: String, val count: Int):ListItem()
object BorderItem:ListItem()
}


sealed classは同一ファイル内だけでしか継承できない抽象クラスです。
これだけではいまいちメリットが見えづらいのですが、継承先が制限されることで、ListItemの具象クラスはHeaderItem,DetailItem、SummaryItem、BorderItemのどれかであることが保証され、コンパイラもそれを前提に動くことが出来ます。

Layout XMLを作る。

ViewTypeごとにレイアウトを作ります。
今回はDataBindingを使いました。
DataBindingを使えば、レイアウトをもとにViewを含むクラスを作ってくれるので、ViewHolderをいちいち作成しなくてよくなり、RecyclerViewとの相性が良いです。
item_header.xml


<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
<data>
<variable name="content" type="org.firespeed.multityperecyclerview.ListItem.HeaderItem"/>
</data>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@{content.label}"
android:padding="8dp"
android:background="@color/colorPrimary"
android:textColor="#fff"
android:textSize="20sp"
tools:text="HEADER"
/>
</layout>


item_detail.xml

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
<data>
<variable name="content" type="org.firespeed.multityperecyclerview.ListItem.DetailItem"/>
</data>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@{content.value}"
android:padding="8dp"
tools:text="LIST ITEM"
/>
</layout>


item_summary.xml

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
<data>
<variable name="content" type="org.firespeed.multityperecyclerview.ListItem.SummaryItem"/>
</data>
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content">

<TextView
android:layout_width="0dp"
android:layout_weight="1"
android:layout_height="wrap_content"
android:text="@{content.value}"
android:padding="8dp"
tools:text="Summary value"
/>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@{String.valueOf(content.count)}"
android:padding="8dp"
tools:text="Summary count"
/>

</LinearLayout>
</layout>

item_border.xml

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
<data>
<variable name="content" type="org.firespeed.multityperecyclerview.ListItem.DetailItem"/>
</data>
<View
android:layout_width="match_parent"
android:layout_height="2dp"
android:layout_margin="3dp"
android:background="@color/colorAccent"
/>
</layout>


ViewDataBindingを保持するViewHolderを作る。

RecyclerViewではスクロール時のパフォーマンスを改善するために生成したViewをViewHolderに格納してスクロールアウトしたViewを使いまわ(Recycle)しています。
そのため、各Viewを保持するViewHolderを作成する必要があるのですが、DataBindingがXMLのレイアウトをもとにViewを含むViewDataBindingクラスを自動生成してくれるのでこれを保持するViewHolderを作ることで代用することが出来ます。
各ViewDataBindingクラスはXMLのファイル名をCamelCaseにしてその後にBindingとつけた名前で生成されます。
例えば item_header.xmlの場合ItemHeaderBindingクラスとして生成されます。
各BindingクラスはViewDataBindingを継承しているため、ViewHolderは親クラスのViewDataBindingを保持することで全てのViewTypeのBindingを格納することが出来ます。

DataBindingUtil.bind()の戻り値はNullableですが、ViewにNullを渡さない限りはNullにならないはずなので!!でNot Nullに強制的に強制的に(大事なことなので2回言いました)置き換えます。
他にいい方法がないかと思ったんですが、Null Pointer ExceptionをThrowするくらいしか考えつかなかった!!です。
BindingViewHolder.kt

class BindingViewHolder(v: View) : RecyclerView.ViewHolder(v) {
val binding:ViewDataBinding = DataBindingUtil.bind(v)!!
}


Adapterを作る。

それでは、いよいよAdapterを作ります。
ListItemArrayListをプロパティとして保持します。


class MultiTypeAdapter(initialItem: List<ListItem>) : RecyclerView.Adapter<BindingViewHolder>() {
private val contents: MutableList<ListItem> = ArrayList(initialItem)


Recycler ViewはViewTypeをIntで管理しているため、companion objectでViewTypeに一致するIntを作ります。

companion object {
private const val VIEW_TYPE_HEADER = 1
private const val VIEW_TYPE_DETAIL = 2
private const val VIEW_TYPE_SUMMARY = 3
private const val VIEW_TYPE_BORDER = 4
}

すべての要素を一つのListに格納しているためgetItemCount()の実装は簡単です。

override fun getItemCount() = contents.size

getItemViewType()はList内の型から判断します。
sealed classとして作っているためWhenではelseが不要ですし、elseを書かないことで今後ListItemを継承するクラスが増えた場合にコンパイラがAdapterの対応を強制してくれます。
この処理をAdapterに書くか、ListItemに書くかは悩ましいのですがViewTypeをIntで管理するのはRecyclerViewの実装寄りに近い都合なのでAdapterに書くことにします。

override fun getItemViewType(position: Int): Int {
return when (contents[position]) {
is ListItem.HeaderItem -> VIEW_TYPE_HEADER
is ListItem.DetailItem -> VIEW_TYPE_DETAIL
is ListItem.SummaryItem -> VIEW_TYPE_SUMMARY
is ListItem.BorderItem -> VIEW_TYPE_BORDER
}
}


viewTypeをもとにレイアウトのIDを取得します。
viewTypeがIntで管理されるので網羅が出来ないのが残念ですが、viewTypeはgetItemViewType()で返した値しか戻ってこないため、ここで例外が発生することはないはずです。

private fun getLayoutRes(viewType: Int) =
when (viewType) {
VIEW_TYPE_HEADER -> R.layout.item_header
VIEW_TYPE_DETAIL -> R.layout.item_detail
VIEW_TYPE_SUMMARY -> R.layout.item_summary
VIEW_TYPE_BORDER -> R.layout.item_boarder
else -> throw IllegalArgumentException("Unknown viewType $viewType") // この処理は呼ばれない
}


onCreateViewHolderでViewをInflateして、BindingViewHolder内でBindされた値をViewHolderとして保持します。

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
BindingViewHolder(LayoutInflater.from(parent.context).inflate(getLayoutRes(viewType), parent, false))

onBindViewHolder内でviewに値をセットします。値のセットはDataBinding内で行っているためここでは適切な型にcastして値をセットしているだけです。
どの型にCastすべきかは、getItemViewTypeと同様に、contents[position]の型をもとに決めます。
DataBindingの各型がどのListItemの型を受け付けるかはレイアウトXMLの方でも記載しているため、ここで、間違って本来の型に一致しない型を指定したとしてもコンパイラーがエラーとして検出してくれます。

override fun onBindViewHolder(holder: BindingViewHolder, position: Int) {
val item = contents[position]
when (item) {
is ListItem.HeaderItem -> (holder.binding as ItemHeaderBinding).content = item
is ListItem.DetailItem -> (holder.binding as ItemDetailBinding).content = item
is ListItem.SummaryItem -> (holder.binding as ItemSummaryBinding).content = item
}
}

contentsを外部から制御するためのメソッドを書きます。
今回はアイテムを追加するメソッドを書くことにします。
この辺は必要に応じて適宜、良い感じに仕上げてください。

fun addItem(item: ListItem) {
val addIndex = contents.size
contents.add(item)
notifyItemInserted(addIndex)
}


adapter全体はこんなふうになりました。
MultiTypeAdapter.kt

class MultiTypeAdapter(initialItem: List<ListItem>) : RecyclerView.Adapter<BindingViewHolder>() {
private val contents: MutableList<ListItem> = ArrayList(initialItem)

companion object {
private const val VIEW_TYPE_HEADER = 1
private const val VIEW_TYPE_DETAIL = 2
private const val VIEW_TYPE_SUMMARY = 3
private const val VIEW_TYPE_BORDER = 4
}

override fun getItemCount() = contents.size
override fun getItemViewType(position: Int): Int {
return when (contents[position]) {
is ListItem.HeaderItem -> VIEW_TYPE_HEADER
is ListItem.DetailItem -> VIEW_TYPE_DETAIL
is ListItem.SummaryItem -> VIEW_TYPE_SUMMARY
is ListItem.BorderItem -> VIEW_TYPE_BORDER
}
}

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
BindingViewHolder(LayoutInflater.from(parent.context).inflate(getLayoutRes(viewType), parent, false))

private fun getLayoutRes(viewType: Int) =
when (viewType) {
VIEW_TYPE_HEADER -> R.layout.item_header
VIEW_TYPE_DETAIL -> R.layout.item_detail
VIEW_TYPE_SUMMARY -> R.layout.item_summary
VIEW_TYPE_BORDER -> R.layout.item_boarder
else -> throw IllegalArgumentException("Unknown viewType $viewType") // この処理は呼ばれない
}


override fun onBindViewHolder(holder: BindingViewHolder, position: Int) {
val item = contents[position]
when (item) {
is ListItem.HeaderItem -> (holder.binding as ItemHeaderBinding).content = item
is ListItem.DetailItem -> (holder.binding as ItemDetailBinding).content = item
is ListItem.SummaryItem -> (holder.binding as ItemSummaryBinding).content = item
}
}

fun addItem(item: ListItem) {
val addIndex = contents.size
contents.add(item)
notifyItemInserted(addIndex)
}
}


Activityを作る。
動作を確認するためにActivityを追加してみます。
activity_main.xml


<?xml version="1.0" encoding="utf-8"?>
<layout 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">

<data>
<variable
name="activity"
type="org.firespeed.multityperecyclerview.MainActivity" />
</data>

<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">

<Button
android:id="@+id/header"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Header"
app:layout_constraintBottom_toTopOf="@+id/barrier"
app:layout_constraintEnd_toStartOf="@+id/detail"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />

<Button
android:id="@+id/detail"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Detail"
app:layout_constraintBottom_toTopOf="@+id/barrier"
app:layout_constraintEnd_toStartOf="@id/summary"
app:layout_constraintStart_toEndOf="@id/header"
app:layout_constraintTop_toTopOf="parent" />

<Button
android:id="@+id/summary"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Summary"
app:layout_constraintBottom_toTopOf="@+id/barrier"
app:layout_constraintEnd_toEndOf="@+id/border"
app:layout_constraintStart_toEndOf="@id/detail"
app:layout_constraintTop_toTopOf="parent " />
<Button
android:id="@+id/border"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Border"
app:layout_constraintBottom_toTopOf="@+id/barrier"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/summary"
app:layout_constraintTop_toTopOf="parent " />

<androidx.constraintlayout.widget.Barrier
android:id="@+id/barrier"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
app:barrierDirection="bottom"
app:constraint_referenced_ids="summary,header,detail" />

<androidx.recyclerview.widget.RecyclerView
android:layout_width="0dp"
android:layout_height="0dp"
android:id="@+id/list"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/barrier" />

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

MainActivity.kt


class MainActivity : AppCompatActivity() {

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val list = ArrayList<ListItem>()
val binding: ActivityMainBinding = DataBindingUtil.setContentView(this, R.layout.activity_main)
binding.activity = this
val adapter = MultiTypeAdapter(list)
binding.list.adapter = adapter
binding.list.layoutManager = LinearLayoutManager(this)
binding.header.setOnClickListener { adapter.addItem(createHeader()) }
binding.detail.setOnClickListener { adapter.addItem(createDetail()) }
binding.summary.setOnClickListener { adapter.addItem(createSummary()) }
binding.border.setOnClickListener { adapter.addItem(ListItem.BorderItem) }
}

private fun createHeader() = ListItem.HeaderItem(arrayOf("日曜日", "月曜日", "火曜日", "水曜日", "木曜日", "金曜日", "土曜日").random())
private fun createDetail() = ListItem.DetailItem(arrayOf("カレー", "ちゃんぽん", "うどん").random())
private fun createSummary() = ListItem.SummaryItem(arrayOf("地球", "太陽", "月").random(), (Math.random() * 100).toInt())

}


ヘッダーのボタンを押すことで好きなViewTypeを追加していくことが出来ます。
この方法なら今後ViewTypeが増えてもAdapterは過度にカオス化せずにシンプルに実装できるのではないでしょうか。
サンプルコード


前:自作キーボードで肩こりが激減した話 次:Kotlinでif let elseをやりたいときはletでなくalsoを使おう

関連キーワード

[Android][IT]

コメントを投稿する

名前URI
コメント