プロジェクト

全般

プロフィール

Androidプログラミング-RecyclerView

概要

多数のデータから一部をリスト表示する

RecyclerViewは、複数のデータをリストまたはグリッド形式で表示する部品で、特に多数のデータの一部分を表示するのに最適化されている部品です。RecyclerViewは実際に画面に表示されているデータだけを表示処理します。例えば、スクロール操作で画面から消えたリストまたはグリッド表示を再利用することで性能を高めています。1つのデータだけが更新されたときはそのデータに対応する表示部分だけを更新します。

Androidには古くからリスト表示部品であるListViewが存在していますが、RecyclerViewはListViewに代替する部品です。

関係するパーツ

  • RecyclerView を画面レイアウトに貼付
  • RecyclerView.layoutManager プロパティにどのようなレイアウトをするかレイアウトマネージャ名を指定
  • 1つのデータの表示レイアウトを定義するXML
  • RecyclerView.Adapter サブクラスを作成して実装
  • RecyclerView.ViewHolder サブクラスを作成して実装

recyclerview_adapter.png

ListAdapterを使用した関係パーツ

  • RecyclerView を画面レイアウトに貼付
  • RecyclerView.layoutManager プロパティにどのようなレイアウトをするかレイアウトマネージャ名を指定
  • 1つのデータの表示レイアウトを定義するXML
  • DiffUtil.ItemCallback サブクラスを作成し、リストの要素の同値性および同一性を判定する実装
  • ListAdapter サブクラスを作成して実装
  • RecyclerView.ViewHolder サブクラスを作成して実装

recyclerview_listadapter.png

データとの関連付け

表示対象となるデータは、ArrayList等のコレクションに格納しておく方法と、Cursorで外部から取得してくる方法とがあります。
RecyclerViewの入門的なサンプルは、実装が簡単なコレクションに格納したデータを扱うものが多いです。しかし、実際使う場合はデータベースに格納したデータを扱うことが多いでしょう。

実装例

コンテントプロバイダから取得したCursorからリスト表示

体温を記録する単純なアプリケーションでの使用例を述べます。検温した日時と体温(℃)を端末内データベースに永続化し、それを一覧表示します。体温の記録はコンテントプロバイダとして提供される想定です。

画面レイアウトにRecyclerViewを貼付

Android Studio 4.0 のレイアウトエディタが持つパレットには残念ながら RecyclerView がありません。そこで、XMLファイルに直接記述します。

  • activity_main.xml
    <?xml version="1.0" encoding="utf-8"?>
    <androidx.constraintlayout.widget.ConstraintLayout
        xmlns:android="http://schemas.android.com/apk/res/android" ...>
    
      :
      <androidx.recyclerview.widget.RecyclerView />
    
    

属性は後でレイアウトエディタ上で設定できるので、まずは要素だけXMLファイルに記述します。

レイアウトマネージャを指定

RecyclerView に、各アイテムをどのように配列するかを制御するレイアウトマネージャを指定します。
ライブラリが提供するレイアウトマネージャは次の3種類です。

  • LinearLayoutManager
    アイテムを水平、または垂直に配置
  • GridLayoutManager
    アイテムを均等な格子状に配置
  • StaggeredGridLayoutManager
    アイテムを不均等な格子状に配置

独自のレイアウトマネージャを実装してそれを指定することも可能です。

本サンプルでは、リスト(一覧)表示をするのでLinearLayout(垂直)を使用します。
レイアウトエディタで RecyclerView を選択し、属性 layoutManager に LinearLayoutManager をテキスト入力します。

1つのデータの表示レイアウトを定義

一つのデータ表示項目のレイアウトを独立したXMLファイルに記述します。
今回は、検温日時と体温の2つを1行に表示します。

LinearLayout で水平にTextViewを2つ並べた例を次に示します。

  • list_item.xml
    <?xml version="1.0" encoding="utf-8"?>
    <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" 
        android:layout_width="match_parent" 
        android:layout_height="wrap_content">
        <TextView
            android:id="@+id/dateTimeView" 
            android:layout_width="0dp" 
            android:layout_height="wrap_content" 
            android:layout_weight="1" 
            android:textAlignment="center" />
    
        <TextView
            android:id="@+id/temperatureView" 
            android:layout_width="0dp" 
            android:layout_height="wrap_content" 
            android:layout_weight="1" 
            android:text="TextView" 
            android:textAlignment="center" />
    
    </LinearLayout>
    

RecyclerView.Adapter サブクラスを作成

Adapterサブクラスの作成でやる事は次です。

  • RecyclerView.Adapter を継承したクラスの定義
  • RecyclerView.ViewHolder を継承したネストクラスの定義
  • onCreateViewHolderメソッドの実装
  • getItemCountメソッドの実装
  • onBindViewHolderメソッドの実装

メソッドは仮の実装とした Adapter サブクラスの全体像は次です。

class TempAdapter(private var cursor: Cursor?) : RecyclerView.Adapter<TempAdapter.TempViewHolder>() {

    class TempViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
        val dateTimeView: TextView = itemView.dateTimeView
        val temperatureView: TextView = itemView.temperatureView
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TempViewHolder {
        TODO("Not yet implemented")
    }

    override fun onBindViewHolder(holder: TempViewHolder, position: Int) {
        TODO("Not yet implemented")
    }

    override fun getItemCount(): Int {
        TODO("Not yet implemented")
    }
}
RecyclerView.Adapter を継承したクラスの定義

RecyclerView.Adapterクラスは、仮型パラメータにRecyclerView.ViewHolderクラスを持つジェネリクスクラスです。
class Adapter<HV extends ViewHolder> (修飾子等省略)

そこで、ネストクラスにViewHolder サブクラスを定義し、それを型パラメータで取ります。(ネストクラスでなくてもかまいませんが)

RecyclerView.ViewHolder を継承したネストクラスの定義

コンストラクタで渡されるアイテム1件(1行)の各ビュー要素を保持します。

    class TempViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
        val dateTimeView: TextView = itemView.dateTimeView
        val temperatureView: TextView = itemView.temperatureView
    }

ここでは、Kotlin Android Extensions を利用して findViewByIdを呼ばずにレイアウトに定義されたビューを取得しています。
最近では、ViewBindingを使用した方がよいかと思います。

onCreateViewHolderメソッドの実装
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TempViewHolder {
        return TempViewHolder(
            LayoutInflater.from(parent.context).inflate(R.layout.list_item, parent, false)
        )
    }

TempViewHolderのインスタンスを返却する実装を記述します。
inflateメソッドの第1引数に指定している R.layout.list_item の list_item は、レイアウトXMLファイル名に基づきます。

getItemCountメソッドの実装
    override fun getItemCount() = cursor?.let { if (it.isClosed) 0 else it.count } ?: 0
onBindViewHolderメソッドの実装
    override fun onBindViewHolder(holder: TempViewHolder, position: Int) {
        cursor?.let {
            if (it.isClosed) return
            it.moveToPosition(position)
            holder.dateTimeView.text = getDateTime(it)
            holder.temperatureView.text = "%.1f".format(getTemperature(it))
        }
    }

cursorがnullでない時の処理を cursor?.letで指定しています。

cursorから日時を文字列で取り出す処理をprivateメソッドで定義しています。

    private fun getDateTime(cursor: Cursor): String {
        val isoDateTimetext = cursor.getString(cursor.getColumnIndex(TempContract.TempEntry.MEASURED_AT))
        val measuredAt = LocalDateTime.parse(isoDateTimetext, DateTimeFormatter.ISO_LOCAL_DATE_TIME)
        return measuredAt.format(DATE_TIME_VIEW_FORMATTER)
    }

cursorから計測値(体温)を取り出す処理をprivateメソッドで定義しています。

    private fun getTemperature(cursor: Cursor) =
        cursor.getDouble(cursor.getColumnIndex(TempContract.TempEntry.MEASUREMENT))

ViewModelに定義したLiveData対応リストからDataBindingを併用しリスト表示

リスト形式のモデルを扱うRecyclerViewでは、LiveDataとDiffUtilを利用してより効率的に実装することができます。

LiveDataは、モデルが変更したことをビューに通知する機構を提供するコンポーネントです。
DiffUtilは、RecyclerViewのコンポーネントに含まれ、リストの更新時に更新前のリストと更新後のリストから差分を抽出します。

DiffUtilを使うときは、RecyclerView.Adapterのサブクラスを定義する代わりに、ListAdapterのサブクラスを定義します。ListAdapterはRecyclerView.Adapterを継承し、DiffUtilを使って差分を扱います。

今回は表示に際しては、DataBindingを使用します。

DiffUtilに関する実装

DiffUtil.Callbackクラスを継承し、リストの要素に対し、同値性判定メソッド areItemsTheSame および同一性判定メソッド areContentsTheSame をオーバーライドして実装します。

class TemperatureDiffCallback : DiffUtil.ItemCallback<Temperature>() {
    override fun areItemsTheSame(oldItem: Temperature, newItem: Temperature): Boolean {
        return oldItem.id == newItem.id
    }

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

}
  • ListAdapter派生クラスで使用するので、ソースコードの定義場所はListAdapterと同じファイル(TemperatureAdapter.kt)としています。

DataBindingに対応したリストアイテムのレイアウト定義

RecyclerViewの1件のレコード表示のレイアウトを定義します。DataBindingを使います。

<?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="temperature" 
            type="com.torutk.android.temprecorder.jetpackkt.domain.Temperature" />
    </data>

    <LinearLayout
        android:layout_width="match_parent" 
        android:layout_height="wrap_content">

        <TextView
            android:id="@+id/measured_at" 
            android:layout_width="wrap_content" 
            android:layout_height="wrap_content" 
            android:layout_weight="1" 
            android:textAlignment="center" 
            android:textSize="16sp" 
            app:measuredAtFormatted="@{temperature}" />

        <TextView
            android:id="@+id/measurement" 
            android:layout_width="wrap_content" 
            android:layout_height="wrap_content" 
            android:layout_weight="1" 
            android:text="@{String.valueOf(temperature.measurement)}" 
            android:textAlignment="center" 
            android:textSize="16sp" />
    </LinearLayout>
</layout>
  • DataBindingを扱うビュー(レイアウトXML)では、layout要素をルート要素とします。
  • DataBindingを扱うビュー(レイアウトXML)では、変数を参照することが可能です。ビューで扱う変数は、layout要素の子要素data にvariable要素として名前と型を定義します。
    ここでは、1レコードを表現するTemperatureクラスを定義しています。
  • DataBindingでTextViewに文字を表示させる際、表示したい内容を持つデータは必ずしも文字列ではありません。そこで次の方法でTextViewに表示したい文字列を設定します。
    • TextViewのtext属性に文字列への変換式(Javaのコード)をビュー上で記述する
      android:text="{String.valueOf(temperature.measurement)}"@
    • データを引数に取り文字列化してtext属性に格納するTextViewの拡張関数を定義し、その関数にアノテーションBindingAdapterを付与し、ビューでTextViewから参照する
      @app:measuredAtFormatted="
      {temperature}"@

TextViewの拡張関数は、BindingUtils.ktにトップレベル関数で記述しました。

@BindingAdapter("measuredAtFormatted")
fun TextView.setMeasuredAtFormatted(item: Temperature) {
    val pattern = context.resources.getString(R.string.main_measured_at_format)
    val formatter = DateTimeFormatter.ofPattern(pattern)
    text = item.measuredAt.format(formatter)
}

res/value/strings.xml にフォーマットパターン文字列を定義しています。
<string name="main_measured_at_format">MM.dd HH:mm</string>

ListAdapterに関する実装

ListAdapterクラスを継承します。onCreateViewHolderメソッドとonBindViewHolderメソッドをオーバーライドし、ViewHolder派生クラスをネストクラスで定義します。

class TemperatureAdapter() : ListAdapter<Temperature, TemperatureAdapter.ViewHolder>(TemperatureDiffCallback()) {

    class ViewHolder private constructor(val binding: ItemTemperatureBinding) : RecyclerView.ViewHolder(binding.root) {
        fun bind(item: Temperature) {
            binding.temperature = item
            binding.executePendingBindings()
        }

        companion object {
            fun from(parent: ViewGroup): ViewHolder {
                val layoutInflater = LayoutInflater.from(parent.context)
                val binding = ItemTemperatureBinding.inflate(layoutInflater, parent, false)
                return ViewHolder(binding)
            }
        }
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        return ViewHolder.from(parent)
    }

    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        val item = getItem(position)
        holder.bind(item)
    }

}
  • ListAdapterはコンストラクタにDiffUtil.ItemCallbackを取る。(既に定義済み)
  • onCreateViewHolderメソッドは、ViewHolder派生クラスのインスタンスを生成し返却する。
    ViewHolder派生クラスはファクトリメソッド(Javaのstaticメソッド相当)fromを持たせ、外部から直接インスタンス化はさせない(private constructorにより抑止)
  • fromメソッドでは、レイアウトXMLファイル item_temperature.xml からDataBindingで生成されるItemTemperatureBindingクラスのinflateメソッドを呼び、ItemTemperatureBindingインスタンスを取得
  • bindメソッドでは、レイアウトXMLファイルのDataBindingで定義した変数(temperature)に値を格納します。executePendingBindingsはスケジュールされた(未実施)のDataBindings処理を実行する(?)もののようです。

MainActivityでRecyclerViewの初期化

class MainActivity : AppCompatActivity() {

    private val temperatureViewModel: MainViewModel by viewModels { MainViewModel.Factory(this.application) }

    override fun onCreate(savedInstanceState: Bundle?) {
        :
        val adapter = TemperatureAdapter()
        binding.recyclerviewMainRecord.adapter = adapter

        temperatureViewModel.temperatureList.observe(this, Observer {
            it?.let {
                adapter.submitList(it)
            }
        })
  • TemperatureAdapterをインスタンス化し、RecyclerViewのインスタンスに設定
  • ビューモデル(MainViewModel)のプロパティ temperatureList(型は LiveData<List<Temperature>>)を観測し、変化があればTemperatureAdapterインスタンスのsubmitListメソッドで新しいリストを渡す

リストの変更時に先頭に移動

         temperatureViewModel.temperatureList.observe(this, Observer {
             it?.let {
                 adapter.submitList(it)
+                binding.recyclerviewMainRecord.smoothScrollToPosition(0) // 先頭に移動
             }
         })

各アイテムの並び替え、スワイプ削除

アイテムをタップし、上下にスライドして順番の入れ替えを行う操作と左にスワイプして表示から削除する実装をします。

MainActivityクラスのonCreateメソッド内に次のコードを追加します。

        val itemTouchHelper = ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(
            ItemTouchHelper.UP or ItemTouchHelper.DOWN,
            ItemTouchHelper.LEFT
        ) {
            override fun onMove(
                recyclerView: RecyclerView,
                viewHolder: RecyclerView.ViewHolder,
                target: RecyclerView.ViewHolder
            ): Boolean {
                val from = viewHolder.adapterPosition ?: 0
                val to = target.adapterPosition ?: 0
                recyclerView.adapter?.notifyItemMoved(from, to)
                return true
            }

            override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
                viewHolder.let {
                    binding.recyclerviewMainRecord.adapter?.notifyItemRemoved(viewHolder.adapterPosition)
                }
            }
        })
        itemTouchHelper.attachToRecyclerView(binding.recyclerviewMainRecord)

recyclerviewコンポーネントのItemTouchHelperクラスのネストクラスSimpleCallbackをオーバーライドし、onMove(並び替え)とonSwiped(削除)のイベントに対応するメソッドを実装します。

この実装はあくまで表示上の操作なので、スワイプ操作でデータベースから項目を消すには追加の処理を記述します。

参考記事