KotlinTips: ダイアログの表示(DialogFragment)

09/02/2021

Androidには混乱している/混乱していた機能がたくさんありますが、ダイアログもそのうちの一つかと思いますので整理してみたいと思います。

サンプル実装はGitHubにあります。

サンプル実装

参考資料は主に公式サイトです。

概要

Dialogクラスはダイアログの基本クラスですが、Androidのダイアログ表示ではこのクラスを直接インスタンス化するのではなく、そのサブクラスを使います。
そのサブクラスもActivityなどから直接呼び出すのではなく、DialogFragmentを継承したクラスを作成し、その中でBuilderクラスを介して構成することが推奨されています。

DialogFragmentを継承したクラスを経由することで、呼び出し元であるActivityやFragmentの肥大化を防ぎ、尚且つ画面の回転などのライフサイクルイベントに自然に対応することができます。

DialogFragmentを継承したクラスの最も簡単な実装は以下の様になります。

三種類のDialogクラスのサブクラス

Dialogクラスのサブクラスには以下の三種類があります。
日付や時間のピッカーを表示する場合以外は、基本的にAlertDialogを使います。

AlertDialog

タイトル、コンテンツ、ボタンを表示できるダイアログ。
コンテンツにはメッセージの表示やチェックボックス、ラジオボタンの表示、その他自由にViewを配置することができます。
名前にはAlertという語が含まれていますが、警告ダイアログに限らず一般的な用途のダイアログとして一番よく使用します。

DatePickerDialog / TimePickerDialog

カレンダー型の日付選択コンテンツや時計型の時刻選択コンテンツが標準で用意されています。

ProgressDialog(非推奨)

時間のかかる処理を行う際、処理が実行中であることをユーザーに伝えるダイアログです。
しかし時間のかかる処理を実行する際にダイアログを表示しユーザー操作を受け付けない設計は、ユーザビリティに問題があるという流れを受け、非推奨になっています。
読み込みや進捗状況を表示する場合はProgressBarをレイアウトに組み込みましょう。

DialogFragmentの継承

DialogFragmentを継承したクラスを作成する

DialogFragmentを継承したクラスを作成しonCreateDialog()メソッドをオーバーライドします。
Builderクラスを利用しながらAlertDialogクラスを構築していきます。

class SampleDialogFragment : DialogFragment() {
    override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
        val builder = AlertDialog.Builder(requireContext())
        builder.setTitle(R.string.title)
        builder.setIcon(R.mipmap.ic_launcher)
        builder.setMessage(R.string.message)
        builder.setPositiveButton(R.string.positive) { dialog, id ->
            //OK
        }
        builder.setNegativeButton(R.string.cancel) { dialog, id ->
            //Cancel
        }
        return builder.create()
    }
}

Builderクラスの各メソッドはBuilderクラスのインスタンス自身を戻り値として返すのでメソッドチェーンにもできます。

class SampleDialogFragment : DialogFragment() {
    override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
        return AlertDialog.Builder(requireContext())
            .setTitle(R.string.title)
            .setIcon(R.mipmap.ic_launcher)
            .setMessage(R.string.message)
            .setPositiveButton(R.string.positive) { dialog, id ->
                //OK
            }
            .setNegativeButton(R.string.cancel) { dialog, id ->
                //Cancel
            }
            .create()
    }
}

DialigFragmentを継承したクラスを呼び出す(ダイアログを表示する)

ダイアログを表示するにはActivityやFragmentからDialogFragmentクラスを継承したクラスのインスタンスを作成し、show()メソッドを呼び出します。
その際、FragmentManagerとDialogFragmentクラスを継承したクラスのタグ名を引数として渡します。

FragmentManagerはFragmentActivityを継承したActivityからgetSupportFragmentManager()を呼び出して取得します。

DialogFragmentクラスを継承したクラスのタグ名は固有の文字列であれば何でもよく、Androidシステムはこのタグを用い必要に合わせFragmentの状態を保存・復元します。
findFragmentByTag()メソッドでタグを指定し、Fragmentを操作することもできます。

            SampleDialogFragment().show(
                supportFragmentManager,
                SampleDialogFragment::class.simpleName
            )

コンストラクタに引数を取らない単純な呼び出しであればDialogFragmentクラスを継承したクラスのインスタンスを直接起こしてもよいかと思います(公式のサンプルも直接コンストラクタを呼んでいます)。

画面が回転するアプリの場合やコンストラクタが引数を取る場合は、インスタンスが再作成されることを意識しておく必要があります。
つまりコンストラクタに直接引数を渡すと、インスタンスが再作成された場合に引数が失われ例外が発生しますので、一般的なフラグメント同様シングルトン(companion object)を利用し、引数で渡される値をフラグメントBundleとして保存するようにした方がよいでしょう。

            SampleDialogFragment.newInstance("ARGUMENT").show(
                supportFragmentManager,
                SampleDialogFragment::class.simpleName
            )
class SampleDialogFragment : DialogFragment() {
    private lateinit var argument: String

    companion object {
        private const val ARGUMENT1 = "ARGUMENT1"

        @JvmStatic
        fun newInstance(message: String): SampleDialogFragment {
            val fragment = SampleDialogFragment()
            val args = Bundle()
            args.putString(ARGUMENT1, message)
            fragment.arguments = args
            return fragment
        }
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        argument = requireArguments().getString(ARGUMENT1, "")
    }

......

フラグメントのBundleはonCreateDialogの中でも取得できます。

    override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
        argument = requireArguments().getString(ARGUMENT1, "")

......

DialogFragmentはフラグメントなので、必要があれば一般的なフラグメント同様、各ライフサイクルのコールバックメソッドをオーバーライドすることができます。

ダイアログが保持する3つの領域

一般的にダイアログはAlertDialog.Builderクラスで提供される各APIを必要に応じて呼び出し、領域ごとに構築していきます。

ダイアログには大きく三つの領域があります

タイトル領域

この領域は省略可能です。
コンテンツ領域に詳細メッセージやリスト、カスタムレイアウトなどがあり、尚且つ他に文言を表示したい場合に使うとよいでしょう。
単純な確認メッセージやダイアログメッセージを表示する場合、タイトルは必要ないでしょう。

タイトル領域にはアイコンを表示することも出来ます。
配置に関するレイアウトを変更したい場合や他の要素を表示したい場合は、自分でコンテンツ領域に実装します。


コンテンツ領域

コンテンツ領域はダイアログの中心となる領域で、メッセージ、リスト、その他のカスタムレイアウトを表示することができます。
基本的なコンポーネントはライブラリー側で既に用意されています。
レイアウトを変更したい場合や、独自のコンポーネントを表示する場合はViewを自分で定義し、カスタムレイアウトを構築します。

ボタン領域

ライブラリー側で用意されているボタンは三種類あります。
AlertDialog.Builderクラスを利用し、1種類ごと必要に合わせて追加します。
同じ種類のボタンを複数配置することはできません。

三種類のボタンで処理が賄えない場合はボタン領域を使用せず、コンテンツ領域に自分で実装することもできます。
ただしその前に本当にそれほど多くのボタンが必要なのか、要件や設計を見直した方がよいでしょう。

Positiveボタン

アクションを受け入れる場合に使用するボタンです。
OKやYESなどポジティブな反応を表現します。

Negativeボタン

アクションをキャンセルする場合に使用するボタンです。
NOやCANCELなどネガティブな反応を表現します。

Neutralボタン

その他のボタンで、ユーザーに別の選択肢を与える場合に使用します。
例えば

  • 利用規約を確認する
  • 後で通知する

など、ポジティブでもネガティブでもない、ニュートラルな用途での使用が想定されています。

各コンテンツ別にみる各ダイアログ

コンテンツ領域に何を表示するかにより、以下の様に分類できます。
各コンテンツは併用できませんので、文字列による表現を加えたい場合はタイトル領域の使用を検討してください。。

メッセージダイアログ

標準的に用いられるメッセージを表示するダイアログです。

メッセージダイアログ
        builder.setMessage("Message")

リストダイアログ

選択肢を表すリストを表示するダイアログです。
リストには次の三種類があります。

  • ボタンリスト
  • ラジオボタンリスト
  • チェックボックスリスト

ボタンリスト

ボタンリストダイアログ
        val items = arrayOf<String>("ITEM1", "ITEM2", "ITEM3", "ITEM4")
        builder.setItems(items) { dialog, which ->
            when (which) {
                0 -> Toast.makeText(activity, "ITEM1", Toast.LENGTH_SHORT).show()
                1 -> Toast.makeText(activity, "ITEM2", Toast.LENGTH_SHORT).show()
                2 -> Toast.makeText(activity, "ITEM3", Toast.LENGTH_SHORT).show()
                3 -> Toast.makeText(activity, "ITEM4", Toast.LENGTH_SHORT).show()
                else -> {
                }
            }
        }

リストのアイテムは配列などで作成し、setItems()に渡します。
配列の他にListAdapterやCursorを使ってデータベースからなどからデータを取得し、setAdapter()、setCursor()を使って動的にリストを作成することも出来ます。

デフォルトではリストアイテムをタップした際、ダイアログが閉じるようになっていますので、必要に合わせListenerを実装します。

ラジオボタンリスト / チェックボックスリスト

setMultiChoiceItems() メソッドまたは setSingleChoiceItems() メソッドを使用すれば、チェックボックスやラジオボタンをリストとして表示することも出来ます。

ラジオボタンリストダイアログ
        val items = arrayOf<String>("ITEM1", "ITEM2", "ITEM3", "ITEM4")
        builder.setSingleChoiceItems(items, 0) { dialog, which ->
            when (which) {
                0 -> Toast.makeText(activity, "ITEM1", Toast.LENGTH_SHORT).show()
                1 -> Toast.makeText(activity, "ITEM2", Toast.LENGTH_SHORT).show()
                2 -> Toast.makeText(activity, "ITEM3", Toast.LENGTH_SHORT).show()
                3 -> Toast.makeText(activity, "ITEM4", Toast.LENGTH_SHORT).show()
                else -> {
                }
            }
        }
チェックボックスリストダイアログ
        val items = arrayOf<String>("ITEM1", "ITEM2", "ITEM3", "ITEM4")
        val checkedItems = booleanArrayOf(false, false, true, true)
        builder.setMultiChoiceItems(items, checkedItems) { dialog, which, isChecked ->
            when (which) {
                0 -> Toast.makeText(activity, "ITEM1 $isChecked", Toast.LENGTH_SHORT).show()
                1 -> Toast.makeText(activity, "ITEM2 $isChecked", Toast.LENGTH_SHORT).show()
                2 -> Toast.makeText(activity, "ITEM3 $isChecked", Toast.LENGTH_SHORT).show()
                3 -> Toast.makeText(activity, "ITEM4 $isChecked", Toast.LENGTH_SHORT).show()
                else -> {
                }
            }
        }

標準のボタンとラジオボタンのリストはどちらも1つのアイテムを選択する、という用途で使われますが、
ラジオボタンの場合は、現在何が選択されているのかを表現することもできます。

カスタムダイアログ

カスタムレイアウトを作成すれば任意のダイアログを作成することができます。
DialogFragmentでレイアウトをインフレートするには、ActivityのgetLayoutInflater()などからLayoutInflaterを取得し、inflate()メソッドを呼び出します。

カスタムダイアログ
        val inflater = requireActivity().layoutInflater
        val content: View = inflater.inflate(R.layout.view_custom_dialog, null)
        content.findViewById<Button>(R.id.show_toast_button).setOnClickListener { e ->
            Toast.makeText(activity, "CLICKED", Toast.LENGTH_SHORT).show()
        }
        builder.setView(content)

AlertDialog.BuilderクラスのオブジェクトでsetView()メソッドを呼び出し、インフレートしたレイアウトを持つViewを設定します。

タイトルやボタンを追加していない場合、Viewはダイアログ全体に表示されます。

日付選択ダイアログ

DialogFragmentのonCreateDialog()内でAlertDialogではなく、DatePickerDialogを作成します。
Builderクラスはありませんので直接コンストラクターを呼び出します。

日付選択ダイアログ
    override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
        val calendar = Calendar.getInstance()
        val year = calendar.get(Calendar.YEAR)
        val month = calendar.get(Calendar.MONTH)
        val day = calendar.get(Calendar.DAY_OF_MONTH)

        return DatePickerDialog(requireActivity(), listener, year, month, day)
    }

時刻選択ダイアログ

DialogFragmentのonCreateDialog()内でAlertDialogではなく、TimePickerDialogを作成します。
Builderクラスはありませんので直接コンストラクターを呼び出します。

時刻選択ダイアログ
    override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
        val calendar = Calendar.getInstance()
        val hour = calendar.get(Calendar.HOUR_OF_DAY)
        val minute = calendar.get(Calendar.MINUTE)

        return TimePickerDialog(
            requireActivity(),
            listener,
            hour,
            minute,
            DateFormat.is24HourFormat(activity)
        )
    }

ダイアログのイベント処理

ダイアログ処理は確認を求めるだけの場合もあれば、ユーザーからの入力値を受け取りたい場合もあるでしょう。
DialogFragmentの中でイベント処理ができる場合はそのままDialogFragmentの中で実装してしまうのも一つの方法です。
一方でダイアログを呼び出したActivityやFragmentに値を返したい場合もあります。
その場合、一般的なフラグメントと同様DialogFragmentにインターフェースを用意し、これを呼び出し元で実装し、処理を伝播することにより実装します。

    override fun onAttach(context: Context) {
        super.onAttach(context)
        try {
            listener = context as MessageDialogListener
        } catch (e: ClassCastException) {
            throw ClassCastException((context.toString() + " must implement MessageDialogListener"))
        }
    }

ホストになるActivityやFragmentでインターフェースが実装されているか、onAttach()メソッドで確認します。

ダイアログを閉じる

AlertDialog.Builderで作成されたアクションボタンのいずれかをユーザーがタップすると、ダイアログが閉じます。
また、ラジオボタンやチェックボックスではないリストを使用している場合、リストのアイテムがタップされると、ダイアログが閉じます。
それ以外の場合は、DialogFragmentでdismiss()を呼び出せば、ダイアログを手動で閉じることができます。

ダイアログを閉じる際のイベント

ダイアログが閉じる際に、特定のアクションを実行する必要がある場合は、DialogFragmentでonDismiss() コールバックメソッドを実装すればイベントを取得できます。

ダイアログに表示される何らかのボタンを押下してダイアログを閉じた場合や、キャンセルした場合、dismiss()メソッドを明示的に呼び出さなくてもonDismiss()コールバックメソッドは呼び出されます。
カスタムダイアログなどで独自の方法でダイアログを閉じる際には開発者がdismiss()メソッドを呼び出す必要があります。

ダイアログをキャンセルする際のイベント

ダイアログをキャンセルして閉じることもできます。
これはユーザーがダイアログが求めるタスクを実行せずに、明示的にダイアログを離れたことを表す特別なイベントです。
この状況が発生するのは、ユーザーが戻るボタンを押す、ダイアログ領域外の画面をタップする、または開発者が明示的にcancel() を呼び出す場合です。

Negativeボタンを押下してダイアログを閉じてもデフォルトではキャンセルイベントが発行されないことに注意してください。
Negativeボタンのイベントリスナーの中で開発者が明示的にcancel()メソッドを呼び出す必要があります。

DialogFragmentを継承したクラスでonCancel()コールバックメソッドを実装すればキャンセルイベントを取得できます。

注意点として、いずれかの方法でキャンセルした場合でもonDismiss()コールバックメソッドは呼び出されます。

参考資料

サンプルプログラム

公式サイト

Advertisements