複雑なリストをEpoxyで作成する① | Androidアプリ開発

はじめに

今回は、airbnbの複雑なリストを楽にしてくれるライブラリ「Epoxy」についての紹介です。

github.com

 

RecyclerViewでリストを作成するとき、自身でRecyclerAdapterやViewTypeを用意して、差分更新するにはDiffUtilを作って…というのが結構手間だったりしますが、そういった手間を解消して、比較的簡単に複雑なリストを構築することができます。

 

今回説明することは次のようになります。

  • 導入手順
  • 1列のリスト表示
  • グリッドリスト表示
  • カルーセルを含む複雑なリスト表示
  • 差分更新について  

また、今回の方法は、DataBindingを併用しており、そちらについては説明いたしません。

導入手順

事前に用意する必要があることがいくつかあり、次の通りです。

  • build.gradle(app) にてkapt pluginを適用する
  • build.gradle(app) にてdatabindingを有効化する
  • build.gradle(app) にてdependencyを追加する
  • package-info.javaを追加する
  • DataBindingEpoxyModelを継承した抽象クラスDataBingingModelを作成する  

build.gradle(app) にてkapt pluginを適用する

まずはじめに、app配下にあるbuild.gradleにkaptを適用します。

これはKotlinを使用している場合に必要です。使用していない場合は、こちらをスキップして、annotationProcesserを利用してください。

  build.gradle(app)

apply plugin: 'kotlin-kapt'

android {
  kapt {
      correctErrorTypes = true
  }
}

build.gradle(app) にてdatabindingを有効化する

次に、今回の手法ではDataBindingを利用するため、有効化します。

 

build.gradle(app)

android {
  dataBinding {
    enabled = true
  }
}

build.gradle(app) にてdependencyを追加する

次に、ライブラリの依存を追加します。

2020/9現在、最新バージョンは3.11.0です。
現在4系の開発が行われているようなので、近いうちにアップデートされるかもしれません。

databindingによるModelの自動生成を行いたいので、epoxy-databindingも追加しています。

 

build.gradle(app)

dependency {
  // Epoxy
  implementation 'com.airbnb.android:epoxy:3.11.0'
  implementation 'com.airbnb.android:epoxy-databinding:3.11.0'
  kapt 'com.airbnb.android:epoxy-processor:3.11.0'
}

package-info.javaを追加する

次に、package-info.javaを追加します。

DataBindingを併用する場合に必須で、こちらで宣言したレイアウトに紐づいたModelクラスを自動生成してくれます。

場所は特に指定がないですが、app > src > main > javaの下に置くのがおすすめです。

package-info.javaの中は次のように記述します。
 

package-info.java

@EpoxyDataBindingPattern(rClass = R.class, layoutPrefix = "item_")
package www.rozkey59.tokyo.androiduitips ;
import com.airbnb.epoxy.EpoxyDataBindingPattern;
import www.rozkey59.tokyo.androiduitips.R;

こちらは例なので、www.rozkey59.tokyo.androiduitipsの部分は、自身のアプリのパッケージ名を指定してください。

公式のREADMEを見ると、EpoxyDataBindingLayoutsを宣言する、とありますが個々に指定したレイアウトに対して自動でModel生成をしたい場合になります。

上記のように、EpoxyDataBindingPatternを宣言すると、layoutPrefixにて指定した文字列が入っていた場合に、個々に指定しなくても自動でModelを生成してくれるようになります。

 

例えば、前者の場合だと、{R.layout.view_hogehoge, R.layout.header_fugafuga}のように名前を個別に分けたい場合に有効です。
ただし、package-info.javaにてレイアウトのリソース名を追加する必要があります。

 

後者の場合だと、{R.layout.item_header, R.layout.item_section, R.layout.item_card}のようなレイアウトがあったときに、item_から始まるリソース名を自動生成してくれるようになります。
package-info.javaに追加で記載する必要がありません。EpoxyのModelを自動生成してくれるリソースのファイル名を一部固定すれば、後者の方が楽になります。

 

DataBindingEpoxyModelを継承した抽象クラスDataBingingModelを作成する

こちらは、任意になりますが、利用パターンとしてユーザーのアクションをもとに、差分を検知してリストを更新したいパターンが出てくると思います。

その場合を考慮して、毎回必須で差分更新部分を忘れず考慮できるように作成しておくと便利だと思います。

 

次のように記述します。

DataBindingModel.kt

abstract class DataBindingModel<in T : ViewDataBinding> : DataBindingEpoxyModel() {

    //  通常時
    abstract fun bind(binding: T)

    //  差分更新用
    abstract fun bindUpdating(binding: T, previouslyBoundModel: EpoxyModel<*>?)

    abstract fun unbind(binding: T)

    @Suppress("UNCHECKED_CAST")
    override fun setDataBindingVariables(dataBinding: ViewDataBinding?) {
        val binding = dataBinding as? T ?: return
        bind(binding)
    }

    @Suppress("UNCHECKED_CAST")
    override fun setDataBindingVariables(
        dataBinding: ViewDataBinding?,
        previouslyBoundModel: EpoxyModel<*>?
    ) {
        val binding = dataBinding as? T ?: return
        bindUpdating(binding, previouslyBoundModel)
    }

    @Suppress("UNCHECKED_CAST")
    override fun unbind(holder: DataBindingHolder) {
        val binding = holder.dataBinding as? T ?: return
        unbind(binding)
    }
}

Epoxy側に、DataBindingEpoxyModelが用意されており、自身で値を柔軟に設定したい場合は、こちらのクラスを継承するのですが、そのまま利用した場合に、setDataBindingVariablesを自身で必要なときに呼び出す必要があり、差分更新について考慮するのを忘れる可能性があるため、今回は用意しました。

不要な方は、そのままDataBindingEpoxyModelを利用すると良いと思います。

差分更新に関しての詳細は後述します。

導入手順に関しては、以上になります。

1列のリスト表示

まずは、1列の単純なリストを表示する場合についてです。

f:id:rozkey59:20200905114622p:plain
1列のサンプル

Epoxyでリスト表示をするためにいくつか必要なことがあり、次の通りです。

  • EpoxyRecyclerViewをActivity / Fragment側に用意する
  • EpoxyModel用のレイアウトリソースを用意する
  • クリック処理、Viewへのデータ適用などしたい場合に、カスタムDataBindingModelを定義する
  • Fragment側で、自動生成されたEpoxyModelをRecyclerViewに適用する

一つずつ、順を追って見ていきましょう。

EpoxyRecyclerViewをActivity / Fragment側に用意する

今回は、EpoxyのKotlin Extentionsを利用しているため、EpoxyRecyclerViewをActivityかFragment側に用意する必要があります。

次のように記述します。(コード中の「...」は省略を表しています。以後同様です。)

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

  <androidx.constraintlayout.widget.ConstraintLayout
      android:layout_width="match_parent"
      android:layout_height="match_parent">

    <com.airbnb.epoxy.EpoxyRecyclerView
        android:id="@+id/recyclerView"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"
        />

    ...

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

レイアウトリソース側で用意することとしては、EpoxyRecyclerViewを定義するだけでOKです。

EpoxyModel用のレイアウトリソースを用意する

次に、EpoxyModel用のレイアウトリソースを用意します。
ここで注意することとしては、導入手順で指定したlayoutPrefixで始まる文字列でリソース名を定義することとlayoutタグで全体を囲うことです。

次のように記述します。

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

  <androidx.cardview.widget.CardView
      android:id="@+id/card"
      android:layout_width="match_parent"
      android:layout_height="wrap_content"
      android:layout_margin="16dp"
      app:cardCornerRadius="16dp">

    <TextView android:id="@+id/number"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_margin="24dp"
        android:gravity="center"
        android:textColor="#333333"
        android:textSize="24sp"
        tools:text="1" />

  </androidx.cardview.widget.CardView>

</layout>

layoutタグで囲わなかったり、item_から始まる(今回の例の場合)リソース名を定義しなかった場合、自動でModel生成が行われません。

マージンに関しては、カスタムEpoxyModelでコードから指定しても良いですが、ここでレイアウトのルート自体に対してマージンを入れるでも良いです。

今回のように作成し、一度Buildを行うと、CardBindingModelを自動で生成してくれます。

クリック処理、Viewへのデータ適用などしたい場合に、カスタムDataBindingModelを定義する

次に、リストの項目に対して動的に値を変更したい場合や、クリック処理などを入れたい場合は別途カスタムDataBindingModelを作成する必要があります。
項目の表示のみの場合に関しては、作成しなくて良いです。

先ほどのレイアウトリソースを例にとって、カスタムしたDataBindingModelを次のように作成しました。

@EpoxyModelClass(layout = R.layout.item_card)
abstract class CardElementModel: DataBindingModel<ItemCardBinding>() {

    @EpoxyAttribute
    var numberText: String = ""

    @EpoxyAttribute(DoNotHash)
    var cardClickListener: View.OnClickListener? = null

    override fun bind(binding: ItemCardBinding) {
        binding.apply {
            number.text = numberText
            card.setOnClickListener(cardClickListener)
        }
    }

    override fun bindUpdating(binding: ItemCardBinding, previouslyBoundModel: EpoxyModel<*>?) {
        binding.apply {
            number.text = "Updating$numberText"
            card.setOnClickListener(cardClickListener)
        }
    }

    override fun unbind(binding: ItemCardBinding) {
        binding.card.setOnClickListener(null)
    }
}

最初に、前述の導入手順の方で紹介した、DataBindingModelを継承した抽象クラスを定義します。名前はなんでも良いですが、databindingで自動生成した名前(今回の例だと、CardModel)と被らないのがおすすめです。

カスタムDataBindingModelModelクラスでやることがいくつかあります。

@EpoxyModelClassで、カスタムしたいレイアウトリソース名を指定します。

次に、@EpoxyAttributeで設定したい値やクリックリスナーを定義します。
Epoxy側が自動生成してくれるので、privateにしないようにしましょう。
また、クリックリスナーは公式でも推奨されており、再bindを防止できるため、DoNotHashを付与しましょう。

その後、bind、bindUpdating、unbindの三つを実装します。

bindでは、リストの項目で通常の表示について定義します。大体の場合は、こちらに値を設定してしまう流れになると思います。

bindUpdatingでは、リストの要素の更新があった際に差分更新時の挙動を定義します。特に必要ない場合はUnitを返すと良いと思います。

unbindでは、ViewBinding.java内に書かれてある通り、リスナーの破棄を行いましょう。

Fragment側で、自動生成されたEpoxyModelをRecyclerViewに適用する

次に、EpoxyModelをRecyclerViewに適用していきます。

例として、次のように記載します。

ListFragment.kt

class ListFragment: Fragment() {

    private lateinit var binding: FragmentListBinding
    private val dataList = mutableListOf<Pair<Int, String>>()

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        binding = FragmentListBinding.inflate(inflater, container, false)
        return binding.root
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        initializeData()
        binding.apply {
            ...
            recyclerView.layoutManager = LinearLayoutManager(requireContext())
            ...
            recyclerView.withModels {
                dataList.forEach {
                    val (index, data) = it
                    cardElement {
                        id(LABEL_CARD_ID, "$index")
                        numberText(data)
                        cardClickListener { _ ->
                            Toast.makeText(requireContext(), "Press $data!", Toast.LENGTH_SHORT).show()
                        }
                    }
                }
            }
        }
    }

    private fun initializeData() {
        for(i in 0..10) {
            dataList.add(i to i.toString())
        }
    }

    companion object {
        private const val LABEL_CARD_ID = "label_card_id"
    }
}

最初に、単一のリストの場合は、layoutManagerの指定を、LinearLayoutManagerで指定します。

次に、recyclerView.withModelsの中で、構築したいリストの順番で、自動生成されたEpoxyModelを定義していきます。
毎回必要なこととして、idはユニークなものを指定し、カスタムしたEpoxyModelを使用している場合には、EpoxyAttributeで指定したものを一つずつ定義していきます。
気をつけることとして、idをユニークなものにしなかった場合に、重複した要素が出る場合があります。

単一リストの構築に関しては、以上となります。

グリッドリスト表示

次に、グリッドリストの表示についてです。サンプルとしては次の通りです。

f:id:rozkey59:20200905122821p:plain
グリッドリストのサンプル

グリッドリストは単純に、先ほどの単一リストの手順の一部を変更します。
単一リストでは、LayoutManagerをLinearLayoutManagerで指定しましたが、グリッドリストの場合は、GridLayoutManagerを指定するようにします。

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        ...
        binding.apply {
            ...
            recyclerView.layoutManager = GridLayoutManager(requireContext(), SPAN_COUNT)
            ...
        }
    }

    companion object {
        private const val SPAN_COUNT = 2
    }

SPAN_COUNTでは、指定したい列数を入れます。3列なら、3とすればOKです。

カルーセルを含む複雑なリスト表示

次に、カルーセルを含む複雑なリスト表示についてです。サンプルとしては、次のとおりです。

f:id:rozkey59:20200905123244p:plain
複雑なリストのサンプル

単一のリストのときとほぼやることは同じですが、2点必要なことがあります。次のとおりです。

  • カルーセル実装は、Epoxyで用意されているCarouselModelを利用する
  • 複雑なリストの場合は、spansizeをoverrideする

カルーセル実装は、Epoxyで用意されているCarouselModelを利用する

Carouselの実装は、自前で用意しなくてもEpoxy側が用意してくれているので、そちらを利用しましょう。

カルーセルの実装に関しては、次のように記載します。

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        ...
        binding.apply {
            ...
            recyclerView.withModels {
                val carouselElementsList = dataList.map {
                    DoubleRoundIconModel_()
                        .apply {
                            id(LABEL_DOUBLE_ROUND_ICON_ID, it.second)
                            iconClickListener { _ ->
                                Toast.makeText(requireContext(), "Press ${it.second}!", Toast.LENGTH_SHORT).show()
                            }
                        }
                }
                ...
                carousel {
                    id(LABEL_CAROUSEL_ID)
                    models(carouselElementsList)
                    ...
                }
                ...
            }
        }
    }

recyclerView.withModelsの中で、carouselを定義します。idを設定し、modelsにてカルーセルに表示したいEpoxyModelのリストを設定します。
これだけで、カルーセル表示が可能です。

複雑なリストの場合は、spansizeをoverrideする

複雑なリストを構築する場合は、layoutManagerをGridLayoutManagerで指定したのち、表示したいリストの項目によってspanSizeが異なるため、RecyclerViewと紐付けるEpoxyModel側でspanSizeをoverrideする必要があります。

次のように記述を行います。

class CarouselFragment: Fragment() {

    private lateinit var binding: FragmentCarouselBinding
    private val dataList = mutableListOf<Pair<Int, String>>()

    ...

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        ...
        binding.apply {
            ...
            recyclerView.layoutManager = GridLayoutManager(requireContext(), SPAN_COUNT)
            recyclerView.withModels {
                val carouselElementsList = dataList.map {
                    DoubleRoundIconModel_()
                        .apply {
                            id(LABEL_DOUBLE_ROUND_ICON_ID, it.second)
                            iconClickListener { _ ->
                                Toast.makeText(requireContext(), "Press ${it.second}!", Toast.LENGTH_SHORT).show()
                            }
                        }
                }
                labelTitle {
                    id(LABEL_TITLE_ID)
                    titleText("Carousel")
                    spanSizeOverride { _, _, _ -> COLUMN1 }
                    shouldHideRightArrowIcon(true)
                }
                carousel {
                    id(LABEL_CAROUSEL_ID)
                    models(carouselElementsList)
                    spanSizeOverride { _, _, _ -> COLUMN1 }
                }
                labelTitle {
                    id(LABEL_TITLE_ID)
                    titleText("GridList")
                    spanSizeOverride { _, _, _ -> COLUMN1 }
                    shouldHideRightArrowIcon(true)
                }
                dataList.forEach {
                    val (index, data) = it
                    cardElement {
                        id(LABEL_CARD_ID, "$index")
                        numberText(data)
                        spanSizeOverride { _, _, _ -> COLUMN3 }
                        cardClickListener { _ ->
                            Toast.makeText(requireContext(), "Press $data!", Toast.LENGTH_SHORT).show()
                        }
                    }
                }
                swipeRefresh.isRefreshing = false
            }
            ...
        }
    }

...

    companion object {
        private const val LABEL_TITLE_ID = "label_title_id"
        private const val LABEL_CARD_ID = "label_card_id"
        private const val LABEL_DOUBLE_ROUND_ICON_ID = "label_double_round_icon_id"
        private const val LABEL_CAROUSEL_ID = "label_carousel_id"
        private const val SPAN_COUNT = 3
        private const val COLUMN1 = SPAN_COUNT
        private const val COLUMN3 = SPAN_COUNT / 3
    }
}

GridLayoutManagerで指定するSPAN_COUNTは、複雑なリストの項目の中のグリッドの列数の最小公倍数を指定します。
今回は1列と3列なので、3を指定していますが、2列と3列が入る場合には、6を指定する必要があります。
spanSizeOverrideで指定する値は、最小公倍数をグリッド表示したい列数で割った値になる必要があります。
また、spanSizeOverrideを行うのは、今回でいうと1列の方は指定しなくて良いです。わかりやすいように、指定してあります。

差分更新について

最後に、差分更新についてです。 Epoxyではequalsやhashcodeを利用側で指定せずに、差分を検知して更新してくれます。
今回説明するサンプルは、Fabを押したときにリストの先頭の中身が更新されるようになっています。
サンプルの挙動は次のようになります。

f:id:rozkey59:20200905131058g:plain
差分更新のサンプル

差分更新を実現するために必要なことがあります。

  • bindUpdatingにて差分更新の際の挙動を指定する
  • ユニークなidをEpoxyModelに指定し、dataを変更後にrequestModelBuildを行う

bindUpdatingにて差分更新の際の挙動を指定する

まずはじめに、カスタムしたDataBindingModelの中で、差分更新時の挙動を記述します。 今回の例では次のとおりです。

@EpoxyModelClass(layout = R.layout.item_card)
abstract class CardElementModel: DataBindingModel<ItemCardBinding>() {

    @EpoxyAttribute
    var numberText: String = ""

    override fun bind(binding: ItemCardBinding) {
        binding.apply {
            number.text = numberText
            card.setOnClickListener(cardClickListener)
        }
    }

    override fun bindUpdating(binding: ItemCardBinding, previouslyBoundModel: EpoxyModel<*>?) {
        binding.apply {
            number.text = "Updating$numberText"
            card.setOnClickListener(cardClickListener)
        }
    }
...
}

bindUpdatingの中で、差分更新時にUpdatingという文字列を先頭につけるように変更しました。 サンプルの挙動を見ると、Fabをクリック後に更新した場合に、Updatingが最初についているのがわかります。
スクロールして、戻って再bindされた際にはUpdatingの文字列が消えていることから、差分更新時にのみbindUpdatingが呼ばれていることがわかります。

差分更新の挙動がわかったところで、次に差分更新をさせるために必要な処理があるので解説していきます。

ユニークなidをEpoxyModelに指定し、dataを変更後にrequestModelBuildを行う

bindUpdatingに差分更新の挙動を記述したら、Fragment側で差分更新をさせるために必要な処理を記述していきます。

例は次のとおりです。

...
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        ...
        binding.apply {
            recyclerView.itemAnimator = null
            ...
            fab.setOnClickListener {
                val newList = dataList.map { pair ->
                    if (pair.first == 0) {
                        pair.first to "Data!"
                    } else {
                        pair
                    }
                }
                dataList.clear()
                dataList.addAll(newList)
                recyclerView.requestModelBuild()
            }
            recyclerView.withModels {
                dataList.forEach {
                    val (index, data) = it
                    cardElement {
                        id(LABEL_CARD_ID, "$index")
                        numberText(data)
                        cardClickListener { _ ->
                            Toast.makeText(requireContext(), "Press $data!", Toast.LENGTH_SHORT).show()
                        }
                    }
                }
            }
        }
    }
...

まず、EpoxyModel側にユニークで変わらない値を指定しましょう。
リストの場合だとindexが良さそうです。
fab.setOnClickListenerの中で行っている、差分更新をするために、まずはdataを新しくする必要があります。
dataを更新したいものに変更したら、最後に、requestModelBuildを呼ぶことで再度EpoxyModelをbuildしてくれ、Epoxy側で差分を検知して更新を行ってくれます。

差分更新に関しては以上となります。

おわりに

最後までお付き合いいただき、ありがとうございました。

今回紹介した例に関しては、GitHubにて下記のリポジトリを上げております。参考になりましたら嬉しい限りです。

github.com

今回、書き切れなかったCarouselのカスタム、Stickyリスト、SmoothScrollのカスタムなどEpoxyを利用した場合にいくつか出てくるやりたいについては、②で解説しようと思います。

間違いなどありましたら、コメントいただけると嬉しいです。

良かったと思いましたら、読者登録、スターをしていただけると更新の励みになりますのでよろしくお願いいたします。