Epoxy と 入力フォーム

こんにちは。Android アプリ開発者の geckour です。
今回は、Android アプリ開発が便利になる Epoxy とそれを利用した入力フォーム作りについてお話しします。

はじめに

弊チームではリスト表示を含む UI の実装の際に、Epoxy を利用しています。
RecyclerView を使うと煩雑になりがちなリスト表示の実装も、Epoxy を利用すると宣言的に書くことができて大変便利です。
パーツの種類がいくつかあって使い回され、かつ縦に長くなりがちな入力フォームはまさに Epoxy がぴったりだと思い、その実装にも利用しようとしたところ、思わぬハマりどころがありました。

本記事では問題の発見から解決までの顛末を紹介いたします。

Epoxy

まずは Epoxy についてのお話です。

Epoxy とは?

RecyclerView を利用した UI 実装時の煩雑性をうまくラップしてくれるライブラリです。
Airbnb が提供しています。

いくつかの方法のいずれかでアイテムのビューを宣言し、それを EpoxyController というクラスで DSL によって宣言的に利用してリストを作り上げます。

アイテムの種類のハンドリングや DiffUtil の実装など、素の RecyclerView でやるとどうしても煩雑になりがちな部分をうまく隠蔽してくれます。

入力フォームと Epoxy

さて、Epoxy の実態は RecyclerView です。
つまり、各アイテムはリサイクルされることがあります。

ここで問題が発生しました。
これまでやってきた Epoxy を利用したリスト実装では、データとしてテキストや画像などユーザ入力によって変化しないものが使われていて、問題なくリサイクルが行われていました。
しかし、入力フォームのアイテムには EditText を含むものがあり、そのアイテムがリサイクルされた際に入力された文字列がアイテム間で入れ替わったり消えたりする現象に出会いました。

問題の調査

問題の原因特定は難航しました。

  • アイテムに直接変数として入力文字列を保持できないか
    • リサイクル時に保持できないので失敗
  • リスナーの動作がおかしいのでは
    • 当初リスナーを add だけして remove していなかった
    • unbind してみたもののまだ呼ばれていることを観測
    • きちんと unbind できてないのでは

このようなことを時間をかけて解明していきました。

リサイクルとリスナー

問題の原因

結局原因はリスナーの登録周りにありました。
問題のアイテムの EditText には TextWatcher というリスナーを addTextChangedListener() で登録していました。

Epoxy においてはどうもコールバックは自前で登録/解除しなければならないらしいのですが、解除の際に正しいアイテムのリスナーを指定できていなかったのが原因だったようです。

対処法

Epoxy に用意されている onBind()onUnbind() で対応しようとしたところうまく動かず、同じく Epoxy に用意されているプロパティ設定用のアノテーションを利用することで対処できました。
以下サンプルです。

@ModelView(autoLayout = ModelView.Size.MATCH_WIDTH_WRAP_HEIGHT)
class InputBoxView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr) {

    init {
        inflate(context, R.layout.item_input_box, this)
    }

    val editText: TextInputEditText = findViewById(R.id.inputBox)

    private val editTextWatcher = object : TextWatcher {
        override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit

        override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
            onEditTextChanged?.invoke(s?.toString())
        }

        override fun afterTextChanged(s: Editable?) = Unit
    }

    @set:CallbackProp
    var onEditTextChanged: ((newText: String?) -> Unit)? = null

    @AfterPropsSet
    fun postBindSetup() {
        editText.addTextChangedListener(editTextWatcher)
    }

    @OnViewRecycled
    fun onViewRecycled() {
        editText.removeTextChangedListener(editTextWatcher)
    }
}

登録したいコールバックの変数に @CallbackProp を付け、@AfterPropsSet@OnViewRecycled でそれぞれ attach/dettach しています。

おわりに

RecyclerView の実装が格段に楽になる Epoxy ですが、時々こういった癖のようなもので躓くことがあります。
ただ、今まで解決が不可能なレベルのものには出会っていませんし、これからも便利に使っていきたいと思っています。

みなさんの知見の紹介もお待ちしております!


こちらの記事を読まれて Quipper に興味をお持ちいただいた皆様、ぜひ Quipper に遊びに来ませんか?
以下 Wantedly ページよりお気軽にご連絡ください!
www.wantedly.com

また、2021-08-26 に Android エンジニアを対象とした「スタディサプリ/Quipper オンラインミートアップ #4」を開催します。
Quipper やスタディサプリにご興味がある方はぜひお気軽にご応募いただけると嬉しいです! quipper.connpass.com