個人的にEpoxyを利用していく中で学んだ基本的な知識と使い方、差分更新とハマったことのまとめ

※この記事は、2020年11月時点での内容です。Qiitaに投稿後に移行してきた記事になります。

はじめに

今回は、複雑な画面の構築を楽にしてくれるairbnbのライブラリEpoxyをプロダクト案件で利用してきたので、サンプルを元に知見をまとめて紹介させていただこうと思います。
利用するサンプルはこちらです。

こんな感じのリストが簡単に作れるようになります。
最後の方で、サンプルのリンク先について記載していますので、コードだけ見たい方はさいごにまで飛ばしていただけると幸いです。

記事概要

この記事で説明することは、次のとおりです。

  • 利用にあたって必要な知識
  • 基本的な使い方
  • EpoxyModelの各メソッドについて
  • 差分更新について
  • StickyHeaderを持つUIの実装方法について
  • Epoxyでのカルーセルの基本的な使い方とSnapHelperについて
  • ケース別での困った時の対処法

個人的に、振り返った時に必要だった知識や使い方、ケース別での困った時の対処法など紹介します。

利用にあたって必要な知識

EpoxyConfig

DataBinding利用時に、EpoxyDataBindingPatternを使ってレイアウトファイルからBindingModel_クラスを自動生成するためのファイルです。

layoutPrefixに定義されている名前のxmlを作成することで、自動生成されます。

私の場合は、layoutPrefixにepoxy_を指定しています。

個別にxmlを指定していくやり方もありますが、この手法で行うとこのファイルに追加の記載が必要なくなるので、こちらを推奨します。

EpoxyConfigはインターフェースでもクラスでもよく、どのパッケージに置いても問題ありません。

EpoxyConfig.kt
@EpoxyDataBindingPattern(rClass = R::class, layoutPrefix = "epoxy_")
interface EpoxyConfig

EpoxyModelについて

後述するDataBindingModelを継承したクラスを指します。

役割として、Viewに特定の値をbindしたい時に利用します。

特に値など指定する必要がない場合は、自動生成されたBindingModel_クラスを利用してください。

以降、EpoxyModelのことをモデルと表現します。

例として、下記のようなクラスのことを指しています。コード中の…は省略を意味し以降同様です。

ListRowModel.kt
@EpoxyModelClass(layout = R.layout.epoxy_list_row)
abstract class ListRowModel : DataBindingModel<EpoxyListRowBinding>() {
  ...
}

DataBindingModelについて

Epoxyライブラリによるものではないですが、EpoxyModel構築の際に実装をしやすいように用意しています。

EpoxyModelWithHolderを継承したEpoxyModelを作成する際にDataBindingを使用するための抽象クラスです。

これを継承することで、後述する必須の3つのメソッドについてを忘れずに実装できます。

DataBindingModel.kt
abstract class DataBindingModel<in T : ViewDataBinding> :
    EpoxyModelWithHolder<DataBindingEpoxyHolder>() {

    abstract fun bind(binding: T, context: Context)

    abstract fun bind(binding: T, context: Context, previouslyBoundModel: EpoxyModel<*>)

    abstract fun unbind(binding: T)

    @Suppress("UNCHECKED_CAST")
    override fun bind(holder: DataBindingEpoxyHolder) {
        val binding = holder.binding as? T ?: return
        val context = binding.root.context
        bind(binding, context)
    }

    @Suppress("UNCHECKED_CAST")
    override fun bind(holder: DataBindingEpoxyHolder, previouslyBoundModel: EpoxyModel<*>) {
        val binding = holder.binding as? T ?: return
        val context = binding.root.context
        bind(binding, context, previouslyBoundModel)
    }

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

EpoxyAttributeについて

注釈付きフィールドのゲッター、セッター、等号、およびハッシュコードを使用してモデルのサブクラスを生成するために、EpoxyModelクラスのフィールドに注釈を付けるためのアノテーションです。

EpoxyAttributeには、いくつかのOptionがありますが、 よく利用するケースとしてリスナーに関してはDoNotHashを利用します。

DoNotHashは、wikiにある通り、Epoxyは各モデルのequalsとhashCodeを呼び出して差分を検知して更新を行っています。

DoNotHashを指定することで、生成されたモデルのequalsとhashCodeの条件分岐がnullかどうかになり、一度入るとunbindでリソースの解放がされるまで差分更新の影響を受けなくなります。

バインド呼び出しごとに再作成される匿名のリスナーでは更新する必要がないため、このオプションを指定して定義するようにします。

CardElementModel.kt
@EpoxyModelClass(layout = R.layout.epoxy_list_row)
abstract class ListRowModel: DataBindingModel<EpoxyListRowBinding>() {

    @EpoxyAttribute
    lateinit var uiData: UiData

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

EpoxyRecyclerView

Epoxyの実装を楽にしてくれるRecyclerViewになります。

1番のメリットとして使用する場合は、EpoxyModelのビルドの際にEpoxyControllerを自身で用意する必要がなくなります。

他にもいくつかの利点があります。
LinearLayoutManagerがデフォルトで設定されているため、特にカスタムする必要がない単一リストの場合はLayoutManagerを用意する必要がありません。
Shared View Poolに関しては、同じアクティビティ内のすべてのインスタンスで同じビュープールを共有すると記載されています。

StickyHeaderLinearLayoutManagerについて

次のサンプルのようなStickyHeaderをもつUIを実装したい場合は、こちらのLayoutManagerを利用することで実現することが可能です。

詳しい使い方についてはStickyHeaderを持つUIの実装の仕方の章にて説明します。

基本的な使い方

Epoxy利用のレイアウトファイルを用意する

今回は、前述したEpoxyConfigのlayoutPrefixにてepoxy_からはじまる名前のレイアウトファイルを定義すると定めています。

例えば、次のようにepoxy_list_row.xmlという名前のレイアウトファイルを定義することで、ListRowBindingModel_クラスが自動生成されます。

ListRowBindingModel_.kt
public class ListRowBindingModel_ extends DataBindingEpoxyModel implements GeneratedModel<DataBindingEpoxyModel.DataBindingHolder>, ListRowBindingModelBuilder {
  ...
}

特に値などカスタムしない場合は、このまま自動生成されたクラスを利用することができます。

動作や値をカスタムしたい場合、EpoxyModelを用意する

Viewに表示する情報やクリックリスナーなど用意する必要がある場合は、DataBindingModelを継承したEpoxyModelクラスを作成します。

クラス名に特に決まりはないのですが、XXXModel(XXXは任意の文字列)として定義しています。

例えば、ListRowModelを例にとると、次のような実装になります。

ListRowModel.kt
@EpoxyModelClass(layout = R.layout.epoxy_list_row)
abstract class ListRowModel: DataBindingModel<EpoxyListRowBinding>() {
    ...
    override fun bind(binding: EpoxyListRowBinding, context: Context) {
        ...
    }

    override fun bind(
        binding: EpoxyListRowBinding,
        context: Context,
        previouslyBoundModel: EpoxyModel<*>?
    ) {
        ...
    }

    override fun unbind(binding: EpoxyListRowBinding) {
        ...
    }
}

クラスに対して@EpoxyModelClassのアノテーションをつけ、layoutIdでレイアウトのxmlを指定します。
DataBindingModelを継承しているため、後述する必須の3つのメソッドをoverrideし、必要な処理を記載します。

Activity / Fragment側にてEpoxyRecyclerViewを用意する

ActivityかFragment側のレイアウトファイルにて、EpoxyRecyclerViewを用意してください。
例えば、 fragment_list.xml の実装は次のようになっています。

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

    ...

            <com.airbnb.epoxy.EpoxyRecyclerView
                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_toTopOf="parent"
                />
    ...              
</layout>

Activity / Fragment側にて生成されたEpoxyModelに値や動作を記述する

ActivityかFragment側にてモデルの構築を行います。サンプルの場合は、updateViews内のIDEALに記載されています。

EpoxyRecyclerView#withModelsにて、次にあげる例のソースコードのように、自動生成されたモデルに対して必要な情報や処理の記載を行います。

例えば、 ListFragment.kt の実装は次のようになっています。

ListFragment.kt
binding.recyclerView.withModels {
  ...
  val list = data ?: throw IllegalArgumentException("Illegal results are being returned. Please review the communication.")
  ...
  list.forEach {
    listRow {
      id(LIST_ROW_ID, "${it.id}")  // ユニークな値を指定する
      listData(it)  // EpoxyAttributeで定めたデータをセットします
      spanSizeOverride { _, _, _ -> COLUMN1 }  // 列数についての指定を行います
      onRootClicked { _ ->
        Toast.makeText(requireContext(), "Press ${it.name}!", Toast.LENGTH_SHORT).show()
      }
    }
  }
}

EpoxyModelの各メソッドについて

EpoxyModel作成の際には共通で実装すべきメソッドが3つあります。ここでは、それらについて説明します。※XXXには任意のモデルの名前が入ります

ここでは、具体例として、 ListRowModel.kt を元に説明します。全て表示すると差分が大きいため、一部ソースコードを省略しています。

bind(binding: EpoxyXXXBinding, context: Context)

指定のViewにデータやリスナーをセットします。

差分更新以外の通常の時に基本的な処理についてはこちらに記載をします。

例えば、ListRowModelの場合は次のようになります。

ListRowModel.kt
@EpoxyModelClass(layout = R.layout.epoxy_list_row)
abstract class ListRowModel: DataBindingModel<EpoxyListRowBinding>() {

    @EpoxyAttribute
    lateinit var listData: ListData
    ...
    @EpoxyAttribute(DoNotHash)
    var onRootClicked: View.OnClickListener? = null

    override fun bind(binding: EpoxyListRowBinding, context: Context) {
        binding.apply {
            label.visibility = View.GONE
            // EpoxyAttributeにて定義したデータを元に処理を記載します
            name.text = listData.name
            description.text = listData.description ?: "No Description."
            Glide.with(context)
                .load(listData.userUrl)
                .centerCrop()
                .into(userIcon)
            // EpoxyAttributeにて指定したクリックリスナーをセットします
            root.setOnClickListener(onRootClicked)
        }
    }
  ...
}

bind(binding: EpoxyXXXBinding, context: Context, previouslyBoundModel: EpoxyModel<*>)

差分更新時に呼ばれる為、差分更新の挙動について記載を行います。

previouslyBoundModelは、以前にバインドされたものと同じIDを持つモデルです。

このモデルと現在のモデルを比較して、何が変更されたかによって更新を行うようにします。

例えば、ListRowModelの場合は次のようになります。

ListRowModel.kt
@EpoxyModelClass(layout = R.layout.epoxy_list_row)
abstract class ListRowModel: DataBindingModel<EpoxyListRowBinding>() {
    ...
    @JvmField
    @EpoxyAttribute
    var isChanged: Boolean = false
    ...
    override fun bind(
        binding: EpoxyListRowBinding,
        context: Context,
        previouslyBoundModel: EpoxyModel<*>?
    ) {
        // 念の為同じIDを持つ前のモデルが違う場合は早期リターンを行います。
        if (previouslyBoundModel !is ListRowModel) return
        // 更新時に同じIDを持つ前のモデルと後のモデルで値の比較を行います。同じ場合は通常のbindメソッドを念の為呼ぶようにしています。
        if (previouslyBoundModel.isChanged == isChanged) {
            bind(binding, context)
            return
        }
        // 差分更新時に行いたい処理を記載しています。サンプルではisChangedの値が異なる場合に変わったことを示すラベルを表示するようにしています
        binding.apply {
            label.visibility = View.VISIBLE
        }
    }
    ...
}

この状態で、差分更新を行った時の挙動が次のとおりです。Fabをクリックした時にAPIを呼び出し、データの更新を行い、差分が検知されてChangedのラベルが表示されていることが確認できます。

unbind

モデルにバインドされたビューがリサイクルされるときに呼び出されます。ビューがリサイクルされたときにリソースを解放する必要がある場合の処理を記載します。

利用パターンとしては、画像のリソース解放とリスナーの解放を行うようにしています。初期化として利用はしないことに注意してください。

例えば、ListRowModelの場合は次のようになります。

ListRowMode.kt
@EpoxyModelClass(layout = R.layout.epoxy_list_row)
abstract class ListRowModel: DataBindingModel<EpoxyListRowBinding>() {
    ...
    override fun unbind(binding: EpoxyListRowBinding) {
        binding.root.setOnClickListener(null)
        binding.userIcon.setImageDrawable(null)
    }
}

差分更新について

差分更新の仕方は、更新したいModelの新しいデータを用意してbindにて差分更新の条件を明記します。

サンプルでは、更新したい際の動作に合わせて、API呼び出し後UiStateと一緒に新しいデータをセットして更新を行うようにしています。

APIを呼ばずに更新したい場合は、requestModelBuildを行う必要があります。

次の画面を例にとって説明します。

差分更新の条件については、前述のbindについてを参照してください。

サンプルの場合だと、ViewModelにてViewに必要な情報をUiDataというクラスで保持しているため、API呼び出し後に更新したい項目のみ新しいものに入れ替えてデータを更新します。

ListViewModelを例にとって、データ更新のロジックは次の通りです。

ListViewModel.kt
class ListViewModel : ViewModel() {

    private fun repositoryBuilder() = GitHubRepository()
    val uiLive = MutableLiveData<Pair<UiState, UiData?>>()
    private var uiData = UiData(
        list = mutableListOf(),
        isChanged = false
    )

    fun getGitHubRepositoryData(since: Int, shouldChange: Boolean = false) {
        viewModelScope.launch {
            uiLive.postValue(UiState.LOADING to null)
            repositoryBuilder().getRepositories(since).collect { response ->
                try {
                    // 更新するためのデータを作り直す
                    uiData = uiData.copy(
                        list = response,
                        isChanged = shouldChange
                    )
                    // 更新したデータをLiveDataに渡す。サンプルはUIの状態として、UiStateを定めているため、一緒に渡しています。
                    uiLive.postValue(UiState.IDEAL to uiData)
                } catch (e: HttpException) {
                    uiLive.postValue(UiState.ERROR to null)
                }
            }
        }
    }
}

その後にLiveDataにて、更新したデータを流し再度recyclerView#withModelsが呼び出されることでモデルの再ビルドが走り、Epoxy側が内部的に差分を検知して差分更新のbindメソッドを呼び出すようになります。

サンプルの場合は、LiveDataを元にして必ずrecyclerView#withModelsが通るため、requestModelBuild()を呼ぶことはないですが、requestModelBuild()でもモデルの再ビルドになるため、同様の結果を受けることができます。

StickyHeaderを持つUIの実装方法について

利用する手順としては、次のとおりです。
1. TypedEpoxyControllerを用意して、buildModelsにて構築したいEpoxyModelについて記載する
2. EpoxyRecyclerViewに1で用意したControllerのAdapterと、StickyHeaderLinearLayoutManagerをセットする
3. TypedEpoxyController#setDataで更新したい時に、更新したいデータをセットする
4. HeaderとするModelの記載を行う

StickyListFragment.ktを例にとって説明します。
まず、TypedEpoxyControllerを用意して、buildModelsにて構築したいEpoxyModelについてbuildModelsの中に記載を行います。

StickyListFragment.kt
class StickyListFragment: Fragment() {
    private lateinit var binding: FragmentStickyListBinding
    ...
    // モデルにセットしたいデータを持つTypedEpoxyControllerを用意
    private lateinit var stickyHeaderController: TypedEpoxyController<List<ListData>>
    ...
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        setUp()
    }

    private fun setUp() {
        viewModel = StickyListViewModel()
        stickyHeaderController = object : TypedEpoxyController<List<ListData>>() {
            override fun buildModels(data: List<ListData>) {
                // 構築したいEpoxyModelについて記載する
                data.forEach {
                    stickyHeader {
                        id(STICKY_HEADER_ID, "${it.id}Header")
                        titleText(it.name)
                        onTitleClicked { _ ->
                            Toast.makeText(requireContext(), "Press ${it.name}!", Toast.LENGTH_SHORT).show()
                        }
                    }

                    stickyContents {
                        id(STICKY_CONTENTS_ID, "${it.id}")
                        listData(it)
                        onRootClickListener { _ ->
                            Toast.makeText(requireContext(), "Press ${it.description}!", Toast.LENGTH_SHORT).show()
                        }
                    }
                }
            }
          ...
        }
        ...
    }
  ...
}

次に、EpoxyRecyclerViewに用意したTypedEpoxyControllerのAdapterとStickyHeaderLinearLayoutManagerのセットを行います。

StickyListFragment.kt
class StickyListFragment: Fragment() {
    ...
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        setUp()
    }

    private fun setUp() {
        ...
        binding.apply {
            ...
            recyclerView.layoutManager = StickyHeaderLinearLayoutManager(requireContext())
            recyclerView.adapter = stickyHeaderController.adapter
        }
        ...
    }
  ...
}

次に、TypedEpoxyController#setDataで更新したい時に、更新したいデータをセットするようにします。サンプルの場合は、通信が成功した時に、UiState.IDEALに更新時のデータが流れるので、ここでsetDataを行っています。

StickyListFragment.kt
class StickyListFragment: Fragment() {
    ...
    private fun updateViews(uiState: UiState, data: UiData?) {
        when(uiState) {
            ...
            UiState.IDEAL -> {
                val uiData = data ?: throw IllegalArgumentException("Illegal results are being returned. Please review the communication.")
                ...
                stickyHeaderController.setData(uiData.list)
            }
        }
    }
    ...
}

最後に、HeaderとするEpoxyModelの指定を行います。
次のようにすることで、Headerとなるモデルの指定が可能です。

StickyListFragment .kt
class StickyListFragment: Fragment() {
    ...
    private fun setUp() {
        ...
        stickyHeaderController = object : TypedEpoxyController<List<ListData>>() {
            ...
            // HeaderとするEpoxyModelの指定を行う
            override fun isStickyHeader(position: Int): Boolean {
                return adapter.getModelAtPosition(position)::class == StickyHeaderModel_::class
            }
        }
        ...
    }
    ...
}

Epoxyでのカルーセルの基本的な使い方とSnapHelperについて

基本的な使い方

Epoxyを利用すると、カルーセルの実装が非常に楽に行うことができます。
カルーセルというのは、下記のサンプル画像の赤枠部分のUIを指します。

では、ソースコードを見ていきましょう。
まずは、カルーセルにセットする項目の一つ一つのEpoxyModelのリストを作成します。サンプルでいうと、丸いアイコンとテキストのUIを持つEpoxyModelを定義したリストを作成します。

ListFragment.kt
binding.recyclerView.withModels {
  // カルーセルのリストを作成する
  val carouselList = list.map {
    // カルーセル中の項目の一つ一つのModel
    ContributorsModel_()
      .apply {
        id(CONTRIBUTORS_ID, "${it.id}")
        uiData(it)
        onRootClickListener { _ ->
          Toast.makeText(requireContext(), "Press ${it.name}!", Toast.LENGTH_SHORT).show()
        }
      }
    }
  ...
}

次に、EpoxyにはCarouselのEpoxyModelが標準で用意されており、Kotlin Extenstionsもサポートされているため、次のようにして利用します。

ListFragment.kt
binding.recyclerView.withModels {
  ...
  carousel {
    val margin12 = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 12F, resources.displayMetrics).toInt()
    val margin16 = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 16F, resources.displayMetrics).toInt()
    id(CAROUSEL_ID)
    models(carouselList)  // カルーセルのリストをセットする
    spanSizeOverride { _, _, _ -> COLUMN1 }
    numViewsToShowOnScreen(4.5f)  // 表示したいカルーセルの項目数を設定する
    padding(Carousel.Padding(0, margin16, 0, margin12, 0))  // カルーセルのPaddingを設定する
  }
}

EpoxyRecyclerView#withModelsの中で、modelsに先ほど作成したEpoxyModelのリストをセットします。
表示に関しては、これだけで可能です。
さらに、表示項目数を選ぶことができるため、サンプルではnumViewsToShowOnScreenで、4.5fと指定しています。これは、4.5個分の要素を表示することを示しています。
これだけではなく、paddingにてカルーセルの枠からの上下左右のpaddingと項目間のスペースをも指定することができます。サンプルでは上下にpaddingを追加しています。

カルーセルのSnapHelperについて

EpoxyではデフォルトでSnapHelperが指定されているため、特にカスタムせずに使用すると次のような挙動になります。

サンプルでは右端までスクロールして、戻らない挙動を実現したいため、無効にする対応を入れています。
その方法について紹介します。

Customizationにあるとおり、まずはカルーセルの振る舞いを変えるべくカルーセルのサブクラスを作成します。

DisableSnapHelperCarousel.kt
@ModelView(saveViewState = true, autoLayout = ModelView.Size.MATCH_WIDTH_WRAP_HEIGHT)
class DisableSnapHelperCarousel(context: Context) : Carousel(context) {
    override fun getSnapHelperFactory(): SnapHelperFactory? {
        return null
    }
}

getSnapHelperFactoryにて、Snapの振る舞いを変更できます。今回は無効化にしたいため、nullを指定します。

次に、Kotlin Extensionsと同様にモデルの構築ができるように、Builderクラスを用意します。

DisableSnapHelperCarouselBuilder.kt
class DisableSnapHelperCarouselBuilder(
    val disableSnapHelperCarousel: DisableSnapHelperCarouselModel_ = DisableSnapHelperCarouselModel_()
) : ModelCollector, DisableSnapHelperCarouselModelBuilder by disableSnapHelperCarousel {
    private val models = mutableListOf<EpoxyModel<*>>()
    override fun add(model: EpoxyModel<*>) {
        models.add(model)
        disableSnapHelperCarousel.models(models)
    }
}

ここでは、カルーセルにセットするカルーセルの項目の一つ一つのEpoxyModelを追加するようにしています。
最後に、Fragment側で拡張関数を作成し、EpoxyRecyclerView#withModelsの中で通常のカルーセルと同様に記載します。

ListFragment.kt
binding.recyclerView.withModels {
  ...
  customCarousel {
    val margin12 = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 12F, resources.displayMetrics).toInt()
    val margin16 = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 16F, resources.displayMetrics).toInt()
    id(CAROUSEL_ID)
    models(carouselList)  // カルーセルのリストをセットする
    spanSizeOverride { _, _, _ -> COLUMN1 }
    numViewsToShowOnScreen(4.5f)  // 表示したいカルーセルの項目数を設定する
    padding(Carousel.Padding(0, margin16, 0, margin12, 0))  // カルーセルのPaddingを設定する
  }
}

// 公式Kotlin Extensionsの時と同じようにして、Modelのセットを行う
private fun ModelCollector.customCarousel(builder: DisableSnapHelperCarouselBuilder.() -> Unit) : DisableSnapHelperCarouselModel_ {
  val carouselBuilder = DisableSnapHelperCarouselBuilder().apply { builder() }
  add(carouselBuilder.disableSnapHelperCarousel)
  return carouselBuilder.disableSnapHelperCarousel
}

これでサンプルを動かしてみると、SnapHelperが無効になりカルーセルの振る舞いのカスタムができていることがわかります。

ケース別での困ったときの対処法

更新時のアニメーションが意図せずに走る場合

デフォルト状態だと、更新時に差分を検知してRecyclerViewのアニメーションが走ってしまいます。

EpoxyRecyclerViewを持つActivityやFragmentで、 recyclerView.itemAnimator = null を指定して、無効化する必要があります。

コンテンツの内容が重複している場合

モデルはIDをセットする必要があり、このIDがユニークな値でないと重複しているとし、同じ要素のViewが表示されてしまうことがあります。

この場合、IDをユニークな値にして重複を避けましょう。

差分更新がされない場合

新しいデータを入れるようにして、再度Modelをビルドしても差分更新がされない場合、モデルのIDに問題があるということがありました。

モデルのIDに、可変な値を指定してしまっているとモデルを別のものと認識して差分更新のbindが呼ばれません。差分更新がうまくいかない場合は、モデルのIDの見直しを行ってみてください。

ListFragment.kt
class ListFragment: Fragment() {
    ...  
    private fun updateViews(uiState: UiState, data: UiData?) {
        when(uiState) {
            ...
            UiState.IDEAL -> {
                 ...
                    list.forEach {
                        listRow {
                            id(LIST_ROW_ID, "${it.id}")  // IDはユニークな値にし、可変な値は指定しない
                            listData(it)
                            isChanged(data.isChanged)
                            spanSizeOverride { _, _, _ -> COLUMN1 }
                            onRootClicked { _ ->
                                Toast.makeText(requireContext(), "Press ${it.name}!", Toast.LENGTH_SHORT).show()
                            }
                        }
                    }
                }
            }
        }
    }
  ...
}

BooleanをEpoxyAttributeで利用したい場合

Epoxyのissueによると、Epoxyの生成されたモデルは属性ごとにgetterを作成してくれますが、

kotlinがgetterをfinalにして、ブール値の場合のみうまく生成されないようです。

対応自体が入っていない為、現状の回避策として、@JvmFieldを属性に付与して、フィールドに直接アクセスするようにします。

ListRowModel.kt
@EpoxyModelClass(layout = R.layout.epoxy_list_row)
abstract class ListRowModel: DataBindingModel<EpoxyListRowBinding>() {
    ...
    @JvmField
    @EpoxyAttribute
    var isChanged: Boolean = false
    ...
}

プルトゥがうまく動作しない場合

プルトゥリフレッシュを行いたい場合において、注意点があります。

モデルのビルドを行う際に、一番先頭にくるモデルが非表示になる可能性がある場合、つまり、タイミングによって先頭のモデルが非表示になってしまうとプルトゥが効かなくなる現象が判明してます。

EpoxyRecyclerView#withModelsにて最初に追加を行うEpoxyModelは必ず表示される想定のモデルを入れるようにしましょう。

回避策として、先頭に目に見えないほどの高さのダミーのViewのモデルを入れるという手法があります。

モデルのデータのセットやリスナーのセットがうまくいかない場合

カスタムしたEpoxyModelを利用している場合で、ActivityやFragmentにてモデルにデータをセットしたりリスナーをセットする際にうまくいかないことがあります。

その際に、現在使用しているモデルがカスタムしたEpoxyModelによって自動生成されたモデルを使用しているかの確認を行ってください。

カスタムしたEpoxyModelを使用している場合は、BindingModelとModelがそれぞれ自動生成されます。BindingModelを参照している可能性が高いため、一度見直しを行うようにしてください。

Sample.kt
// BindingModel
hoge(modelInitializer: HogeBindingModelBuilder.() -> Unit)
// Model
hoge(modelInitializer: HogeModelBuilder.() -> Unit)

さいごに

Epoxyを利用することで、簡単にリストの構築ができるのでぜひ利用してみてください。
今回紹介したサンプルに関しては、下記リンクより詳細な実装をみることができます。

AndroidUITips | GitHub

少しでも何かの参考になれば幸いです。

コメントを残す

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

CAPTCHA