[Android] ViewModel + DialogFragment で作成するダイアログ実装

# はじめに
業務で DialogFragment を作成しようと思いどうやって実装したら良さそうか考えたのでまとめです。
他にこうやった方が良さそうだよみたいなのがあれば教えていただけると嬉しいです。

# コード全文
https://github.com/fumiyatani/FragmentDialogSample

# やりたいこと
以下のような実装を目指していきたいと思います。
- DialogFragment は メソッドチェーンを使って表示項目を設定する。
- ViewModel を使って呼び出し元に対してボタンクリック時のイベントを渡す。


# 実装
## 1. クリックイベントなどを通知する ViewModel を作成する
まずはダイアログ用の ViewModel を作成します。
今回作成するダイアログはタイトルとメッセージ、ボタンが２つ (OKとキャンセル) のシンプルなダイアログを作成します。
なので以下の3つの実装を持つ ViewModel を作成します。
- ダイアログのボタンクリックイベントをViewModelへ通知するメソッド
- 呼び出し画面で監視するクリックイベントの LiveData
- イベント消費用メソッド

また、 何のイベントが通知されたのかを判別するために Event 用の Enum を作成します。

```SampleDialogViewModel.kt
class SampleDialogViewModel : ViewModel() {
    private val _state: MutableLiveData<SampleDialogState> = MutableLiveData(SampleDialogState())
    val state: LiveData<SampleDialogState> = _state

    fun onClickPositive() {
        val currentEvents = _state.value?.events ?: return
        val newEvents = mutableListOf<Event>()
        newEvents.addAll(currentEvents)
        newEvents.add(Event.POSITIVE)
        _state.value = SampleDialogState(newEvents)
    }

    fun onClickNegative() {
        val currentEvents = _state.value?.events ?: return
        val newEvents = mutableListOf<Event>()
        newEvents.addAll(currentEvents)
        newEvents.add(Event.NEGATIVE)
        _state.value = SampleDialogState(newEvents)
    }

    fun consumeEvent(event: Event) {
        val currentEvents = _state.value?.events ?: return
        val newEvents = currentEvents.filterNot { it == event }
        _state.value = SampleDialogState(newEvents)
    }

    data class SampleDialogState(
        val events: List<Event> = emptyList()
    )
}
```

```Event.kt
enum class Event {
    POSITIVE,
    NEGATIVE,
}
```

処理の流れとしては以下のようになります。
1. ダイアログないの何らかのボタンがタップされたタイミングで `onClickXxx` がよばれる。
2. 続いて呼び出し元画面で `state` を observe した箇所に通知がいく
3. 最後にイベントが消費された際に consumeEvent を呼び出す

また3に関しては Android Developers に記事があるのでそちらを一読していただければと思います。
https://developer.android.com/jetpack/guide/ui-layer/events?hl=ja#consuming-trigger-updates

## 2. 内部に Builder クラスを持つ DialogFragment を作成
まずは DialogFragment を継承したクラスと表示用のレイアウトを作成します。

```dialog_sample.xml
<?xml version="1.0" encoding="utf-8"?>
<!-- findViewById が面倒なので DataBinding を使用しています。 -->
<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.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:padding="16dp">

        <androidx.constraintlayout.helper.widget.Flow
            android:id="@+id/flow_layout"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:orientation="vertical"
            app:constraint_referenced_ids="title,message,flow_button"
            app:flow_verticalGap="16dp"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

        <TextView
            android:id="@+id/title"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:gravity="center"
            tools:text="タイトル" />

        <TextView
            android:id="@+id/message"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:gravity="center"
            tools:text="メッセージ" />

        <androidx.constraintlayout.helper.widget.Flow
            android:id="@+id/flow_button"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:orientation="horizontal"
            app:constraint_referenced_ids="cancel,ok"
            app:flow_horizontalGap="16dp"
            app:flow_wrapMode="chain" />

        <Button
            android:id="@+id/cancel"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            tools:text="ネガティブ" />

        <Button
            android:id="@+id/ok"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            tools:text="ポジティブ" />

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

```SampleDialogFragment.kt
class SampleDialogFragment : DialogFragment() {

    private val viewModel: SampleDialogViewModel by activityViewModels()

    override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
        super.onCreateDialog(savedInstanceState)

        val layoutInflater = LayoutInflater.from(requireContext())
        val binding = DialogSampleBinding.inflate(layoutInflater)

        return Dialog(requireContext()).apply {
            setContentView(binding.root)
            binding.title.text = "タイトル"
            binding.message.text = "メッセージ"
            binding.ok.text = "OK"
            binding.ok.setOnClickListener {
                // TODO: ViewModel のイベントを呼び出す
                dismiss()
            }
            binding.cancel.text = "キャンセル"
            binding.cancel.setOnClickListener {
                // TODO: ViewModel のイベントを呼び出す
                dismiss()
            }
        }
    }
}
```


次にダイアログに表示するための値を渡す Builder クラスを DialogFragment 内に作成します。
今回は以下の4つの値を渡せるようにします。
- タイトル (必須)
- メッセージ (任意)
- PositiveButton のテキスト (任意)
- NegativeButton のテキスト (任意)

```SampleDialogFragment.kt
class SampleDialogFragment : DialogFragment() {

    // 省略...

    class Builder(title: String) {
        private val bundle = Bundle()

        init {
            if (title.isEmpty()) throw IllegalArgumentException("タイトルは必須項目です")

            bundle.putString(SampleDialogKey.TITLE.name, title)
        }

        fun setMessage(message: String): Builder = apply {
            bundle.putString(SampleDialogKey.MESSAGE.name, message)
        }

        fun setPositiveButton(label: String): Builder = apply {
            bundle.putString(SampleDialogKey.POSITIVE.name, label)
        }

        fun setNegativeButton(label: String): Builder = apply {
            bundle.putString(SampleDialogKey.NEGATIVE.name, label)
        }

        fun build(): SampleDialogFragment = SampleDialogFragment().apply {
            arguments = bundle
        }
    }

    // Builder 内の bundle に設定する Key 
    private enum class SampleDialogKey {
        TITLE,
        MESSAGE,
        POSITIVE,
        NEGATIVE,
    }
}
```

## 3. 作成した ViewModel と DialogFragment を紐づける
先ほど作成した `SampleDialogViewModel` と `SampleDialogFragment` を紐づけます。
また呼び出し元にクリックイベントを通知するため Activity スコープで `SampleDialogViewModel` のインスタンスを取得するようにします。
https://developer.android.com/guide/fragments/communicate

```SampledialogFragment.kt
class SampleDialogFragment : DialogFragment() {

    // 呼び出し元にクリックイベントを伝えるために Activity スコープでインスタンスを呼び出す
    private val viewModel: SampleDialogViewModel by activityViewModels()

    override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
        super.onCreateDialog(savedInstanceState)

        val layoutInflater = LayoutInflater.from(requireContext())
        val binding = DialogSampleBinding.inflate(layoutInflater)

        return Dialog(requireContext()).apply {
            setContentView(binding.root)
            binding.title.text = arguments?.getString(SampleDialogKey.TITLE.name, "")
            binding.message.text = arguments?.getString(SampleDialogKey.MESSAGE.name, "")
            binding.ok.text = arguments?.getString(SampleDialogKey.POSITIVE.name, "")
            binding.ok.setOnClickListener {
                viewModel.onClickPositive()
                viewModel.consumeEvent(Event.POSITIVE)
                dismiss()
            }
            binding.cancel.text = arguments?.getString(SampleDialogKey.NEGATIVE.name, "")
            binding.cancel.setOnClickListener {
                viewModel.onClickNegative()
                viewModel.consumeEvent(Event.NEGATIVE)
                dismiss()
            }
        }
    }
}
```


## 4. 作成したダイアログを MainActivity で呼び出す
ここまで行うと以下のように Builder を使って以下のように呼び出すことができるようになります。
```MainActivity.kt
    SampleDialogFragment.Builder("タイトル")
        .setMessage("メッセージ")
        .setPositiveButton("OK")
        .setNegativeButton("キャンセル")
        .build()
        .show(supportFragmentManager, SAMPLE_DIALOG_TAG)
```

また通知は以下のように受け取れます。
```MainActivity.kt
dialogViewModel.state.observe(this) { state ->
    state.events.forEach { event ->
        when(event) {
            Event.POSITIVE -> showToast("PositiveAction!")
            Event.NEGATIVE -> showToast("NegativeAction!")
        }
    }
}
```

実際の呼び出す部分は以下のようになっています。
``` MainActivity.kt
class MainActivity : AppCompatActivity() {
    private val sampleDialogViewModel: SampleDialogViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val binding: ActivityMainBinding =
            DataBindingUtil.setContentView(this, R.layout.activity_main)

        binding.showDialog.setOnClickListener {
            SampleDialogFragment.Builder("タイトル")
                .setMessage("メッセージ")
                .setPositiveButton("OK")
                .setNegativeButton("キャンセル")
                .build()
                .show(supportFragmentManager, SAMPLE_DIALOG_TAG)
        }

        dialogViewModel.state.observe(this) { state ->
            state.events.forEach { event ->
                when(event) {
                    Event.POSITIVE -> showToast("PositiveAction!")
                    Event.NEGATIVE -> showToast("NegativeAction!")
                }
            }
        }
    }

    private fun showToast(message: String) {
        Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
    }

    companion object {
        private const val SAMPLE_DIALOG_TAG = "sample_dialog"
    }
}
```

# 完成した画面

画像のように値をうまく受け渡しできた Dialog が表示できました。
![ダイアログ.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/236467/a176f316-b76a-2e9a-b32d-c8c0853288f8.gif)

# 最後に
とりあえず Builder パターンと ViewModel を使ったダイアログを作れたのは良かったです。
ただ、以下のような課題があるのでその辺りをもう少し考えたいと思います。
- ボタンのテキストが空文字だった場合の表示制御
- Activity スコープ以外で ViewModel のインスタンスを取り出すと通知できない

特に `Activity スコープ以外で ViewModel のインスタンスを取り出すと通知できない` に関しては他のチームメンバーに使ってもらうことを考えると、このクラスが初見の人には優しくない作りなのでこの辺りは解消したいです。。