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

公開:2021/02/23
更新:2021/02/23
12 min読了の目安(約11100字TECH技術記事 2

はじめに

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(supportFragmentManager, CustomDialog::class.simpleName)

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

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

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

やりがちなミス

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

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 を継承させています)

interface ButtonClickListener : Serializable {
    fun onClick()
}

positiveButtonnegativeButton 用にそれぞれリスナーを用意し、ボタンが押された時にリスナーを実行するようにします。

    private var positiveButtonClickListener: ButtonClickListener? = null
    private var negativeButtonClickListener: ButtonClickListener? = null
    ...
    override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
        ...
        binding.positiveButton.setOnClickListener {
            positiveButtonClickListener?.onClick()
            // ダイアログは自動的に消えないので明示的に消す
            dismiss()  
        }
        binding.negativeButton.setOnClickListener {
            negativeButtonClickListener?.onClick()
            dismiss()
        }
        ...
    }

create メソッドを以下のようにすればタイトルなどと同じようにリスナーを外部から注入可能です。

fun create(
            title: String,
            message: String,
            positiveButtonClickListener: ButtonClickListener,
            negativeButtonClickListener: ButtonClickListener
        ): CustomDialog {
            return CustomDialog().apply {
                val bundle = Bundle()
                bundle.putString("TitleKey", title)
                bundle.putString("MessageKey", message)
                bundle.putSerializable("PositiveButtonListenerKey", positiveButtonClickListener)
                bundle.putSerializable("NegativeButtonListenerKey", negativeButtonClickListener)
                arguments = bundle
            }
        }
CustomDialog
    .create(
        title = "カスタムタイトル",
        message = "カスタムメッセージ",
        positiveButtonClickListener = object : ButtonClickListener {
            override fun onClick() {
                // はいが押された時の処理
            }
        },
        negativeButtonClickListener = object : ButtonClickListener {
            override fun onClick() {
                // いいえが押された時の処理
            }
        }
    )
    .show(supportFragmentManager, CustomDialog::class.simpleName)

問題点

しかし、このままでは 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 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 {
            return this.apply {
                bundle.putString("PositiveButtonTextKey", buttonText)
                val positiveButtonListener = object : ButtonClickListener {
                    override fun onClick() {
                        listener.invoke()
                    }
                }
                bundle.putSerializable("PositiveButtonListenerKey", positiveButtonListener)
            }
        }

        fun setNegativeButton(buttonText: String, listener: () -> Unit): Builder {
            return this.apply {
                bundle.putString("NegativeButtonTextKey", buttonText)
                val negativeButtonListener = object : ButtonClickListener {
                    override fun onClick() {
                        listener.invoke()
                    }
                }
                bundle.putSerializable("NegativeButtonListenerKey", negativeButtonListener)
            }
        }
        // CustomDialog インスタンス生成&設定した値を反映
        fun build(): CustomDialog {
            return CustomDialog().apply {
                arguments = bundle
            }
        }
    }
}

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

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

また、ボタンリスナーの設定時には高階関数を利用し、設定時にいちいちリスナーの記述をしなくていいようにしています。

fun setPositiveButton(buttonText: String, listener: () -> Unit): Builder {
        ...
        val positiveButtonListener = object : ButtonClickListener {
            override fun onClick() {
                listener.invoke()
            }
        }
        bundle.putSerializable("PositiveButtonListenerKey", positiveButtonListener)
    }
}

呼び出し時はこう書けます。

    .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 =
                it.getSerializable("PositiveButtonListenerKey") as? ButtonClickListener
            negativeButtonClickListener =
                it.getSerializable("NegativeButtonListenerKey") as? ButtonClickListener
        }
        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 {
            dismiss()
            positiveButtonClickListener?.onClick()
        }
        binding.negativeButton.setOnClickListener {
            dismiss()
            negativeButtonClickListener?.onClick()
        }
        dialog.setContentView(binding.root)
        return dialog
    }

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

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

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

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

おわりに

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

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

この記事に贈られたバッジ