Androidでモーダルダイアログ

今更だがAndroidではモーダルなダイアログが存在しない。
ダイアログ自体は存在するがそれはモードレスダイアログだけだ。


過去にはこちらのサイトにあるように、非UIスレッド上という制限付きだがモーダルダイアログが実現できていた。
先ほどのサイト内でも書かれているがAndroidではUIスレッドを止めることがご法度なので
非UIスレッド上という制限がついている。


それでもどうしてもUIスレッド上でモーダルダイアログを実現したいので
無茶な方法だが実現してみた。


以下がそのコード。
※後述しますがこのダイアログにも判明している制限があります。
 また、無茶をしているので予想外の影響を及ぼす可能性があります。
 使用する場合は個人の責任で、よくテストを行ってから使用してください。

private static boolean showModalDialog(Context context, String text) {
    final Handler handler = new Handler() {
        @Override
        public void dispatchMessage(Message msg) {
            if (msg.what == 1192) {
                try {
                    msg.recycle();
                } catch (IllegalStateException ex) {
                    this.removeMessages(msg.what);
                }
                //大域脱出のための例外を発生させる
                throw new BreakMainLoopException();
            }
            super.dispatchMessage(msg);
        }
    };

    final Runnable finalize = new Runnable() {
        @Override
        public void run() {
            //停止しているUIスレッドを起動させるためにメッセージを送る
            handler.sendEmptyMessage(1192);
        }
    };

    final Object[] result = new Object[1];
    result[0] = false;

    // 表示するダイアログを作成する
    final AlertDialog.Builder builder = new AlertDialog.Builder(context);
    builder.setMessage(text);

    //YESボタンを設定
    builder.setPositiveButton("YES", new DialogInterface.OnClickListener() {
        @Override
        public void onClick(DialogInterface arg0, int arg1) {
            result[0] = true;
            finalize.run();
        }
    });

    //NOボタンを設定
    builder.setNegativeButton("NO", new DialogInterface.OnClickListener() {
        @Override
        public void onClick(DialogInterface arg0, int arg1) {
            result[0] = false;
            finalize.run();
        }
    });

    //キャンセルされたときの動作を設定
    builder.setOnCancelListener(new DialogInterface.OnCancelListener() {
        @Override
        public void onCancel(DialogInterface dialogInterface) {
            result[0] = false;
            finalize.run();
        }
    });

    //まずダイアログを表示する
    builder.show();

    //メッセージループは止めずにプログラムの実行を現時点で停止させたいので、
    //本来はAndroidのmain関数内で実行されているメッセージループメソッドを
    //ここでも実行してメッセージを処理させる。
    try {
        Looper.loop();
        //メッセージループから強制的に脱出するために専用の例外が投げられてくるためそれを捕まえる。
    } catch (BreakMainLoopException e) {
    }

    return (boolean)result[0];
}
private static final class BreakMainLoopException extends RuntimeException {}



上記のコードをトーストで囲むようにして呼び出すと、ちゃんとプログラムが停止していることがわかる。

 private void testModalDialog() {
      Toast.makeText(this, "Before", Toast.LENGTH_SHORT).show();

      boolean result = showModalDialog2(this, "modal dialog!");

      Toast.makeText(this, "After:" + result, Toast.LENGTH_SHORT).show();
  }



このモーダルダイアログの実現方法だが、先ほども書いたようにAndroidではUIスレッドを止めることはご法度だ。
これを実際の処理に近い単語で言い換えると、メインのメッセージループを止めることがご法度なのだ。


なら、「止めたい場所でメッセージループをもう一度呼び出せばいい」というのがこの方法の肝である。
あとは本来無限ループになっているメッセージループから抜け出すために、Handlerと例外を利用している。
この二つの無茶でモーダルダイアログを実現した。


私が作っているアプリでは昔からこの方法を採用していて、一応Androidのバージョン2.3の頃から8.0までは動作が確認できている。
ただし、Fragmentとの相性は未知数で私自身は試したことがない。
また必ずエラーとなる場合もあり、それは以下のようにViewクラスのonTouchEventメソッドのコンテキストで呼び出す場合である。

new View(context) {
    @Override
    public boolean onTouchEvent(MotionEvent event) {
        showModalDialog(context, "error error error!");
        return super.onTouchEvent(event);
    }
};



この場合、間違いなくエラーになるので注意して欲しい。
もちろんクリックイベント内で今回のダイアログを呼び出すのは問題ない。
他にも私が気づいていないだけで使用できないケースが他にもあると思うので本当に注意してほしい。


最後に、前述のサイトで実現されていた
非UIスレッド用モーダルダイアログと、今回のUIスレッド用モーダルダイアログを混ぜたメソッドを紹介する。
私はずっとこれを利用していて、UIスレッドにいるのか非UIスレッドにいるのかあまり気にせずに呼び出している。

private static boolean showModalDialog(Context context, String text) {
    //UIメッセージループインスタンスを取得します
    Looper mainLooper = Looper.getMainLooper();
    //現在のスレッドとメッセージループのスレッドが同じであれば、現在はUIスレッドにいる
    final boolean isInUIThread = Thread.currentThread() == mainLooper.getThread();

    //カウントダウンシグナルはUIスレッド以外で実行されたときだけ参照される。
    final CountDownLatch signal = isInUIThread == false ?  new CountDownLatch(1) : null;

    //ハンドラはUIスレッドで実行されたときだけ参照される
    final Handler handler = isInUIThread ? new Handler() {
        @Override public void dispatchMessage(Message msg) {
            if(msg.what == 1192) {
                try {
                    msg.recycle();
                } catch (IllegalStateException ex) {
                    this.removeMessages(msg.what);
                }
                throw new BreakMainLoopException();
            }
            super.dispatchMessage(msg);
        }
    } : null;

    final Runnable finalize = new Runnable() {
        @Override
        public void run() {
            if (isInUIThread) {
                //停止しているUIスレッドを起動させるためにメッセージを送る
                handler.sendEmptyMessage(1192);
            } else {
                //停止スレッドを起動させるためにシグナルを送る
                signal.countDown();
            }
        }
    };

    //戻り値にする値(イベントリスナ内で値を操作する必要があるので配列にする)
    final Object[] result = new Object[1];
    result[0] = false;

    // 表示するダイアログを作成する
    final AlertDialog.Builder builder = new AlertDialog.Builder(context);
    builder.setMessage(text);

    //YESボタンを設定
    builder.setPositiveButton("YES", new DialogInterface.OnClickListener() {
        @Override
        public void onClick(DialogInterface arg0, int arg1) {
            result[0] = true;
            finalize.run();
        }
    });

    //NOボタンを設定
    builder.setNegativeButton("NO", new DialogInterface.OnClickListener() {
        @Override
        public void onClick(DialogInterface arg0, int arg1) {
            result[0] = false;
            finalize.run();
        }
    });

    //キャンセルされたときの動作を設定
    builder.setOnCancelListener(new DialogInterface.OnCancelListener() {
        @Override
        public void onCancel(DialogInterface dialogInterface) {
            result[0] = false;
            finalize.run();
        }
    });

    if(isInUIThread) {
        //UIスレッドから実行された場合

        //まずダイアログを表示する
        builder.show();

        //メッセージループは止めずにプログラムの実行を現時点で停止させたいので、
        //本来はAndroidのmain関数内で実行されているメッセージループメソッドを
        //ここでも実行してメッセージを処理させる。
        try {
            Looper.loop();
            //メッセージループから強制的に脱出するために専用の例外が投げられてくるためそれを捕まえる。
        } catch(BreakMainLoopException e) { }

    } else {
        //UIスレッド以外から実行されたので、呼び出しスレッドをとめてダイアログを表示

        //ダイアログはUIスレッドで表示させないといけない
        new Handler(mainLooper).post(new Runnable() {
            @Override
            public void run() {
                builder.show();
            }
        });

        try {
            //スレッド停止
            signal.await();
            //スレッドはユーザがダイアログのボタンを押した時に再開される
        } catch (InterruptedException e) {
        }
    }

    return (boolean)result[0];
}

private static final class BreakMainLoopException extends RuntimeException {}



個人的には、なかなか無茶な実現方法で、とても気に入っている。