【Android】クリック可能なToastをつくる(考察編)

今作っているTwitterのクライアントは常時UserStreamに接続、ふぁぼられたりRTされたりするとToastで表示すると言う誰しもが考えそうな承認欲求を満たすための機能がついています。

が、うっかり(n * 100)RT以上されるような長文Postをしてしまったがために画面を占拠され、しかも延々とToastが表示され続けるせいでアプリを止めても邪魔され続けると言う悲しい状況も既に何度か体験しています。

一応Toastクラスには表示を止めるためのToast#cancel()メソッドが用意されているのですが、これはつまり、Toastのインスタンスを何処かに保持していなければならないわけです。また、仮にこれを保持しておくとしても、cancelメソッドを発火させるイベントも用意しなくてはなりません。

イベントを発火させる方法はいくつか考えられますが、一番わかりやすいのは「Toastそのものをタップしたら消せる」かな、と思い、ClickableなToastを作りたいなーと思ったんですが、全然一筋縄ではいかないねって話をします。

ToastはViewではない

もう一度Toastクラスのドキュメントを見てみましょう。オブジェクトの関係はこうなっています。

java.lang.Object
   ?android.widget.Toast

と言うわけで、ToastはViewではありません。なのでToastそのものにView.OnClickListenerをセットするなんてことは無理です。

そもそも親がObjectな時点で、Toastに対してAndroid SDKに用意されているListener全般をセットするのは不可能と考えたほうがよさそうです。さっさと次のアイデアに移りましょう。

Toastが内部で所持しているViewにView.OnClickListenerをセットする

Toastそのものがダメなら内部で所持しているViewにView.OnClickListenerをセットしてみるのはどうでしょう。

ToastはToast#setView(View view)メソッドを使用することで自由にそのレイアウトを変更することが出来ます。セットしていなくてもToast#getView()を使えばデフォルトのViewを取得できるはずです。

ちょっと簡単なコードを書いてみましょう。

public void showClickableToast() {
    final Toast toast = Toast.makeText(getApplicationContext()
        // 念のため長い文字列にしておく
        , "おああああああああああああああああああああああああああああああああ"
        , Toast.LENGTH_LONG);
    
    View v = toast.getView();
    v.setOnClickListener(new View.OnClickListener(){
        @Override
        public void onClick(View v) {
            toast.cancel();
        }
    });
    
    v.setFocusableInTouchMode(true);
    v.setClickable(true);
    
    // 多分なくてもいいけどダメ押し
    toast.setView(v);
    
    toast.show();
}

なんかいけそうじゃね?と思って実行してみるも、うんともすんとも言いません。

View.OnClickListener内にブレークポイントを置いてみても反応しません。どうもクリックイベントすら受け取ってくれていない気がします。

Toastのしくみ

ここまでくるとリファレンスレベルではお手上げです。ソースを覗いてみることにしましょう。

すべて載せると流石に長いので、いくつかの重要そうなメソッドレベルで見ていきます。

まずはmakeText。二つオーバーロードがありますが、最終的にはここにきます。

/**
 * Make a standard toast that just contains a text view.
 *
 * @param context  The context to use.  Usually your {@link android.app.Application}
 *                 or {@link android.app.Activity} object.
 * @param text     The text to show.  Can be formatted text.
 * @param duration How long to display the message.  Either {@link #LENGTH_SHORT} or
 *                 {@link #LENGTH_LONG}
 *
 */
public static Toast makeText(Context context, CharSequence text, int duration) {
    Toast result = new Toast(context);

    LayoutInflater inflate = (LayoutInflater)
            context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
    View v = inflate.inflate(com.android.internal.R.layout.transient_notification, null);
    TextView tv = (TextView)v.findViewById(com.android.internal.R.id.message);
    tv.setText(text);
    
    result.mNextView = v;
    result.mDuration = duration;

    return result;
}

次にshow。

/**
 * Show the view for the specified duration.
 */
public void show() {
    if (mNextView == null) {
        throw new RuntimeException("setView must have been called");
    }

    INotificationManager service = getService();
    String pkg = mContext.getPackageName();
    TN tn = mTN;
    tn.mNextView = mNextView;

    try {
        service.enqueueToast(pkg, tn, mDuration);
    } catch (RemoteException e) {
        // Empty
    }
}

どうやらINotificationManagerと言うものが実際にToastを表示しているようです。getService()でgrepしてみましょう。

private static INotificationManager sService;

static private INotificationManager getService() {
    if (sService != null) {
        return sService;
    }
    sService = INotificationManager.Stub.asInterface(ServiceManager.getService("notification"));
    return sService;
}

単にServiceに対してシングルトンパターンを適用しているだけみたいですね。「hoge.Stub.asInterface」と言うのは、AIDLを使用するときのイディオムです。何らかのサービスに処理を委譲してるんですかね。

Toast#show()の最後に発行しているINotificationManager#enqueueToastメソッドを調べましょう。リファレンスはないのでAIDLのソースです。

public void enqueueToast(java.lang.String pkg, android.app.ITransientNotification callback, int duration) throws android.os.RemoteException;

実装まで見に行ってもいいんですが、多分INotificationManagerでは大したことをしてないと思います。

と言うのも、表示するべきViewはここの引数で渡している「android.app.ITransientNotification callback」にあるからです。Toast#show()のこの部分ですね。

TN tn = mTN;
tn.mNextView = mNextView;

この「TN」と言うアドホック感満載の何か、これは先ほどのgetServiceメソッドの直下にいます。

private static class TN extends ITransientNotification.Stub {
    final Runnable mShow = new Runnable() {
        @Override
        public void run() {
            handleShow();
        }
    };

    final Runnable mHide = new Runnable() {
        @Override
        public void run() {
            handleHide();
            // Don't do this in handleHide() because it is also invoked by handleShow()
            mNextView = null;
        }
    };

    private final WindowManager.LayoutParams mParams = new WindowManager.LayoutParams();
    final Handler mHandler = new Handler();    

    int mGravity;
    int mX, mY;
    float mHorizontalMargin;
    float mVerticalMargin;


    View mView;
    View mNextView;

    WindowManager mWM;

    TN() {
        // XXX This should be changed to use a Dialog, with a Theme.Toast
        // defined that sets up the layout params appropriately.
        final WindowManager.LayoutParams params = mParams;
        params.height = WindowManager.LayoutParams.WRAP_CONTENT;
        params.width = WindowManager.LayoutParams.WRAP_CONTENT;
        params.format = PixelFormat.TRANSLUCENT;
        params.windowAnimations = com.android.internal.R.style.Animation_Toast;
        params.type = WindowManager.LayoutParams.TYPE_TOAST;
        params.setTitle("Toast");
        params.flags = WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON
                | WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
                | WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE;
    }

    /**
     * schedule handleShow into the right thread
     */
    @Override
    public void show() {
        if (localLOGV) Log.v(TAG, "SHOW: " + this);
        mHandler.post(mShow);
    }

    /**
     * schedule handleHide into the right thread
     */
    @Override
    public void hide() {
        if (localLOGV) Log.v(TAG, "HIDE: " + this);
        mHandler.post(mHide);
    }

    public void handleShow() {
        if (localLOGV) Log.v(TAG, "HANDLE SHOW: " + this + " mView=" + mView
                + " mNextView=" + mNextView);
        if (mView != mNextView) {
            // remove the old view if necessary
            handleHide();
            mView = mNextView;
            Context context = mView.getContext().getApplicationContext();
            if (context == null) {
                context = mView.getContext();
            }
            mWM = (WindowManager)context.getSystemService(Context.WINDOW_SERVICE);
            // We can resolve the Gravity here by using the Locale for getting
            // the layout direction
            final Configuration config = mView.getContext().getResources().getConfiguration();
            final int gravity = Gravity.getAbsoluteGravity(mGravity, config.getLayoutDirection());
            mParams.gravity = gravity;
            if ((gravity & Gravity.HORIZONTAL_GRAVITY_MASK) == Gravity.FILL_HORIZONTAL) {
                mParams.horizontalWeight = 1.0f;
            }
            if ((gravity & Gravity.VERTICAL_GRAVITY_MASK) == Gravity.FILL_VERTICAL) {
                mParams.verticalWeight = 1.0f;
            }
            mParams.x = mX;
            mParams.y = mY;
            mParams.verticalMargin = mVerticalMargin;
            mParams.horizontalMargin = mHorizontalMargin;
            if (mView.getParent() != null) {
                if (localLOGV) Log.v(TAG, "REMOVE! " + mView + " in " + this);
                mWM.removeView(mView);
            }
            if (localLOGV) Log.v(TAG, "ADD! " + mView + " in " + this);
            mWM.addView(mView, mParams);
            trySendAccessibilityEvent();
        }
    }

    private void trySendAccessibilityEvent() {
        AccessibilityManager accessibilityManager =
                AccessibilityManager.getInstance(mView.getContext());
        if (!accessibilityManager.isEnabled()) {
            return;
        }
        // treat toasts as notifications since they are used to
        // announce a transient piece of information to the user
        AccessibilityEvent event = AccessibilityEvent.obtain(
                AccessibilityEvent.TYPE_NOTIFICATION_STATE_CHANGED);
        event.setClassName(getClass().getName());
        event.setPackageName(mView.getContext().getPackageName());
        mView.dispatchPopulateAccessibilityEvent(event);
        accessibilityManager.sendAccessibilityEvent(event);
    }        

    public void handleHide() {
        if (localLOGV) Log.v(TAG, "HANDLE HIDE: " + this + " mView=" + mView);
        if (mView != null) {
            // note: checking parent() just to make sure the view has
            // been added...  i have seen cases where we get here when
            // the view isn't yet added, so let's try not to crash.
            if (mView.getParent() != null) {
                if (localLOGV) Log.v(TAG, "REMOVE! " + mView + " in " + this);
                mWM.removeView(mView);
            }

            mView = null;
        }
    }
}

よーく見ていくと、handleShowと言ういかにもなメソッド内でWindowManager mWMに対してaddViewしています。

そしてWindowManager.LayoutParamsに対し、ある特定のパラメータを渡すと表示レイヤの優先度をあげることが出来ます。Toastの動作から考えても筋が通っています。

ここまで見てみたところ、結局のところToast内部のViewをそのまま表示しているようにしか見えません。Viewレベルではクリックイベントが無視されるような設定がされていません。

となるとWindowManager.LayoutParamsに何か設定している部分があるんだろう、と思ってgrepすると、TNのコンストラクタが引っかかります。

TN() {
    // XXX This should be changed to use a Dialog, with a Theme.Toast
    // defined that sets up the layout params appropriately.
    final WindowManager.LayoutParams params = mParams;
    params.height = WindowManager.LayoutParams.WRAP_CONTENT;
    params.width = WindowManager.LayoutParams.WRAP_CONTENT;
    params.format = PixelFormat.TRANSLUCENT;
    params.windowAnimations = com.android.internal.R.style.Animation_Toast;
    params.type = WindowManager.LayoutParams.TYPE_TOAST;
    params.setTitle("Toast");
    params.flags = WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON
            | WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
            | WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE;
}

あっ

TN() {
    // XXX This should be changed to use a Dialog, with a Theme.Toast
    // defined that sets up the layout params appropriately.
    final WindowManager.LayoutParams params = mParams;
    params.height = WindowManager.LayoutParams.WRAP_CONTENT;
    params.width = WindowManager.LayoutParams.WRAP_CONTENT;
    params.format = PixelFormat.TRANSLUCENT;
    params.windowAnimations = com.android.internal.R.style.Animation_Toast;
    params.type = WindowManager.LayoutParams.TYPE_TOAST;
    params.setTitle("Toast");
    params.flags = WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON
            | WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
            | WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE;
}

こいつか!!!!!!

念のためWindowManager.LayoutParamsのドキュメントで該当するフラグを確認しておきましょう。

完全にこれが原因でクリックイベントが阻害されていますね。

まとめと次回予告

と言うわけで、次回はToastのソースから上記の二つのフラグをとっぱらったら普通に動いちゃうんじゃないの?と言うのを調べます。

まだ実行できてないので、本当にそれだけで動くかどうかはわかりません。

[2014/05/31追記]

実装しました。

参考