RecylerViewの実装方法まとめ（その①）

[Navigation](https://qiita.com/kame_yang/items/131fa044dd8880bc3379)、[ViewModel](https://qiita.com/kame_yang/items/a88c316b5fd90c212d78)、[Room](https://qiita.com/kame_yang/items/b5e2297fdf489919d9be)に続き、今回は`RecyclerView`の実装方法をまとめていきます。

# この記事の内容
公式の[ドキュメント](https://developer.android.com/guide/navigation?hl=ja)やトレーニング「[Android Kotlin の基礎](https://developer.android.com/courses/kotlin-android-fundamentals/overview?hl=ja)」のレッスン7「RecyclerView」を参考に実装のポイントとエッセンスをまとめていきます。
今回は長いので第一回、第二回の2回に分けて解説していきます。

### 前提知識

* kotlinの基礎的な文法
* AndroidStudioの使い方／アプリの作り方
* 画面や画面部品の配置方法
* `Navigation`の実装方法（[こちらの記事](https://qiita.com/kame_yang/items/131fa044dd8880bc3379)参照）
* `ViewModel`の実装方法（[こちらの記事](https://qiita.com/kame_yang/items/a88c316b5fd90c212d78)参照）
* `Room`の実装方法（[こちらの記事](https://qiita.com/kame_yang/items/b5e2297fdf489919d9be)参照）

### 開発環境

* Windows 10 Home
* Android Studio 4.2.1

# 作成するサンプル
代り映えしなくて恐縮ですが今回も単語帳のアプリを作成します。
**Import**ボタンのクリックで単語一覧を内部で生成し、DBに登録するとともに`RecyclerView`に登録します。**Delete**ボタンで削除。
`Room`を使っているのでアプリを再起動してもちゃんと表示する内容が保持されていますね。
![サンプル.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1073313/213fe842-96ee-18df-9aeb-5bffaa930440.gif)
このサンプルだとあんまり`RecyclerView`のメリットが伝わりませんね……。その辺は次回。

# RecyclerViewの作成
それでは`RecyclerView`を実装していきます。

## build.gradel(app)ファイルの更新
`RecyclerView`用の`implementation`を`dependencies`ブロックに追加。

```gradle
dependencies {
    // （略）
    // RecyclerView
    implementation 'androidx.recyclerview:recyclerview:1.0.0'
}
```

## RecyclerViewの配置
`Fragment`か`Activity`に`RecyclerView`を配置します。
**layoutManager**の設定が必要ですが、今回はオーソドックスに**LinearLayout**を指定します。

```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">
    <LinearLayout/>
        <!--略-->
        <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/word_list"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"/>
    </LinearLayout>
</layout>
```

## ListItemの作成
`RecyclerView`内に表示する`ListItem`を作成します。
新規にレイアウトファイルを作ります。今回は単語の名前と品詞のアイコン、お気に入り登録されているかの有無を表すアイコンで構成された画面部品を使用します。

```xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="horizontal"
    android:layout_width="match_parent"
    android:layout_height="50dp">

    <TextView
        android:id="@+id/word_name_text"
        android:layout_width="0dp"
        android:layout_height="25dp"
        android:layout_weight="1"
        android:layout_marginLeft="10dp"
        android:layout_marginTop="15dp"
        android:layout_marginBottom="15dp"
        android:textSize="16sp"/>

    <ImageView
        android:id="@+id/word_speech_image"
        android:layout_width="0dp"
        android:layout_weight="0.15"
        android:layout_height="25dp"
        android:layout_marginTop="15dp"
        android:layout_marginBottom="15dp"/>

    <ImageView
        android:id="@+id/word_favorite_image"
        android:layout_width="0dp"
        android:layout_weight="0.15"
        android:layout_height="25dp"
        android:layout_marginTop="15dp"
        android:layout_marginBottom="15dp"
        android:layout_marginRight="10dp"/>
</LinearLayout>
```

## ViewHolderクラスを作成。
`ListItem`の**ViewHolder**クラスを作成。後述の**Adapter**クラスの内部に作成します。

```kotlin
    // ViewHolderクラス
    class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
        val wordNameText: TextView = itemView.findViewById(R.id.word_name_text)
        val wordSpeechImage: ImageView = itemView.findViewById(R.id.word_speech_image)
        val wordFavoriteImage: ImageView = itemView.findViewById(R.id.word_favorite_image)
    }
```

## Adapterクラスの作成
表示元のデータオブジェクトを`RecyclerView`に設定するための**Adapter**クラスを作成します。
**Adapter**クラスでは以下の定義が必要です。

* `RecyclerView`に表示するデータオブジェクト
* 表示対象データの要素数を返す**getItemCount()**のオーバーライド
* RecyclerViewの所定の位置に所定のデータを表示する**onBindViewHolder()**のオーバーライド
* **ViewHolder**を生成する**onCreateViewHolder()**のオーバーライド

あわせて**ViewHolder**クラスを拡張します。

```kotlin
class WordDataAdapter: RecyclerView.Adapter<WordDataAdapter.ViewHolder>() {

    // RecyclerViewに表示するデータオブジェクト
    var data = listOf<WordData>()
        set(value) {
            field = value
            // データ変更時に自動で再描画するための設定
            notifyDataSetChanged()
        }

    // 表示対象データの要素数を返す
    override fun getItemCount() = data.size

    // RecyclerViewの所定の位置に所定のデータを表示するためのメソッド
    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        val item = data[position]
        holder.bind(item)
    }

    //　ViewHolderを生成するメソッド
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        return ViewHolder.from(parent)
    }

    // ViewHolderクラス
    class ViewHolder private constructor(itemView: View) : RecyclerView.ViewHolder(itemView) {
        val wordNameText: TextView = itemView.findViewById(R.id.word_name_text)
        val wordSpeechImage: ImageView = itemView.findViewById(R.id.word_speech_image)
        val wordFavoriteImage: ImageView = itemView.findViewById(R.id.word_favorite_image)

        fun bind(item: WordData) {
            // ViewHolderの各要素に値を設定
            wordNameText.text = item.word
            wordSpeechImage.setImageResource(when(item.speech) {
                // 品詞に応じて設定するアイコンを切り替え
                "助" -> { R.drawable.speech_auxiliary }
                "動" -> { R.drawable.speech_verb }
                "不" -> { R.drawable.speech_infinitive }
                "副" -> { R.drawable.speech_adverb }
                "構" -> { R.drawable.speech_syntax }
                "接" -> { R.drawable.speech_conjunction }
                "形" -> { R.drawable.speech_adjective }
                "名" -> { R.drawable.speech_noun }
                "代" -> { R.drawable.speech_pronoun }
                "前" -> { R.drawable.speech_preposition }
                "定" -> { R.drawable.speech_template }
                else -> { R.drawable.speech_none }
            })
            wordFavoriteImage.setImageResource(when(item.favorite) {
                true -> R.drawable.favorite_on
                else -> R.drawable.favorite_off
            })
        }

        companion object {
            fun from(parent: ViewGroup): ViewHolder {
                // LayoutInflaterインスタンスを作成
                val layoutInflater = LayoutInflater.from(parent.context)
                val view = layoutInflater.inflate(R.layout.list_item, parent, false)
                return ViewHolder(view)
            }
        }
    }
}
```

## Adapterクラスの登録
`RecyclerView`を使用する_**Fragment.kt**_ファイルで、**Adapter**を生成し対象の`RecyclerView`に登録します。
（ついでに`Observer`も）

```kotlin
class WordDisplayFragment : Fragment() {
    // （略）
    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        // （略）

        // Adapterインスタンスの生成
        val adapter = WordDataAdapter()
        // Bindingを設定
        binding.wordList.adapter = adapter
        // Observerの登録
        wordDisplayViewModel.words.observe(viewLifecycleOwner, Observer {
            it?.let {
                adapter.data = it
            }
        })

        return binding.root
    }
}
```

# リファクタリング

前章までで`RecyclerView`の実装は完了……というより動くものはできましたが、ベストプラクティスに則り諸々リファクタリングしていきます。

## DiffUtilの使用
これまでは`RecylerView`のデータに変更があった場合**notifyDataSetChanged()**で知得していました。
これは画面表示外のデータもすべて再描画することになり、パフォーマンスに難があるため、**DiffUtil**の使用を検討します。
**Adapter**と同じファイルの末尾に**DiffUtilコールバック**クラスを作成。
**areItemsTheSame()**、**areContentsTheSame()**メソッドをオーバーライドします。

```kotlin
class WordDataDiffCallback: DiffUtil.ItemCallback<WordData>() {
    override fun areItemsTheSame(oldItem: WordData, newItem: WordData): Boolean {
        return oldItem.word == newItem.word
    }

    override fun areContentsTheSame(oldItem: WordData, newItem: WordData): Boolean {
        return oldItem == newItem
    }
}
```

## Adapterクラスのリファクタリング
**Adapter**クラスを**ListAdapter**に拡張します。
**DiffUtil**をコンストラクタに含めているため内部の変数dataに関する記述は不要となります。
同様に**getItemCount()**も不要に。
**onBindViewHolder()**はitemの生成に**ListAdapter**の**getItem(position)**メソッドを使用します。
ちなみに**ListAdapter**は`androidx.recyclerview.widget.ListAdapter`からインポートする点に注意。

```kotlin
class WordDataAdapter: ListAdapter<WordData, WordDataAdapter.ViewHolder>(WordDataDiffCallback()) {

    // RecyclerViewの所定の位置に所定のデータを表示するためのメソッド
    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        val item = getItem(position)
        holder.bind(item)
    }

    //　ViewHolderを生成するメソッド
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        return ViewHolder.from(parent)
    }

    // ViewHolderクラス
    class ViewHolder private constructor(itemView: View) : RecyclerView.ViewHolder(itemView) {
        val wordNameText: TextView = itemView.findViewById(R.id.word_name_text)
        val wordSpeechImage: ImageView = itemView.findViewById(R.id.word_speech_image)
        val wordFavoriteImage: ImageView = itemView.findViewById(R.id.word_favorite_image)

        fun bind(item: WordData) {
            // ViewHolderの各要素に値を設定
            wordNameText.text = item.word
            wordSpeechImage.setImageResource(when(item.speech) {
                // 品詞に応じて設定するアイコンを切り替え
                "助" -> { R.drawable.speech_auxiliary }
                "動" -> { R.drawable.speech_verb }
                "不" -> { R.drawable.speech_infinitive }
                "副" -> { R.drawable.speech_adverb }
                "構" -> { R.drawable.speech_syntax }
                "接" -> { R.drawable.speech_conjunction }
                "形" -> { R.drawable.speech_adjective }
                "名" -> { R.drawable.speech_noun }
                "代" -> { R.drawable.speech_pronoun }
                "前" -> { R.drawable.speech_preposition }
                "定" -> { R.drawable.speech_template }
                else -> { R.drawable.speech_none }
            })
            wordFavoriteImage.setImageResource(when(item.favorite) {
                true -> R.drawable.favorite_on
                else -> R.drawable.favorite_off
            })
        }

        companion object {
            fun from(parent: ViewGroup): ViewHolder {
                // LayoutInflaterインスタンスを作成
                val layoutInflater = LayoutInflater.from(parent.context)
                val view = layoutInflater.inflate(R.layout.list_item, parent, false)
                return ViewHolder(view)
            }
        }
    }
}
```

**ListAdapter**の導入にあわせて_**Fragment.kt**_のObserverの記述も下記の通りに置き換えます。

```kotlin
class WordDisplayFragment : Fragment() {
    // （略）
    override fun onCreateView(
    // （略）
    ): View? {
        // （略）
        wordDisplayViewModel.words.observe(viewLifecycleOwner, Observer {
            it?.let {
                adapter.submitList(it)
            }
        })
        // （略）
    }
}
```

## RecyclerViewのDataBinding
`RecyclerView`の……正確にはビュー内部に表示する`ListItem`に対し、`DataBinding`を設定していきます。
まず、`ListItem`のレイアウトファイルで`DataBinding`を有効にします。

```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="word"
            type="com.warpstudio.android.recyclerviewsample.database.WordData" />
    </data>
    <!--略-->
</layout>
```

続いて**ViewHolder**の生成処理も変更します。**from()**メソッドを下記の通り`Binding`オブジェクトを生成し、返す方式に変更します。

```kotlin
        companion object {
            fun from(parent: ViewGroup): ViewHolder {
                // LayoutInflaterインスタンスを作成
                val layoutInflater = LayoutInflater.from(parent.context)
                val binding = ListItemBinding.inflate(layoutInflater, parent, false)
                return ViewHolder(binding)
            }
        }
```

このままではエラーが出てしまうので、**ViewHolder**クラスの宣言を下記の通り修正します。

```kotlin
class ViewHolder private constructor(val binding: ListItemBinding) 
     : RecyclerView.ViewHolder(binding.root) {
```

`findViewById`に代わり、`Binding`により下記の通りインライン化することができます。

```kotlin
    class ViewHolder private constructor(val binding: ListItemBinding)
        : RecyclerView.ViewHolder(binding.root) {

        fun bind(item: WordData) {
            // ViewHolderの各要素に値を設定
            binding.wordNameText.text = item.word
            binding.wordSpeechImage.setImageResource(
                when (item.speech) {
                    // 品詞に応じて設定するアイコンを切り替え
                    "助" -> { R.drawable.speech_auxiliary }
                    "動" -> { R.drawable.speech_verb }
                    "不" -> { R.drawable.speech_infinitive }
                    "副" -> { R.drawable.speech_adverb }
                    "構" -> { R.drawable.speech_syntax }
                    "接" -> { R.drawable.speech_conjunction }
                    "形" -> { R.drawable.speech_adjective }
                    "名" -> { R.drawable.speech_noun }
                    "代" -> { R.drawable.speech_pronoun }
                    "前" -> { R.drawable.speech_preposition }
                    "定" -> { R.drawable.speech_template }
                    else -> { R.drawable.speech_none }
                }
            )
            binding.wordFavoriteImage.setImageResource(
                when (item.favorite) {
                    true -> R.drawable.favorite_on
                    else -> R.drawable.favorite_off
                }
            )
        }
```

## BindingAdapterの作成
さらにリファクタリングを勧めます。ここでは`BindingAdapter`を作成し、`Entity`クラスとレイアウトの画面部品を直接`DataBinding`で結びつけます。
まず新たに_**BindingUtil.kt**_というクラスを作成します（クラス名は何でもいいです）。
そして**package**以外を全て削除し、表示先の画面部品ごとに*@BindingAdapter*のアノテーションを付与した画面部品の拡張関数を作成します。
全容は下記の通りです。

```kotlin
@BindingAdapter("wordNameText")
fun TextView.setWordNameText(item: WordData) {
    text = item.word
}

@BindingAdapter("speechImage")
fun ImageView.setSpeechImage(item: WordData) {
    setImageResource(
        when (item.speech) {
            // 品詞に応じて設定するアイコンを切り替え
            "助" -> { R.drawable.speech_auxiliary }
            "動" -> { R.drawable.speech_verb }
            "不" -> { R.drawable.speech_infinitive }
            "副" -> { R.drawable.speech_adverb }
            "構" -> { R.drawable.speech_syntax }
            "接" -> { R.drawable.speech_conjunction }
            "形" -> { R.drawable.speech_adjective }
            "名" -> { R.drawable.speech_noun }
            "代" -> { R.drawable.speech_pronoun }
            "前" -> { R.drawable.speech_preposition }
            "定" -> { R.drawable.speech_template }
            else -> { R.drawable.speech_none }
        }
    )
}

@BindingAdapter("favoriteImage")
fun ImageView.setFavoriteImage(item: WordData) {
    setImageResource(
        when (item.favorite) {
            true -> R.drawable.favorite_on
            else -> R.drawable.favorite_off
        }
    )
}
```

続いて`ListItem`の画面部品に`Binding`を設定します。

```xml
        <TextView
            android:id="@+id/word_name_text"
            <!--略-->
            app:wordNameText="@{word}"/>

        <ImageView
            android:id="@+id/word_speech_image"
            <!--略-->
            app:speechImage="@{word}"/>

        <ImageView
            android:id="@+id/word_favorite_image"
            <!--略-->
            app:favoriteImage="@{word}"/>
```

最後に、**ViewHolder**クラスの**bind()**メソッドで明示的な`Binding`が不要となったので、下記の通り内容を改修します。

```kotlin
    class ViewHolder private constructor(val binding: ListItemBinding)
        : RecyclerView.ViewHolder(binding.root) {

        fun bind(item: WordData) {
            binding.word = item
            binding.executePendingBindings()
        }
        // (略)
    }
```

# Recap
今回は以上。
あんまり`RecyclerView`らしさは出せていませんが、実際には画面外はデータの描画処理を行っていないなど、裏側でのメリットは既にあったりもします。
後半へ続く。