Androidアプリ開発初級編(RecyclerView)

今回はRecyclerViewの基本的な使い方について学習していきます。様々なアプリで多用されるリスト表示に使われるため使用頻度も多くなるかと思います。

■サンプルアプリの動作

じゃんけんを行い、対戦成績をリスト表示。対戦成績のアイテムは遷移先で削除出来るといったシンプルなアプリです

■RecyclerViewのレイアウト

タイトル画面のレイアウトについて見ていきます。

<RecyclerView>のlayoutManagerでは様々なManagerを指定することで、レイアウトについてカスタマイズ可能となります。今回はシンプルにアイテムが縦一列に並ぶ構成としています。

<?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="titleViewModel"
            type="com.tomostudy.janken.title.TitleViewModel" />
    </data>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        tools:context=".title.TitleFragment">
        
        <TextView
            android:id="@+id/titleText"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="16dp"
            android:layout_marginBottom="16dp"
            android:text="@string/janken_game"
            app:layout_constraintBottom_toTopOf="@+id/gameStartButton"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

        <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/recyclerView"
            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/gameStartButton"
            app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"/>

        <Button
            android:id="@+id/gameStartButton"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="16dp"
            android:layout_marginBottom="16dp"
            android:text="@string/game_start"
            android:onClick="@{() -> titleViewModel.onClickGameStart()}"
            app:layout_constraintBottom_toTopOf="@+id/recyclerView"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/titleText" />

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

次はRecyclerViewのアイテムレイアウトについて見ていきます。

DataBindingにてリストアイテムに紐づけるデータとアイテム押下時のClickListenerをバインドしています。リスト表示のみであればClickListenerは不要です。

<?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="jankenResult"
            type="com.tomostudy.janken.database.JankenResult" />

        <variable
            name="clickListener"
            type="com.tomostudy.janken.title.JankenResultListener" />
    </data>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:onClick="@{() -> clickListener.onClick(jankenResult)}">

        <ImageView
            android:id="@+id/resultImage"
            jankenResultResource="@{jankenResult.result}"
            android:layout_width="64dp"
            android:layout_height="64dp"
            android:layout_marginStart="8dp"
            android:layout_marginTop="8dp"
            android:layout_marginEnd="16dp"
            android:layout_marginBottom="8dp"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toStartOf="@+id/resultDate"
            app:layout_constraintHorizontal_bias="0.4"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            tools:src="@drawable/pose_win_boy" />

        <TextView
            android:id="@+id/resultDate"
            resultDate="@{jankenResult}"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_marginStart="16dp"
            android:layout_marginTop="8dp"
            android:layout_marginEnd="16dp"
            android:layout_marginBottom="1dp"
            app:layout_constraintBottom_toBottomOf="@+id/resultImage"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toEndOf="@+id/resultImage"
            app:layout_constraintTop_toBottomOf="@+id/textView"
            tools:text="@string/app_name" />

        <TextView
            android:id="@+id/textView"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_marginTop="8dp"
            android:text="@string/result_date"
            app:layout_constraintBottom_toTopOf="@+id/resultDate"
            app:layout_constraintEnd_toEndOf="@+id/resultDate"
            app:layout_constraintStart_toStartOf="@+id/resultDate"
            app:layout_constraintTop_toTopOf="@+id/resultImage" />

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

今回はリストにヘッダー扱いのTextViewを設定しているので、そちらも一応載せます。”対戦成績”の部分になります。

<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:gravity="center"
    android:text="@string/result_list_header"
    android:textStyle="bold|italic" />

■Adapterの実装

対戦成績のリストデータをRecyclerViewが参照出来るようにするためにアダプターの実装を行います。

ListAdapterを継承、アイテムとするデータ型とViewHolderのデータ型を指定し、継承先にDiffUtilをコンストラクタにて渡します。ReultListAdapterの公司とラクダではViewHolderにて使用するclickListenerを渡します。

ViewHolderとListAdapterの設計では、実際のView操作はHolderの責務、ListAdapterではViewHolderとListItemの管理のみとすることでより実装処理が明確にすることが出来ます。ViewHolderの内容についてListAdapterが知らないように意識するとコードがすっきりすると思います。そして、ViewHolder自体はDataBindingによって更新処理がシンプルになるため実際にはデータをBindするだけとなり、よりコードがシンプルになっています。

private const val ITEM_HEADER = 0
private const val ITEM_JANKEN_RESULT_DATA = 1

class ResultListAdapter(private val clickListener: JankenResultListener) :
    ListAdapter<DataItem, RecyclerView.ViewHolder>(JankenResultDiffCallbacks()) {

    fun addHeaderAndSubmitList(list: List<JankenResult>?) {
        val items = when (list) {
            null -> listOf(DataItem.Header)
            else -> listOf(DataItem.Header) + list.map { DataItem.JankenResultItem(it) }
        }
        submitList(items)
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
        return when (viewType) {
            ITEM_HEADER -> TextViewHolder.from(parent)
            ITEM_JANKEN_RESULT_DATA -> ViewHolder.from(parent)
            else -> throw ClassCastException("UnKnown type $viewType")
        }
    }

    override fun getItemViewType(position: Int): Int {
        return when (getItem(position)) {
            is DataItem.Header -> ITEM_HEADER
            is DataItem.JankenResultItem -> ITEM_JANKEN_RESULT_DATA
        }
    }

    override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
        when (holder) {
            is ViewHolder -> {
                holder.bind(getItem(position) as DataItem.JankenResultItem, clickListener)
            }
        }
    }


    class ViewHolder private constructor(private val binding: ListItemJankenResultHistoryBinding) :
        RecyclerView.ViewHolder(binding.root) {

        fun bind(result: DataItem.JankenResultItem, clickListener: JankenResultListener) {
            binding.jankenResult = result.jankenResult
            binding.clickListener = clickListener
        }

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

次にDiffUtilを継承したJankenResultDiffCallbacks部分について見ていきます。

areItemsTheSameではidをアイテム自体の比較に、areContentsTheSameはデータ内容の変更があるかを見ています。

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

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

次にDataItemです。

Headerとじゃんけん結果のデータを定義しています。getItemViewType等でHeaderと通常Itemの判断を行います。addHeaderAndSubmitListメソッド内で実行しているsubmitListにより実際にデータが設定され、更新処理まで行います。

sealed class DataItem {
    abstract val id: Long

    data class JankenResultItem(val jankenResult: JankenResult) : DataItem() {
        override val id: Long = jankenResult.id
    }

    object Header : DataItem() {
        override val id: Long = Long.MIN_VALUE
    }
}

ClickListenerの定義、TextViewHolder(ヘッダーのViewHolder)の説明については特筆する点はありませんので、コードだけ載せておきます。

class JankenResultListener(val clickListener: (sleepId: Long) -> Unit) {
    fun onClick(result: JankenResult) = clickListener(result.id)
}

class TextViewHolder private constructor(view: View) : RecyclerView.ViewHolder(view) {
    companion object {
        fun from(parent: ViewGroup): TextViewHolder {
            val layoutInflater = LayoutInflater.from(parent.context)
            val view = layoutInflater.inflate(R.layout.item_header, parent, false)
            return TextViewHolder(view)
        }
    }
}

それでは、Fragment側の呼び出し定義について見ていきます。

adapterを生成時にclickListenerを設定しています。今回はnavigationを使い画面遷移を行うのみです。bindingオブジェクトからrecyclerViewを取得しadapterを設定します。viewModel.resultsは対戦成績のリストデータとなります。LiveDataとして管理することでリスト内容の変更に応じて更新まで処理されるようになります。

     override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
       .....
        val adapter = ResultListAdapter(JankenResultListener {
            findNavController().navigate(
                TitleFragmentDirections.actionTitleFragmentToResultDetailFragment(
                    it
                )
            )
        })
        binding.recyclerView.adapter = adapter
        viewModel.results.observe(viewLifecycleOwner, Observer {
            adapter.addHeaderAndSubmitList(it)
        })
       .....
    }

■まとめ

・layoutManagerによりRecyclerViewのアイテム表示に関するレイアウトを設定する

・AdapterではDiffUtilを使い、更新処理の最適化を行うことでnotifyDataSetChanged等の重い更新処理を使うことを防ぎ、簡潔に実装出来る

・AdapterはリストデータとViewHolderの紐づけ、ViewHolderはViewの操作定義を責務とし、DataBindingを使うことで簡潔に実装出来る。

・Adapterで処理するリストデータにItemのパターンを用意することでHeader+アイテム等の実装が可能

RecyclerViewは様々な場面で使うことになると思いますので、色々な実装パターンを見てみることをお勧めします。その際に気を付けて欲しいのは更新処理についてnotifyDataSetChangedのみを使ってリスト全体を毎回更新するコードが散見されます。これはパフォーマンス低下の大きな要因となりますので注意してください。今回はあまり触れませんでしたが、レイアウトマネージャについてGridのレイアウトにしたり色々と試行錯誤してみると面白いです。

それでは。

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です