tl;dr
- UIのリスト表示は、{RecyclerView + androidx.recyclerview.widget.ListAdapter + DataBinding + LiveData + ViewModel} on Kotlinで構築する
- ListAdapterに引数として何を渡すかは要件(やりたいこと)による。最小ケースでは引数なしもありえる
- executePendingBindings() is 大事
- LayoutManager is 大事
- 今回のサンプルのリポジトリー:https://github.com/quwac/howtouserecyclerview2020
この記事は?
RecyclerViewの使い方のメモです。
2020年時点のRecyclerView実装の記事の中ではベストな方法、かつユースケースが網羅的になるように書きます。
個々の技術要素の使い方よりも技術要素同士をどう組み合わせるかに主眼を置いています。
DataBinding、LiveData、ViewModel、Kotlinそのものの使い方については別途検索することをおすすめします。
作るもの
一般的な(?)リストUIです。
+ボタンを押すとリストの要素が追加されていきます。
リストの要素をタップすることができます。
タップにはリップルエフェクト(タップした点を中心に、もわぁ〜んと広がるUI効果)がついています。
特色としてリストの各要素にスイッチをつけています。
ユーザー操作によってスイッチのON/OFFが切り替わると、即座にデータにも反映されます。
✅ボタンを押すとリストの全要素のスイッチがONになります。
実装
build.gradle
モジュールの方のbuild.gradleです。
...
+ // 後述するlifecycle-common-java8のために必要
+ apply plugin: "kotlin-kapt"
android {
...
+ // DataBindingを有効にする。
+ // 少し前まではdataBinding.enabled = trueだったが最近はその書き方はdeprecatedになった。
+ buildFeatures.dataBinding = true
...
+ // DataBindingによる自動生成コードはJava 8以上でないと生成できないため指定する。
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_1_8
+ targetCompatibility JavaVersion.VERSION_1_8
+ }
+
+ kotlinOptions.jvmTarget = "1.8"
...
}
...
dependencies {
...
+ // RecyclerViewを使うために追加
+ // https://developer.android.com/jetpack/androidx/releases/recyclerview?hl=ja
+ implementation 'androidx.recyclerview:recyclerview:1.1.0'
+ // ViewModelとLiveDataを使うために追加
+ // https://developer.android.com/jetpack/androidx/releases/lifecycle?hl=ja
+ // 数が多くて漏れが生じやすいので必ず上のリファレンスからコピペする。
+ // コピペ時はlifecycle-common-java8だけ注意する(後述)。
+ // Android Studioでプロジェクトを新規作成するとデフォルトの構成でなんとなくビルドが通ってしまうことがあるが、
+ // 正常に動作しないので必ず手で追加する。
+ def lifecycle_version = "2.2.0"
+ implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
+ implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"
+ // Java 8を使う時はlifecycle-compilerはdependenciesに加えず、lifecycle-common-java8を加える。
+ // また上記リファレンスだとimplementationになっているが、kaptに変更する。
+ kapt "androidx.lifecycle:lifecycle-common-java8:$lifecycle_version"
...
+ // 以下は本筋とは関係ないが要るライブラリー
+
+ // Fragment内でviewModels関数を使うために追加
+ implementation "androidx.fragment:fragment-ktx:1.2.5"
+ // ConstraintLayoutを使うために追加
+ implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
+ // SwitchMaterialを使うために追加
+ implementation 'com.google.android.material:material:1.2.0'
}
エンティティクラス User
エンティティクラス User
です。リストに表示される要素に対応します。
data class User(
val id: Long,
val name: String,
val isChecked: MutableLiveData<Boolean> = MutableLiveData(false)
) {
...
}
ユーザー操作によるスイッチのON/OFFは即座に反映させたいので、MutableLiveDataの isChecked
で持たせています。
ちなみに、即座に反映させるための仕組みとしてかつては ObservableBoolean
や ObservableField
といった BaseObservable
がありましたが、2020年現在は LiveData
に取って代わられています。
最早 BaseObservable
を新規に使うことはないでしょう。
ViewModelクラス MainViewModel
ViewModelクラス MainViewModel
です。 ListAdapter
に渡すリストを抱えています。
リスト表示や要素の状態変更に関係のないコードは端折っています。
class MainViewModel : ViewModel() {
private val usersRaw = mutableListOf<User>()
private val _users = MutableLiveData<List<User>>(emptyList())
val users: LiveData<List<User>> = _users.distinctUntilChanged()
...
fun addElement() {
// userの作成
// ...
usersRaw.add(user)
_users.value = ArrayList(usersRaw)
// ...
}
fun checkAll() {
usersRaw.forEach {
it.isChecked.value = true
}
}
fun onClickItem(item: User) {
// ...
}
}
onClickItem
に注目してください。
今回、リスト要素のタップイベント(コールバック)を受け取るのはViewModelになります。
つぎの user_view.xml
でも説明します。
レイアウトファイル main_fragment.xml
、 user_view.xml
レイアウトファイル main_fragment.xml
、 user_view.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="viewModel"
type="<package>.ui.main.MainViewModel" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/main"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.main.MainFragment">
...
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/list"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/textView">
</androidx.recyclerview.widget.RecyclerView>
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/floatingActionButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp"
android:clickable="true"
android:focusable="true"
android:onClick="@{() -> viewModel.addElement()}"
android:src="@drawable/ic_baseline_add_24"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/floatingActionButton2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp"
android:clickable="true"
android:focusable="true"
android:onClick="@{() -> viewModel.checkAll()}"
android:src="@drawable/ic_baseline_check_24"
app:layout_constraintBottom_toTopOf="@+id/floatingActionButton"
app:layout_constraintEnd_toEndOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
main_fragment.xml
は特別なことはしていません。
いつもどおりに variable viewModel
のプロパティや関数を "@{...}"
でbindingしています。
<?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">
<data>
<variable
name="user"
type="io.github.quwac.how_to_use_recyclerview_2020.ui.main.User" />
<variable
name="viewModel"
type="io.github.quwac.how_to_use_recyclerview_2020.ui.main.MainViewModel" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp"
android:background="?android:attr/selectableItemBackground"
android:onClick="@{() -> viewModel.onClickItem(user)}">
<TextView
android:id="@+id/name"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@{user.name}"
android:textAppearance="@style/TextAppearance.AppCompat.Large"
app:layout_constraintEnd_toStartOf="@+id/isEnabled"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/isEnabled"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="64dp"
android:checked="@={user.isChecked}"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
一方、 user_view.xml
は少し目新しさがあるかもしれません(?)。
variableとして user
だけでなく viewModel
も指定しています。これはリスト要素タップのコールバッククラスとして MainViewModel
を指定しているためです。どの要素がタップされたかは、user
を指定します。これにより、レイアウトファイルだけでタップのコールバック処理を完結させることができます。
その他に着目すべき点としては isEnable
の android:checked
に指定された user.isChecked
でしょうか。
前述したとおり、 User#isChecked
は MutableLiveData<Boolean>
です。
MutableLiveDataを意図したとおり動作させるためには、bindingオブジェクトに LifecycleOwner
をセットしてやる必要があり、そのため UserListAdapter
に LifecycleOwner
オブジェクトを渡してやる必要があります。これは後ほど説明します。
リップルエフェクトは user_view.xml
で指定します。
ルート要素のレイアウトに android:background="?android:attr/selectableItemBackground"
を指定してください。
ListAdapterクラス UserListAdapter
、 DiffUtil.ItemCallback
、 ViewHolderクラス UserViewHolder
ListAdapterクラス UserListAdapter
です。androidx.recyclerview.widget.ListAdapter
の継承クラスになります。
ようやく本丸です。
ネット検索をしていると、アダプタークラスについては冗長な実装や古いやり方の実装を多数見かけます。
「個々の技術要素の使い方は説明しない」と書きましたが、ここでは少しだけ丁寧に見ていきます。
はじめにファイルの全景を引用します。
ここではobject DiffCallback
と ViewHolderクラス UserViewHolder
も定義しています。
private object DiffCallback : DiffUtil.ItemCallback<User>() {
override fun areItemsTheSame(oldItem: User, newItem: User): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItem: User, newItem: User): Boolean {
return oldItem == newItem
}
}
class UserListAdapter(
private val viewLifecycleOwner: LifecycleOwner,
private val viewModel: MainViewModel
) : ListAdapter<User, UserListAdapter.UserViewHolder>(DiffCallback) {
class UserViewHolder(private val binding: UserViewBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(item: User, viewLifecycleOwner: LifecycleOwner, viewModel: MainViewModel) {
binding.run {
lifecycleOwner = viewLifecycleOwner
user = item
this.viewModel = viewModel
executePendingBindings()
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): UserViewHolder {
val layoutInflater = LayoutInflater.from(parent.context)
return UserViewHolder(UserViewBinding.inflate(layoutInflater, parent, false))
}
override fun onBindViewHolder(holder: UserViewHolder, position: Int) {
holder.bind(getItem(position), viewLifecycleOwner, viewModel)
}
}
ListAdapterとDiffUtil.ItemCallback
ListAdapter
は RecyclerView.Adapter
を継承したクラスです。
かつては RecyclerView.Adapter
を継承するのが主流でしたが、現在はゴリゴリにパフォーマンスチューニングするのでもない限り、 ListAdapter
を継承するのが一般的です。
というのも、かつてのアダプタークラスといえば
- ①overrideするべき関数がたくさんある
- ②
val items: List<User>
のように、リストUIに表示する要素のリスト変数をアダプタークラス内で別途保持しなければならない- ③あまつさえ、外部クラスからの要請に応えリスト変数の中身をいい感じにデータ管理する必要がある
- ④
notifyDataSetChanged
やnotifyItemXXX
関数を適切に呼ばないと処理が遅かったりリスト更新のアニメーションがいまいち
という非常に開発者泣かせのクラスでしたが、 ListAdapter
では
- ① -> 最低限overrideしなくてはいけない関数は2つだけになった
- ②、③ -> リスト変数を保持する必要がなくなった
- ④ -> notifyDataSetChangedもnotifyItemXXXも一切呼ぶ必要がなくなった
というように、劇的な改善が果たされました。ここに飛びつかない理由はありません。
必要なくなった処理はすべて ListAdapter
内で受け持ってくれるようになりました。その代わりに DiffUtil.ItemCallback
型の変数をコンストラクタに渡す必要ができました。
class UserListAdapter(
...
) : ListAdapter<User, UserListAdapter.UserViewHolder>(DiffCallback) {
DiffUtil.ItemCallback
は2つの要素を比較するユーティリティクラスです。ListAdapter
は要素の追加・変更・削除をこのユーティリティを使うことによって検知し処理してします。
private object DiffCallback : DiffUtil.ItemCallback<User>() {
override fun areItemsTheSame(oldItem: User, newItem: User): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItem: User, newItem: User): Boolean {
return oldItem == newItem
}
}
areItemsTheSame
は「2つの要素の識別子の同一性」を調べる関数です。多くの場合、エンティティクラスはidプロパティを持っているはずなのでそれを比較すればよいです。
areContentsTheSame
は「2つの要素が保持するプロパティの同一性」を調べる関数です。要するにdata classなら==で比較するだけです。
ちなみに、RecyclerView登場以後〜ListAdapter登場前のアダプタークラスの要素比較処理は DiffUtil.Callback
クラスを使って実装するのが一般的でしたが、 ListAdapter
と DiffUtil.**Item**Callback
の登場によって見かける機会は激減しました。
DiffUtil.Callback
ではユーザーが変更前後の要素リストを見比べて変更状態を自前で実装しなければならなかったのに対して、 DiffUtil.**Item**Callback
では単純に要素同士の同一性を実装するだけでよいので、やはりここでも手間が激減しています。
ListAdapterとViewHolder
DataBindingを使用する場合の ViewHolder
はBindingクラスをプロパティとして持たせてやるようにし、ViewHolderの操作はBindingクラスを主体に考えます。
class UserViewHolder(private val binding: UserViewBinding) :
RecyclerView.ViewHolder(binding.root)
UserListAdapter#onCreateViewHolder
では、素直にUserViewHolderを作ってやればOKです。
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): UserViewHolder {
val layoutInflater = LayoutInflater.from(parent.context)
return UserViewHolder(UserViewBinding.inflate(layoutInflater, parent, false))
}
UserListAdapter#onBindViewHolder
では、正味の処理は UserViewHolder
に委譲してしまいます。具体的には UserViewHolder#bind
関数を呼ぶだけです。
override fun onBindViewHolder(holder: UserViewHolder, position: Int) {
holder.bind(getItem(position), viewLifecycleOwner, viewModel)
}
もう一度 UserViewHolder
に戻り、bind関数の中身を見てみましょう。
fun bind(item: User, viewLifecycleOwner: LifecycleOwner, viewModel: MainViewModel) {
binding.run {
lifecycleOwner = viewLifecycleOwner
user = item
this.viewModel = viewModel
executePendingBindings()
}
}
いつもActivityクラスやFragmentクラスでやるように、bindingオブジェクトに対して値をセットしてやります。
最後に executePendingBindings()
を呼ぶのを忘れないでください。executePendingBindings()がないとbind関数が呼ばれても画面反映が即座に行われません。筆者はよく忘れます。
なお、今回のリストUIは、
- UI(スイッチ)のON/OFF操作とUser#isCheckedの反映を即座に行いたい -> LiveDataをbindingするためlifecycleOwnerが必要
- リスト要素のタップイベントを取得したい -> viewModelが必要
という要件があるので、リスト要素と直接ひも付く item: User
のほかに、viewLifecycleOwner: LifecycleOwner
、 viewModel: MainViewModel
が引数として登場することになり、bindingにセットしています。
class UserListAdapter(
private val viewLifecycleOwner: LifecycleOwner,
private val viewModel: MainViewModel
) : ListAdapter<User, UserListAdapter.UserViewHolder>(DiffCallback) {
UserListAdapter
のコンストラクタ引数に viewLifecycleOwner
と viewModel
を指定したのは巡り巡って UserViewHolder#bind
で使用するためです。
逆にこれらの要件がない場合、UserListAdapter
のコンストラクタ引数にこれらの値は必要ありません。
- リスト要素は操作UIを持たない(=エンティティクラスのプロパティの値を書き換えない、または即時反映不要) -> lifecycleOwnerは不要
- リスト要素のタップイベントは不要 -> viewModelは不要
ListAdapterのクラスを新造する場合、既存のクラスをコピペして作りがちですが、引数のLifecycleOwnerやViewModelは必要に応じて削りましょう。
Fragmentクラス MainFragment
最後にFragmentクラスです。
class MainFragment : Fragment() {
...
private val viewModel: MainViewModel by viewModels()
private lateinit var userListAdapter: UserListAdapter
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
return MainFragmentBinding.inflate(inflater, container, false)
.apply {
...
list.run {
layoutManager = LinearLayoutManager(context)
addItemDecoration(DividerItemDecoration(context, DividerItemDecoration.VERTICAL))
adapter = UserListAdapter(viewLifecycleOwner, this@MainFragment.viewModel).also {
userListAdapter = it
}
}
}
.run {
root
}
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
viewModel.run {
users.observe(viewLifecycleOwner, {
userListAdapter.submitList(it)
})
...
}
}
}
といっても UserListAdapter
ほどの驚きはありません。
onCreateView
では MainFragmentBinding
を生成し、 viewLifecycleOwner
、 viewModel
をセットします。
list
(RecyclerView)にはいつもどおり LayoutManager
、ItemDecoration
をセットしてから UserListAdapter
をセットしてやります。
ちなみに、 LayoutManager
のセットを忘れるといくら UserListAdapter
に値を渡しても描画が行われません 。筆者はよくやらかします。
onActivityCreated
では MainViewModel
の users
をobserveします。
もちろん、observeされた時の処理は UserListAdapter
への通知です。
ListAdapter
には submitList
という関数が用意されているのでこれを呼び出します。前述のとおり、 notifyDataSetChanged
も notifyItemXXX
も呼び出す必要はありません。
完全なサンプル
終わりに
メモのつもりだったのに思いのほか長くなってしまった。。。
途中かなり端折ったので意味不明になっている箇所があればコメントをお願いします。
もちろん「そのやり方間違っているよ」も歓迎です。
2021/02/13追記
続編記事を書きました。