💬

DialogFragmentでカスタムダイアログを実装する

2021/02/23に公開
3

はじめに

Androidでお手軽にダイアログを実装する方法としては AlertDialog があります。

しかし、AlertDialog はカスタマイズ性があまり高くなく、レイアウトをお手軽に変更できません。

そこで DialogFragment を使って、自分で自由にレイアウトを作れるカスタムダイアログを実装していきたいと思います。

リポジトリ

こちらに今回の完成品のリポジトリを置いておきます。
https://github.com/nanaten/CustomDialogByDialogFragment

今回の目標

せっかくなので、iOSライクなこういうアラートを表示するダイアログにしてみます。

この記事で説明する事

  • DialogFragment でカスタムViewのダイアログを表示する方法
  • カスタムダイアログに値やリスナーを注入する実装方法

この記事で説明しない事

  • ViewBindingの使い方
  • レイアウトの組み方

実装内で ViewBinding を利用していますが、ViewBindingの使い方については割愛しています。

また、今回作成したレイアウトの細かい組み方については、リポジトリを参照してください。

実装

一番簡単なダイアログ表示

ひとまずはダイアログを表示させてみます。

class CustomDialog: DialogFragment() {

    override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
        val dialog = Dialog(requireContext())
        // ダイアログの背景を透過にする
        dialog.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
        
        // カスタムダイアログのレイアウトファイルは layout_custom_dialog.xml
        val binding = LayoutCustomDialogBinding.inflate(requireActivity().layoutInflater)
        binding.title.text = "タイトル"
        binding.message.text = "メッセージ"
        binding.positiveButton.text = "OK"
        binding.negativeButton.text = "キャンセル"
        dialog.setContentView(binding.root)
        return dialog
    }
}
CustomDialog().show(childFragmentManager, CustomDialog::class.simpleName)

childFragmentManager のところはActivityの場合は supportFragmentManager に読み替えてください。

これでひとまずダイアログの表示だけはできましたが、ボタンなどを押しても何も動きませんし、文言も固定です。

ここから文言やボタンを押した時のアクションを外部から指定できるようにしていきます。

ダイアログのタイトルとメッセージを指定する

やりがちなミス

ダイアログに任意のタイトル・メッセージなどを指定できるようにしたいのですが、このようなコードを書いてしまいがちです。

class CustomDialog(
    // コンストラクタで値を注入
    private val title: String,
    private val message: String
): DialogFragment() {

    override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
        ...
        // コンストラクタの値を反映
        binding.title.text = title
        binding.message.text = message
        
        ...
        return dialog
    }
}

ですが、このような書き方は アンチパターンです。

なぜなら、 DialogFragment は画面回転などで Activity が破棄されるとそのたびに再生成され、再生成時は 引数なしコンストラクタで初期化されてしまう からです。

このため、引数を渡す方法で実装すると、一見うまく表示できているように見えますが、ダイアログ表示中に画面回転などを行うとクラッシュします(※)。

※これは DialogFragment に限らず、Fragment を継承しているクラス全般に共通する振る舞いです。

参考記事: http://y-anz-m.blogspot.com/2012/04/androidfragment-setarguments.html

解決策

以下のように自身を戻り値として返すメソッドを生やして、 Bundle を使った値の受け渡しを行うのが一般的です。


class CustomDialog() : DialogFragment() {
    // プロパティを追加
    private var title: String = ""
    private var message: String = ""

    companion object {
        // CustomDialogインスタンスを生成するメソッド
        fun create(title: String, message: String): CustomDialog {
            return CustomDialog().apply {
                // Bundleを利用してインスタンスに値を渡す
                val bundle = Bundle()
                bundle.putString("TitleKey", title)
                bundle.putString("MessageKey", message)
                arguments = bundle
            }
        }
    }

    override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
        val dialog = Dialog(requireContext())
        // ダイアログの背景を透過にする
        dialog.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
        
        // Bundleに渡した値を取り出す
        arguments?.let {
            title = it.getString("TitleKey", "")
            message = it.getString("MesasgeKey", "")
        }
        val binding = LayoutCustomDialogBinding.inflate(requireActivity().layoutInflater)
        binding.title.text = title
        binding.message.text = message
        binding.positiveButton.text = "OK"
        binding.negativeButton.text = "キャンセル"
        dialog.setContentView(binding.root)
        return dialog
    }
}

ダイアログの生成は以下のように create メソッド経由で行います。

    // create メソッド経由でインスタンスを生成
    CustomDialog
        .create("カスタムタイトル", "カスタムメッセージ")
        .show(supportFragmentManager, CustomDialog::class.simpleName)

上記のように実装すると、タイトルとメッセージを自由に変更することが可能になりました。

ダイアログのボタンを押した時のふるまいを実装する

ダイアログのボタンを押した時にどういうふるまいをするかを、外部から注入できるようにします。

まずはダイアログのボタンをクリックした時のリスナーを定義します。
(※Bundleへ渡せるように Serializable を継承させています

こちらのやり方があまりよろしくないと コメントで教えてもらった ので、べつのやり方を紹介していきます。

ActivityとFragmentの両方で使えるDialogFragmentの書き方 - Qiita がよさそうだったのですが、 API Level 28 で setTargetFragment が deprecated になる ようなので、こちらsetFragmentResult を使ったやり方を参考にさせていただきました。

※こちらのやり方はFragmentを前提にしています。Activityでやりたい時は公式の ダイアログのホストにイベントを渡す を参考にされるとよいかと思います。

まずはダイアログの各ボタンクリック時の動作を以下のように定義します。

override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
        ...
        binding.positiveButton.setOnClickListener {
            setFragmentResult(
                "RequestPositiveButtonKey",
                bundleOf()
            )
            // ダイアログは自動的に消えないので明示的に消す
            dismiss()  
        }
        binding.negativeButton.setOnClickListener {
            setFragmentResult(
                "RequestNegativeButtonKey",
                bundleOf()
            )
            dismiss()
        }
        ...
    }

setFragmentResult(requestKey: String, result: Bundle) で Fragment への通知を送ります。 requestKey が一致している setFragmentResultListener を呼び出すことで、好きな Fragment で通知を受け取ることが可能になります。

今回 Bundle は使用していませんが、何か渡したい値がある場合は bundleOf 内に {key} to {param} という形で記載すれば値のやりとりが出来ます。

そしてダイアログの create メソッドを以下のように修正します。

fun create(
    fragment: Fragment,
    title: String,
    message: String,
    positiveButtonClick: (() -> Unit)? = null,
    negativeButtonClick: (() -> Unit)? = null
): CustomDialog {
    fragment.childFragmentManager.setFragmentResultListener(
        "RequestPositiveButtonKey",
        fragment.viewLifecycleOwner
    ) { _, _ ->
        positiveButtonClick?.invoke()
    }
    fragment.childFragmentManager.setFragmentResultListener(
        "RequestNegativeButtonKey",
        fragment.viewLifecycleOwner
    ) { _, _ ->
        negativeButtonClick?.invoke()
    }
    return CustomDialog().apply {
        // Bundleを利用してインスタンスに値を渡す
        val bundle = Bundle()
        bundle.putString("TitleKey", title)
        bundle.putString("MessageKey", message)
        arguments = bundle
    }
}

すこし長くなったので一つずつ見ていきます。

fun create(
    fragment: Fragment,
    title: String,
    message: String,
    positiveButtonClick: (() -> Unit)? = null,
    negativeButtonClick: (() -> Unit)? = null
): CustomDialog

引数で Fragment を渡すようにしたのと、各ボタンをクリックした時の高階関数(ラムダ式)を追加したのが大きな変化です。

fragment.childFragmentManager.setFragmentResultListener(
        "RequestPositiveButtonKey",
        fragment.viewLifecycleOwner
    ) { _, _ ->
        positiveButtonClick?.invoke()
    }

ここで引数で渡した Fragment に対して setFragmentResultListener を設定しています。先ほど設定した setFragmentResult に対応したリスナーです。これで各ボタンをクリックした時の通知が Fragment に対して通知されるようになります。

※わざわざ setFragmentResultListener を2つにわけて実行しているのは、後の Builderパターンへ向けた準備です。煩雑に感じる場合は Bundle に

bundleOf("ResultKey" to "PositiveButtonClick"(もしくは "NegativeButtonClick")

みたいな値を渡して、Listenerの方でKeyによって分岐判定してもよいかと思います。

問題点

しかし、このままでは create メソッドの引数がどんどん多くなってしまい、可読性が良くありません。

また、「タイトルは設定したくない」などの場合でも引数を省略できず、呼び出し側の実装が面倒です。

解決策

ここで、 AlertDialog でも使われている Builderパターン を採用してみたいと思います。

※Builderパターンについての説明はこちら: https://qiita.com/takutotacos/items/33cfda205ab30a43b0b1

Builderパターンを利用することで、

CustomDialog.Builder()
    .setTitle("タイトル")
    .setMessage("メッセージ")
    .show(...)

などのように、簡潔にわかりやすい形で値を設定することが可能になります。

Builderパターンで外部から値を設定する

以下のように、 CustomDialog の内部に Builder クラスを実装し、 Builder クラス内で値を Bundle に反映させるようにします。

最後に build() メソッドで CustomDialog インスタンスを生成し、 Bundle に詰めた値を渡しています。

class CustomDialog() {
    ...
    class Builder(private val fragment: Fragment) {
        private val bundle = Bundle()
        fun setTitle(title: String): Builder {
            return this.apply {
                bundle.putString("TitleKey", title)
            }
        }

        fun setMessage(message: String): Builder {
            return this.apply {
                bundle.putString("MessageKey", message)
            }
        }

        fun setPositiveButton(buttonText: String, listener: () -> Unit): Builder {
            fragment.childFragmentManager
                .setFragmentResultListener(
                    "RequestPositiveButtonKey",
                    fragment.viewLifecycleOwner
                ) { _, _ ->
                    listener?.invoke()
                }
            return this.apply {
                bundle.putString("PositiveButtonTextKey", buttonText)
            }
        }

        fun setNegativeButton(buttonText: String, listener: () -> Unit): Builder {
            fragment.childFragmentManager
                .setFragmentResultListener(
                    "RequestNegativeButtonKey",
                    fragment.viewLifecycleOwner
                ) { _, _ ->
                    listener?.invoke()
                }
            return this.apply {
                bundle.putString("NegativeButtonTextKey", buttonText)
            }
        }
        // CustomDialog インスタンス生成&設定した値を反映
        fun build(): CustomDialog {
            return CustomDialog().apply {
                arguments = bundle
            }
        }
    }
}

Builder クラスの setXXX メソッドは Builder クラス自身を戻り値にしており、メソッドチェーンで連続して書けるようにしています。

CustomDialog.Builder(this)
    .setTitle("タイトル")
    .setMessage("メッセージ")
    ...

また、ボタンリスナーの設定時には高階関数を利用しているため、呼び出し時はこう書けます。

    .setPositiveButton("はい") { // はいが押された時の処理 }

onCreateDialog メソッドでは、 Bundle に詰めた値を取り出すだけです。

    override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
        val dialog = Dialog(requireContext())
        // ダイアログの背景を透過にする
        dialog.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))

        arguments?.let {
            title = it.getString("TitleKey", "")
            message = it.getString("MessageKey", "")
            positiveButtonText =
                it.getString("PositiveButtonTextKey", "OK")
            negativeButtonText =
                it.getString("NegativeButtonTextKey", "キャンセル")
            positiveButtonClickListener =
        }
        val binding = LayoutCustomDialogBinding.inflate(requireActivity().layoutInflater)
        binding.title.text = title
        binding.message.text = message
        binding.positiveButton.text = positiveButtonText
        binding.negativeButton.text = negativeButtonText
        binding.positiveButton.setOnClickListener {
            setFragmentResult(
                "RequestPositiveButtonKey",
                bundleOf()
            )
            dismiss()  
        }
        binding.negativeButton.setOnClickListener {
            setFragmentResult(
                "RequestNegativeButtonKey",
                bundleOf()
            )
            dismiss()  
        }
        dialog.setContentView(binding.root)
        return dialog
    }

上記のように Builder クラスを実装することで、呼び出し時は CustomDialog.Builder() クラス経由で以下のように値を設定できるようになります。

CustomDialog.Builder(this)
    .setTitle("カスタムタイトル")
    .setMessage("カスタムメッセージ")
    .setPositiveButton("はい") { // はいが押された時の処理 }
    .setNegativeButton("いいえ") { // いいえが押された時の処理 }
    .build()
    .show(childFragmentManager, CustomDialog::class.simpleName)

これでカスタムダイアログの実装がひととおり完了しました。

あとはレイアウトファイルとメソッドの調整さえしてしまえば、自分の好みに合わせて自由にダイアログをカスタムできます。

おわりに

今回は DialogFragment を利用したカスタムダイアログの実装について解説しました。

このあたりは人によって実装に差が出てくるところだと思うので、「自分はこうしているよ!」「こう実装した方が便利だよ!」などあればぜひ教えてください。

Discussion

Naoki NakajimaNaoki Nakajima

ボタンのリスナを Serializable として渡すのは避けたほうが良いです。

今回のサンプルアプリだと、渡されるクロージャが MainActivity に依存しているので、ダイアログ表示中に Activity がキルされるとアプリがクラッシュしてしまいます。

Activity で表示する場合は ダイアログのホストにイベントを渡す、Fragment で表示する場合は ActivityとFragmentの両方で使えるDialogFragmentの書き方 - Qiita とかを参考にされると良いと思います。

最近だと ViewModel なんかも使えそうですね。
android DialogFragment を ViewModel と LiveDataで実装 | sakony.jp

m.coderm.coder

ご指摘ありがとうございます!手元の端末でクラッシュしなかったので「イケるやろ」と思ってました…
調査して記事修正します!