Androidネイティブのコレクションバインディング事情

この記事は、[VALU Advent Calendar 2018](https://qiita.com/advent-calendar/2018/valu)の5日目のエントリです。


[前回の記事](https://qiita.com/_pochi/items/5cdf71311991c45dfefb)で書ききれなかったコレクションのバインディング事情について書きます。

# やりたいこと

C#erとしては、ListViewのItemsSourceにObservableCollectionをバインドしてあげて、あとは良い感じにDataTemplateを提供してやればコレクションの増減にも追従してくれる...そんなバインディングをしたくなりますよね。

<img src="https://qiita-image-store.s3.amazonaws.com/0/46184/caa93469-f145-fd98-134b-26f4f0b75ef9.gif" width="50%" >

## 外部ライブラリを使わずに実装してみる
特に外部ライブラリを使わずに実装すると、以下のようになるでしょうか。

### ViewModel
ViewModelはこちら。表示したいコレクションは、変更検知機構を持つObservableListにしています。

``` SampleViewModel.kt
class SampleViewModel {
    val text: ObservableField<String> = ObservableField("Hello Binding!!!")
    val collection: ObservableList<SampleItemModel> = ObservableArrayList()

    fun addItem(text: String) {
        collection.add(SampleItemModel(text))
    }
}
```

### Fragment
Fragmentのコードはこちら。RecylerViewにadapterをセットすることで、表示内容を定義します。

``` MainActivityFragment.kt
class MainActivityFragment : Fragment() {

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        // Bindingクラスを通してInflateする
        val binding = FragmentMainBinding.inflate(inflater, container, false)
        // DataBindingのため、ViewModelをセットする
        val viewModel = SampleViewModel()
        binding.viewModel = viewModel

        // ViewModelのCollectionを表示するようにadapterを設定する
        binding.recyclerView.adapter = RecyclerViewAdapter(viewModel.collection)

        // ObservableListの変更イベントが飛んできたらRecyclerViewをリロードする
        viewModel.collection.addOnListChangedCallback(object: ObservableList.OnListChangedCallback<ObservableList<SampleItemModel>>() {
            override fun onChanged(sender: ObservableList<SampleItemModel>?) {
                binding.recyclerView.adapter?.notifyDataSetChanged()
            }

            override fun onItemRangeRemoved(
                sender: ObservableList<SampleItemModel>?,
                positionStart: Int,
                itemCount: Int
            ) {
                binding.recyclerView.adapter?.notifyDataSetChanged()
            }

            override fun onItemRangeMoved(
                sender: ObservableList<SampleItemModel>?,
                fromPosition: Int,
                toPosition: Int,
                itemCount: Int
            ) {
                binding.recyclerView.adapter?.notifyDataSetChanged()
            }

            override fun onItemRangeInserted(
                sender: ObservableList<SampleItemModel>?,
                positionStart: Int,
                itemCount: Int
            ) {
                binding.recyclerView.adapter?.notifyDataSetChanged()
            }

            override fun onItemRangeChanged(
                sender: ObservableList<SampleItemModel>?,
                positionStart: Int,
                itemCount: Int
            ) {
                binding.recyclerView.adapter?.notifyDataSetChanged()
            }

        })

        //binding.recyclerView.adapter
        // inflateしたroot要素を返してやる
        return binding.root
    }
}
```

### Adapter

実装のキモとなる、RecylerViewにセットするAdapterがこちら。通常のinflateではなく、Bindingクラスを利用してinflateしています。

``` RecyclerViewAdapter.kt
    class RecyclerViewAdapter(val collection: ObservableList<SampleItemModel>) :
        RecyclerView.Adapter<BindableViewHolder<ItemTextBinding>>() {

        // viewHolderの作成時に、表示したいレイアウトをInflateしてやる
        override fun onCreateViewHolder(parent: ViewGroup, position: Int): BindableViewHolder<ItemTextBinding> {
            val layoutInflater = LayoutInflater.from(parent.context)
            val binding = ItemTextBinding.inflate(layoutInflater, parent, false)
            return BindableViewHolder(binding)
        }

        // アイテム数はObservableListから取得
        override fun getItemCount(): Int = collection.size

        // このViewHolderが表示すべきアイテムのindexが渡されるので、ViewModelを更新する
        override fun onBindViewHolder(viewHolder: BindableViewHolder<ItemTextBinding>, position: Int) {
            viewHolder.binding.itemModel = collection[position]
        }
    }

    class BindableViewHolder<T : ViewDataBinding>(val binding: T) : RecyclerView.ViewHolder(binding.root)
```

### Layout
最後にレイアウトです。

``` fragment_main.xml
<?xml version="1.0" encoding="utf-8"?>
<!--ルート要素をlayoutタグにする-->
<layout
        xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:tools="http://schemas.android.com/tools"
        xmlns:app="http://schemas.android.com/apk/res-auto"
        >
    <!--ここでバインドするクラスを指定できる-->
    <data>
        <!--バインドするクラスの型と、それにアクセスするための変数名を指定する-->
        <variable
                name="viewModel"
                type="in.kikr.android.bindingsample.viewmodel.SampleViewModel"/>

        <!--additionalTextViewのVisibilityのためにViewをインポートしておく-->
        <import type="android.view.View"/>
    </data>

    <android.support.constraint.ConstraintLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            tools:showIn="@layout/activity_main"
            tools:context=".MainActivityFragment">
~~ 略 ~~
        <android.support.v7.widget.RecyclerView
                android:id="@+id/recyclerView"
                android:layout_width="match_parent"
                android:layout_height="0dp"
                app:layout_constraintTop_toBottomOf="@id/additionalTextView"
                app:layout_constraintBottom_toTopOf="@id/addItemButton"
                android:layout_marginTop="8dp"
                app:layoutManager="android.support.v7.widget.LinearLayoutManager"
                />

        <Button android:id="@+id/addItemButton"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="Add Item"
                app:layout_constraintTop_toBottomOf="@id/recyclerView"
                app:layout_constraintBottom_toBottomOf="parent"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintEnd_toEndOf="parent"
                android:onClick="@{()->viewModel.addItem(viewModel.text)}"
                />

    </android.support.constraint.ConstraintLayout>
</layout>

```

これで簡易的にコレクションのバインドはできますが、以下のようなことを考えるとこのままではちょっとつらいです。

- 表示したいコレクションの型が複数あり、型によって違うレイアウトを表示してあげたい
- アイテムの増減時に毎回refreshするのはちょっとなぁ


## BindingCollectionAdapter
そこでこちらのライブラリを利用します。
https://github.com/evant/binding-collection-adapter

こちらのライブラリを利用してコードを書き換えてみましょう。

### build.gradle
まずライブラリを依存関係に追加しましょう。また、`kotlin-kapt`も追加が必要です。
（今回のアプリがAndroidXにまだ対応していないので、微妙に古いバージョンを使っています、、、）

``` build.gradle
apply plugin: 'kotlin-kapt'

dependencies {
    ...
    // BindingCollectionAdapter
    implementation 'me.tatarka.bindingcollectionadapter2:bindingcollectionadapter:2.3.0-beta3'
    implementation 'me.tatarka.bindingcollectionadapter2:bindingcollectionadapter-recyclerview:2.3.0-beta3'
}
```

### バインド情報を保持するためのクラスを作成
実際にバインドするコレクションと、バインド定義を保持するクラスをViewModelとは別に作成します。
XAMLでいえば`ItemsSource`と`DataTemplate`みたいなものだと思ってもらえれば良いと思います。

``` BindingParameter.kt
class BindingParameter<T>(
    // バインド対象のコレクション
    val collection: ObservableList<T>,
    // バインド定義
    val itemBinding: OnItemBindClass<T>)
```

### レイアウト定義にバインド設定を追加

次にXMLの定義です。
RecyclerViewに指定した、`app:itemBinding`と`app:items`属性と、その属性にバインドするための`bindingParameter`変数を新たに追加しました。

`bindingParameter`の型は、先ほど作成した `BindingParameter<T>`クラスであり、引数としてSampleViewModelのコレクション内アイテムの型を指定しています。（`SampleItemModel`）

``` fragment_main.xml
<?xml version="1.0" encoding="utf-8"?>
<layout
        xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:tools="http://schemas.android.com/tools"
        xmlns:app="http://schemas.android.com/apk/res-auto"
        >
    <data>

        <import type="in.kikr.android.bindingsample.binding.BindingParameter"/>
        <import type="in.kikr.android.bindingsample.viewmodel.SampleItemModel"/>
        <import type="android.view.View"/>

        <variable
                name="viewModel"
                type="in.kikr.android.bindingsample.viewmodel.SampleViewModel"/>

        <!--コレクションバインディング定義-->
        <variable
                name="bindingParameter"
                type="BindingParameter&lt;SampleItemModel&gt;"/>

    </data>

    <android.support.constraint.ConstraintLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            tools:showIn="@layout/activity_main"
            tools:context=".MainActivityFragment">

~略~

        <android.support.v7.widget.RecyclerView
                android:id="@+id/recyclerView"
                android:layout_width="match_parent"
                android:layout_height="0dp"
                app:layout_constraintTop_toBottomOf="@id/additionalTextView"
                app:layout_constraintBottom_toTopOf="@id/addItemButton"
                android:layout_marginTop="8dp"
                app:layoutManager="android.support.v7.widget.LinearLayoutManager"
                app:itemBinding="@{bindingParameter.itemBinding}"
                app:items="@{bindingParameter.collection}"
                />

        <Button
                android:id="@+id/addItemButton"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="Add Item"
                app:layout_constraintTop_toBottomOf="@id/recyclerView"
                app:layout_constraintBottom_toBottomOf="parent"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintEnd_toEndOf="parent"
                android:onClick="@{()->viewModel.addItem(viewModel.text)}"
                />

    </android.support.constraint.ConstraintLayout>
</layout>

```

### バインド設定をFragment側で作成

Fragment側で行うことは、`BindingParameter`を作成してBindingクラスのvariableにセットするだけです。
「以上で、`SampleItemModel`という型を、`item_text.xml`のレイアウトを使って表示する」という定義は完了です。

``` MainActivityFragment.kt
class MainActivityFragment : Fragment() {

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        // Bindingクラスを通してInflateする
        val binding = FragmentMainBinding.inflate(inflater, container, false)
        // DataBindingのため、ViewModelをセットする
        val viewModel = SampleViewModel()
        binding.viewModel = viewModel

        // コレクションバインディングの定義を行う
        binding.bindingParameter = BindingParameter(
            // バインドしたいコレクションを指定
            viewModel.collection,
            // どの型をどのレイアウトにバインドするかを設定する
            OnItemBindClass<SampleItemModel>().map(
                // 型
                SampleItemModel::class.java,
                // variableタグで指定した変数名を指定
                BR.itemModel,
                // SampleItemModelをRecyclerView内で表示するためのレイアウトを指定
                R.layout.item_text)
        )

        // inflateしたroot要素を返してやる
        return binding.root
    }
}

```

### 動作デモ
何も変わらないのでは・・・という感じはありますが、ちょっと違います。
前の例ではアイテムの変更時に全リロードをしていましたが、BindingCollectionAdapterではきちんとイベントに応じたRecyclerViewの操作をしているので、アイテム追加アニメーションなんかもきちんと再生されています。

<img src="https://qiita-image-store.s3.amazonaws.com/0/46184/09e3887d-d477-3b34-1019-e7c6817d239f.gif" width="50%"/>


# 複雑なコレクションバインディング
次は、型によってレイアウトを変更してみたいと思います。
`SampleItemModel`を`sealed class`にして、複数の子クラスを持つようにしつつ、ViewModelを以下のように変更してみました。

### ViewModel

``` SampleViewModel.kt

class SampleViewModel {
    val text: ObservableField<String> = ObservableField("Hello Binding!!!")
    val collection: ObservableList<SampleItemModel> = ObservableArrayList()

    fun addItemLeft(text: String) {
        collection.add(SampleItemModel.LeftText(text))
    }
    fun addItemRight(text: String) {
        collection.add(SampleItemModel.RightText(text))
    }
    fun addImage() {
        collection.add(SampleItemModel.Image)
    }
}

sealed class SampleItemModel {
    class LeftText(val text: String) : SampleItemModel()
    class RightText(val text: String) : SampleItemModel()
    object Image : SampleItemModel()
}
```

### レイアウトXML
fragmentのレイアウトは、ボタンを足しただけです。
RecylerViewに表示するitemのレイアウトは、ViewModelに対応する以下の3つを追加しました。

``` item_text_left.xml
<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>
    <!--バインドするViewModelの型と変数名を指定-->
    <variable
            name="itemModel"
            type="in.kikr.android.bindingsample.viewmodel.SampleItemModel.LeftText"/>
</data>

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

    <!--ViewModelのtextプロパティを表示する-->
    <TextView
            android:layout_width="wrap_content"
            android:layout_height="match_parent"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintHorizontal_bias="0.0"
            tools:text="itemText"
            android:text="@{itemModel.text}"
            android:layout_margin="8dp"
            />
</android.support.constraint.ConstraintLayout>
</layout>
```

``` item_text_right.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">

    <data>
        <!--バインドするViewModelの型と変数名を指定-->
        <variable
                name="itemModel"
                type="in.kikr.android.bindingsample.viewmodel.SampleItemModel.RightText"/>
    </data>

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

        <!--ViewModelのtextプロパティを表示する-->
        <TextView
                android:layout_width="wrap_content"
                android:layout_height="match_parent"
                app:layout_constraintTop_toTopOf="parent"
                app:layout_constraintBottom_toBottomOf="parent"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintHorizontal_bias="1.0"
                tools:text="itemText"
                android:text="@{itemModel.text}"
                android:layout_margin="8dp"
                />
    </android.support.constraint.ConstraintLayout>
</layout>

```

``` item_image.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">

    <data>
        <!--バインドするViewModelの型と変数名を指定-->
        <variable
                name="itemModel"
                type="in.kikr.android.bindingsample.viewmodel.SampleItemModel.Image"/>
    </data>

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

        <ImageView
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:src="@drawable/ic_launcher_background"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintEnd_toEndOf="parent"
                />

        <ImageView
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:src="@drawable/ic_launcher_foreground"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintEnd_toEndOf="parent"
                />
    </android.support.constraint.ConstraintLayout>
</layout>

```

### Fragment
バインド設定を以下のように変更します。SampleItemModelのそれぞれの型について、表示するレイアウトを指定します。

``` MainActivityFragment.kt
        binding.bindingParameter = BindingParameter(
            // バインドしたいコレクションを指定
            viewModel.collection,
            // どの型をどのレイアウトにバインドするかを設定する
            OnItemBindClass<SampleItemModel>()
                // 右寄せテキスト
                .map(SampleItemModel.RightText::class.java, BR.itemModel, R.layout.item_text_right)
                // 左寄せテキスト
                .map(SampleItemModel.LeftText::class.java, BR.itemModel, R.layout.item_text_left)
                // 画像
                .map(SampleItemModel.Image::class.java, BR.itemModel, R.layout.item_image)
        )
```

### 動作デモ
<img src="https://qiita-image-store.s3.amazonaws.com/0/46184/53a21493-0764-f25f-7a2c-f0d5a4216ca3.gif" width="50%"/>

（邪魔だったので、こっそりfabも削除しました）

# おわりに
このようなかんじで、C#/XAML出身のわたしでも、Androidネイティブのデータバインディングについては困ることなく快適に開発を進められています。みなさまもぜひAndroidで快適なデータバインディング生活を謳歌していただければと思います！

本記事で利用したサンプルコードは[こちら](https://github.com/takuya-kikuchi/android-binding-sample/tree/DAY5)。


明日はブロックチェーン企業のエンジニアっぽいエントリがついに投下される予定ですので、そちらもぜひ。