LoginSignup
84
67

More than 3 years have passed since last update.

2020年版RecyclerViewの使い方 〜 RecyclerView + ListAdapter + DataBinding + LiveData + ViewModel 〜

Last updated at Posted at 2020-08-24

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そのものの使い方については別途検索することをおすすめします。

作るもの

video.gif

一般的な(?)リストUIです。
+ボタンを押すとリストの要素が追加されていきます。

リストの要素をタップすることができます。
タップにはリップルエフェクト(タップした点を中心に、もわぁ〜んと広がるUI効果)がついています。

特色としてリストの各要素にスイッチをつけています。
ユーザー操作によってスイッチのON/OFFが切り替わると、即座にデータにも反映されます。
✅ボタンを押すとリストの全要素のスイッチがONになります。

実装

build.gradle

モジュールの方の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 です。リストに表示される要素に対応します。

User.kt
data class User(
    val id: Long,
    val name: String,
    val isChecked: MutableLiveData<Boolean> = MutableLiveData(false)
) {
    ...
}

ユーザー操作によるスイッチのON/OFFは即座に反映させたいので、MutableLiveDataの isChecked で持たせています。

ちなみに、即座に反映させるための仕組みとしてかつては ObservableBooleanObservableField といった BaseObservable
がありましたが、2020年現在は LiveData に取って代わられています。
最早 BaseObservable を新規に使うことはないでしょう。

ViewModelクラス MainViewModel

ViewModelクラス MainViewModel です。 ListAdapter に渡すリストを抱えています。
リスト表示や要素の状態変更に関係のないコードは端折っています。

MainViewModel.kt
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.xmluser_view.xml

レイアウトファイル main_fragment.xmluser_view.xml です。
それぞれ、画面のレイアウト、リスト要素のレイアウトになります。

main_fragment.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しています。

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">

    <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 を指定します。これにより、レイアウトファイルだけでタップのコールバック処理を完結させることができます。

その他に着目すべき点としては isEnableandroid:checked に指定された user.isChecked でしょうか。
前述したとおり、 User#isCheckedMutableLiveData<Boolean> です。
MutableLiveDataを意図したとおり動作させるためには、bindingオブジェクトに LifecycleOwner をセットしてやる必要があり、そのため UserListAdapterLifecycleOwner オブジェクトを渡してやる必要があります。これは後ほど説明します。

リップルエフェクトは user_view.xml で指定します。
ルート要素のレイアウトに android:background="?android:attr/selectableItemBackground" を指定してください。

ListAdapterクラス UserListAdapterDiffUtil.ItemCallback 、 ViewHolderクラス UserViewHolder

ListAdapterクラス UserListAdapter です。androidx.recyclerview.widget.ListAdapter の継承クラスになります。

ようやく本丸です。

ネット検索をしていると、アダプタークラスについては冗長な実装や古いやり方の実装を多数見かけます。
「個々の技術要素の使い方は説明しない」と書きましたが、ここでは少しだけ丁寧に見ていきます。

はじめにファイルの全景を引用します。
ここではobject DiffCallback と ViewHolderクラス UserViewHolder も定義しています。

UserListAdapter.kt
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

ListAdapterRecyclerView.Adapter を継承したクラスです。
かつては RecyclerView.Adapter を継承するのが主流でしたが、現在はゴリゴリにパフォーマンスチューニングするのでもない限り、 ListAdapter を継承するのが一般的です。
というのも、かつてのアダプタークラスといえば

  • ①overrideするべき関数がたくさんある
  • val items: List<User> のように、リストUIに表示する要素のリスト変数をアダプタークラス内で別途保持しなければならない
    • ③あまつさえ、外部クラスからの要請に応えリスト変数の中身をいい感じにデータ管理する必要がある
  • notifyDataSetChangednotifyItemXXX 関数を適切に呼ばないと処理が遅かったりリスト更新のアニメーションがいまいち

という非常に開発者泣かせのクラスでしたが、 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 クラスを使って実装するのが一般的でしたが、 ListAdapterDiffUtil.**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: LifecycleOwnerviewModel: MainViewModel が引数として登場することになり、bindingにセットしています。

class UserListAdapter(
    private val viewLifecycleOwner: LifecycleOwner,
    private val viewModel: MainViewModel
) : ListAdapter<User, UserListAdapter.UserViewHolder>(DiffCallback) {

UserListAdapter のコンストラクタ引数に viewLifecycleOwnerviewModel を指定したのは巡り巡って UserViewHolder#bind で使用するためです。
逆にこれらの要件がない場合、UserListAdapter のコンストラクタ引数にこれらの値は必要ありません。

  • リスト要素は操作UIを持たない(=エンティティクラスのプロパティの値を書き換えない、または即時反映不要) -> lifecycleOwnerは不要
  • リスト要素のタップイベントは不要 -> viewModelは不要

ListAdapterのクラスを新造する場合、既存のクラスをコピペして作りがちですが、引数のLifecycleOwnerやViewModelは必要に応じて削りましょう。

Fragmentクラス MainFragment

最後にFragmentクラスです。

MainFragment.kt
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 を生成し、 viewLifecycleOwnerviewModel をセットします。
list(RecyclerView)にはいつもどおり LayoutManagerItemDecoration をセットしてから UserListAdapter をセットしてやります。
ちなみに、 LayoutManager のセットを忘れるといくら UserListAdapter に値を渡しても描画が行われません 。筆者はよくやらかします。

onActivityCreated では MainViewModelusers をobserveします。
もちろん、observeされた時の処理は UserListAdapter への通知です。
ListAdapter には submitList という関数が用意されているのでこれを呼び出します。前述のとおり、 notifyDataSetChangednotifyItemXXX も呼び出す必要はありません。

完全なサンプル

終わりに

メモのつもりだったのに思いのほか長くなってしまった。。。
途中かなり端折ったので意味不明になっている箇所があればコメントをお願いします。
もちろん「そのやり方間違っているよ」も歓迎です。

2021/02/13追記

続編記事を書きました。

【続】2020年版RecyclerViewの使い方 〜リストのアイテムに複数のレイアウトを使う〜

84
67
8

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
84
67