RecyclerView Adapter 一つに対して複数のViewHolderを持つリストを作成する

Kotlin でAndroidアプリ開発をはじめました。 まず、LegacyとなったListViewの代わりにRecyclerViewというのが新しく用意されているとの事で触ってみました。 ちゃんとしたオブジェクト指向でコードを書くのはかなり久々な事と、 AdapterとHolder、レイアウトファイルの関連が掴めていなかったのもあり、 理解に至るまで苦しみました。 後日順を追ってKotlinによるAndroid開発のメモは残しておきますが、この記事は先に残しておこうと思います。

やりたいこと:Adapter一つに対して複数のViewHolderを持つRecyclerViewを作る

例えば、RecyclerViewのアイテム一覧の一番最後は「+」アイコンで 要素の追加が出来るようなActivityを用意します。

ポイントとなるのは以下の通りです。

onCreateViewHolder

  • ViewTypeで一番最後か、そうでないかを判断し、読み込むレイアウトファイルを使い分けます
  • 予め用意したViewHolderクラスのスーパークラスにあたる RecyclerView.ViewHolder を戻り値で返します。サブクラス(用意したViewHolderクラス)は返しません

onBindViewHolder

  • 引数のPositionから一番最後か、そうでないかを判断し、読み込むViewHolderを使い分けます
  • RecyclerView.ViewHolderを引数として待ち受け、読み込むViewHolderに従ってキャストします。 > この時厳密に読み込むViewHolderが一致していないとキャストが出来なかったり、ウィジェットの要素がNullになって落ちるので判断する際は注意する。

レイアウトファイル(xml file)

RecyclerViewを表示するアクティビティ用レイアウトファイル (activitynewpost.xml)

後述する[A]及び[B]を組み合わせたものを期待しています。

<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout 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"
        android:layout_width="match_parent"
        android:layout_height="match_parent" tools:layout_editor_absoluteY="81dp">

    <android.support.v7.widget.RecyclerView
            android:id="@+id/addRecyclerView"
            android:layout_width="match_parent"
            android:layout_height="545dp"
            android:layout_marginTop="8dp"
            app:layout_constraintTop_toTopOf="parent" app:layout_constraintStart_toStartOf="parent"
            android:layout_marginStart="8dp" app:layout_constraintEnd_toEndOf="parent" android:layout_marginEnd="8dp"/>
    <Button
            android:id="@+id/btnAdd"
            android:text="Post"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginBottom="8dp"
            app:layout_constraintBottom_toBottomOf="parent" android:layout_marginTop="8dp"
            app:layout_constraintTop_toBottomOf="@+id/addRecyclerView" app:layout_constraintStart_toStartOf="parent"
            android:layout_marginStart="8dp" app:layout_constraintEnd_toEndOf="parent" android:layout_marginEnd="8dp"/>
</android.support.constraint.ConstraintLayout>

[A]RecyclerViewのアイテム一つを表すレイアウトファイル (layoutnewitem_card.xml)

CardViewの中にEditTextとImageButtonをおいてみました。予め、専用の画像等は適切なフォルダに入れておいてください。 Image Buttonの使い方を調べておきましょう。

<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
                                             xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent"
                                               android:layout_height="wrap_content"
                                               xmlns:app="http://schemas.android.com/apk/res-auto">

        <android.support.v7.widget.CardView
            android:id="@+id/android_layout"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            app:cardBackgroundColor="@android:color/white">

            <android.support.constraint.ConstraintLayout
                android:layout_width="match_parent"
                android:layout_height="wrap_content">

                <EditText
                        android:id="@+id/edtFlowerName"
                        android:layout_width="wrap_content"
                        android:layout_height="wrap_content"
                        android:inputType="textPersonName"
                        android:hint="花名を入力"
                        android:ems="20" />

                <ImageButton
                        android:id="@+id/ibtnCamera"
                        android:layout_width="wrap_content"
                        android:layout_height="wrap_content"
                        app:srcCompat="@android:drawable/ic_menu_camera"
                        app:layout_constraintTop_toBottomOf="@+id/edtFlowerName" />

            </android.support.constraint.ConstraintLayout>
        </android.support.v7.widget.CardView>
</android.support.constraint.ConstraintLayout>

[B]RecyclerViewの一番最後の要素に表示するレイアウトファイル (layoutaddbutton.xml)

Image Buttonで+アイコンのみ配置しています

<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout 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" android:layout_width="match_parent"
                                             android:layout_height="wrap_content">

    <ImageButton
            android:id="@+id/ibtnItemAdd"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content" app:srcCompat="@android:drawable/ic_menu_add"
            app:layout_constraintEnd_toEndOf="parent"
            android:layout_marginEnd="8dp" app:layout_constraintStart_toStartOf="parent"
            android:layout_marginStart="8dp" android:layout_marginTop="8dp" app:layout_constraintTop_toTopOf="parent"
            android:layout_marginBottom="8dp" app:layout_constraintBottom_toBottomOf="parent"/>
</android.support.constraint.ConstraintLayout>

Holder

ViewHolder A (NewPostViewHolder.kt)

package com.killinsun.app.recyclepractice

import android.support.v7.widget.RecyclerView
import android.view.View
import android.widget.EditText
import android.widget.ImageButton

class NewPostViewHolder(view: View) : RecyclerView.ViewHolder(view) {

    // 最後の行以外の場合はこっちを読み込む。 
    val edtFlowerName: EditText? = view.findViewById(R.id.edtFlowerName)
    val ibtnCamera: ImageButton? = view.findViewById(R.id.ibtnCamera)

}

ViewHolder B (NewItemViewHolder.kt)

package com.killinsun.app.recyclerpractice

import android.support.v7.widget.RecyclerView
import android.view.View
import android.widget.ImageButton

class NewItemViewHolder(view: View) : RecyclerView.ViewHolder(view){

    // 最後の行だった場合はこっちを読み込む
    val ibtnItemAdd: ImageButton = view.findViewById(R.id.ibtnItemAdd)
}

Adapter(NewPostAdapter.kt)

package com.killinsun.app.recyclepractice

import android.support.v7.widget.RecyclerView
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ArrayAdapter
import kotlinx.android.synthetic.main.layout_new_item_card.view.*

class NewPostAdapter(private val itemList:ArrayList<Int>)
    : RecyclerView.Adapter<RecyclerView.ViewHolder>(){

    override fun getItemViewType(position: Int) : Int{
       return if(position == itemList.size) 0 else 1  // ①
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {

        val layoutInflater = LayoutInflater.from(parent.context)
        val view: View  // ②

       // ③
        if(viewType == 0){
            //最後の行の場合は追加ボタンレイアウトを読み込む
            view = layoutInflater.inflate(R.layout.layout_add_button, parent, false)
            return NewItemViewHolder(view)
        }else {
            //最後の行以外の場合はそのままカードを読み込む
            view = layoutInflater.inflate(R.layout.layout_new_item_card, parent, false)
            return NewPostViewHolder(view)
        }

    }

    override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
       // ④
        if(position == itemList.size){
            val myHolder: NewItemViewHolder = holder as NewItemViewHolder
            myHolder.ibtnItemAdd.setOnClickListener{
                Log.v("test","Add button clicked!!!")
                itemList.add(position+1)
                notifyItemInserted(position + 1)
            }
        }else {
            val myHolder: NewPostViewHolder = holder as NewPostViewHolder
            myHolder.ibtnCamera?.setOnClickListener {
                Log.v("test", "Camera button clicked!!!")
            }
        }

    }

    override fun getItemCount(): Int{
        return itemList.size + 1  // ⑤
    }

}

getItemViewメソッドでポジションを受け取り、用意したitemListとサイズが一致(=与えられた要素の位置が最終行)の場合のみ0を返却し、それ以外の行(=与えられた要素の位置が最終行以外)の場合は、1を返却します。

予め View型のオブジェクトを用意しておきます。

getItemViewTypeで得られた戻り値(0 or 1)を基に判断を進めます。 上述の通り0の場合は最終行なのでそれ用のレイアウトファイルをlayoutInflaterメソッドにより読み込み、②で宣言したViewオブジェクトに代入します。その後、メソッドとしてViewHolderクラスを戻り値として返して終了します。 この時返却するViewHolderクラスは、要素に応じたものです。 最終行以外の場合も、読み込むレイアウトファイルと戻り値のViewHolderクラスが違うだけで、やっていることは一緒です。

これまでと同様に、今回はpositionの値によって最終行かそうでないかを判断しています。 myHolderに対しては、最終行だったら引数として読み込まれた holderRecycleView.ViewHolder型)を NewItemViewHolder型にキャストといった処理をしています。最終行以外の場合も、相応の処理を行っています。 また、この時別々のクラス型にキャストしてあげる事で、ViewHolder内で宣言したウィジェット(例えば最終要素の場合は+ボタン)のsetOnClickListenerにアクセス出来るようになっています。

ちなみに、今回は+ボタンを押された際にRecyclerViewの要素が1つ増える処理が記載されています。

おまじないです。 このRecyclerViewは要素数+1は常に「+ボタン」になっています。

Activity

package com.killinsun.app.recyclerpractice

import android.os.Bundle
import android.support.v7.app.AppCompatActivity
import android.support.v7.widget.LinearLayoutManager
import kotlinx.android.synthetic.main.activity_new_post.*

class NewPostActivity : AppCompatActivity(){

    override fun onCreate(savedInstanceState: Bundle?){
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_new_post)

        var listItems: ArrayList<Int> = arrayListOf(0)

        addRecyclerView.layoutManager = LinearLayoutManager(this)
        addRecyclerView.adapter = NewPostAdapter(listItems)

    }
}

今回のアクティビティではユーザの操作によってRecyclerViewの要素が増える事を想定したものの為、Adapterに渡す配列はInt型で1要素のみを渡しています。

やってみて

ココ最近のAndroidアプリ開発は右も左も分からないまま突っ込んで、ようやくAdapterとHolderの仕組みが少し理解できたかなという気持ちです。 最初は戻り値となるViewHolderをサブクラス(NewItemViewHolder)に指定してたりして、「どうやってもう片方のViewHolderの受け取るんだよ」って躓いたりしました。スーパークラスにしておけばいいんですね・・・。

正直、上記の記述がベストプラクティスだとは思っていませんし、改善の余地があるかと思いますが、 ひとまず「動くもの」を目指して作るのは大事な事だと思っています。 その上でリファクタリングして更に理解を深められたらいいな。

いずれにしてもAdapterにあまり処理を書きたくないと思っているので、どこに移動させるのがベストか、 意見がありましたらツイッターでもコメントでもフィードバックいただけると嬉しいです。

参考

/以上