Dialog/PopupWindow/Toast 到底該怎么選

前言

顯示頁(yè)面除了Activity,使用最多的可能就是Dialog、PopupWindow、Toast了。這三者有相似之處也有不一樣的地方,本篇文章旨在厘清三者關(guān)系,闡明各自的優(yōu)缺點(diǎn),并探討哪種場(chǎng)合使用它們。
本篇文章涉及到WindowManager相關(guān)知識(shí),如有需要請(qǐng)移步:Window/WindowManager 不可不知之事

通過(guò)本篇文章,你將了解到:

1、Dialog/PopupWindow/Toast 生命周期
2、Dialog/PopupWindow/Toast 異同之處
3、Dialog/PopupWindow/Toast 使用場(chǎng)合

Dialog/PopupWindow/Toast 生命周期

在之前的文章有提過(guò):任何View都需要添加到Window上才能展示,這個(gè)過(guò)程大致分為四個(gè)步驟:

1、構(gòu)造顯示的目標(biāo)View
2、獲取WindowManager 實(shí)例
2、構(gòu)造約束Window的WindowManager.LayoutParams
3、WindowManager.addView(View, LayoutParams)

Dialog/PopupWindow/Toast 實(shí)際上就是封裝了上述四個(gè)步驟,并提供更進(jìn)一步的功能及其更豐富的接口使用,接下來(lái)我們逐步分析。

Dialog 生命周期

先來(lái)看看簡(jiǎn)單demo

        //自定義View
        MyGroup myGroup = new MyGroup(v.getContext());
        //Dialog 實(shí)例
        Dialog dialog = new Dialog(v.getContext());
        //添加View
        dialog.setContentView(myGroup);
        //最終展示
        dialog.show();

先看看Dialog構(gòu)造函數(shù):

    Dialog(@NonNull Context context, @StyleRes int themeResId, boolean createContextThemeWrapper) {
        //themeResId 指定Dialog樣式
        if (createContextThemeWrapper) {
            if (themeResId == Resources.ID_NULL) {
                //若不指定,則使用默認(rèn)的樣式
                final TypedValue outValue = new TypedValue();
                context.getTheme().resolveAttribute(R.attr.dialogTheme, outValue, true);
                themeResId = outValue.resourceId;
            }
            mContext = new ContextThemeWrapper(context, themeResId);
        } else {
            mContext = context;
        }

        //獲取WindowManager,context是Activity類型,因此此時(shí)獲取的WindowManager
        //即是Activity的WindowManager
        mWindowManager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
        //構(gòu)造Window對(duì)象
        final Window w = new PhoneWindow(mContext);
        mWindow = w;
        //監(jiān)聽(tīng)touch/key event等事件
        w.setCallback(this);
        //省略
        w.setWindowManager(mWindowManager, null, null);
        //Window默認(rèn)居中
        w.setGravity(Gravity.CENTER);
    }

構(gòu)造Window對(duì)象時(shí):

#Window.java
//構(gòu)造LayoutParams
    private final WindowManager.LayoutParams mWindowAttributes =
            new WindowManager.LayoutParams();

//WindowManager.java
    public static final int TYPE_APPLICATION        = 2;
    public LayoutParams() {
        super(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT);
        type = TYPE_APPLICATION;
        format = PixelFormat.OPAQUE;
    }

可以看出,Dialog構(gòu)造方法主要做了兩件事:

1、構(gòu)造WindowManager
2、構(gòu)造Window對(duì)象,同時(shí)在Window里會(huì)初始化WindowManager.LayoutParams 變量

完成了四個(gè)步驟的第二、三步:構(gòu)造WindowManager/LayoutParams對(duì)象。

再看看setContentView(XX)

#Dialog.java
    public void setContentView(@android.annotation.NonNull View view) {
        //Window 方法,實(shí)例是PhoneWindow
        mWindow.setContentView(view);
    }

#PhoneWindow.java
    public void setContentView(View view) {
        setContentView(view, new ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT));
    }

    @Override
    public void setContentView(View view, ViewGroup.LayoutParams params) {
        if (mContentParent == null) {
            //構(gòu)造DecorView
            installDecor();
        } else if (!hasFeature(FEATURE_CONTENT_TRANSITIONS)) {
            mContentParent.removeAllViews();
        }
        if (hasFeature(FEATURE_CONTENT_TRANSITIONS)) {
            //省略
        } else {
            //mContentParent 為 DecorView 子View
            //將自定義View添加到mContentParent里,最終也是掛到了DecorView Tree里
            mContentParent.addView(view, params);
        }
        //省略
    }

其中有關(guān)DecorView的創(chuàng)建過(guò)程請(qǐng)移步:Android DecorView 一窺全貌(上)

setContentView(XX)構(gòu)造了DecorView,并將自定義View添加到DecorView里

最后看看dialog.show()

    public void show() {
        if (mShowing) {
            //Dialog 正在展示,則退出
            return;
        }
        if (!mCreated) {
            //最終調(diào)用onCreate(xx)
            dispatchOnCreate(null);
        } else {
            //省略
        }
        onStart();
        //獲取DecorView,在setContentView(XX)時(shí)已經(jīng)構(gòu)造好DecorView
        mDecor = mWindow.getDecorView();
        //在創(chuàng)建Window時(shí)已經(jīng)構(gòu)造好
        WindowManager.LayoutParams l = mWindow.getAttributes();

        //添加DecorView
        mWindowManager.addView(mDecor, l);
        mShowing = true;
    }

dialog.show() 完成了四個(gè)步驟中的最后一步:addView(xx)
至此,Dialog創(chuàng)建完畢并顯示,通過(guò)上述分析可知,Dialog將四個(gè)步驟封裝了。

如何關(guān)閉Dialog

既然是通過(guò)WindowManager.addView(xx)添加的View,那么Dialog關(guān)閉相應(yīng)的也需要調(diào)用WindowManager.removeView(xx),此處調(diào)用的是WindowManager.removeViewImmediate(xx),表示立即執(zhí)行銷毀動(dòng)作。

#Dialog.java
    @Override
    public void dismiss() {
        if (Looper.myLooper() == mHandler.getLooper()) {
            //主線程直接執(zhí)行
            dismissDialog();
        } else {
            //子線程切換到主線程執(zhí)行
            mHandler.post(mDismissAction);
        }
    }

    @UnsupportedAppUsage
    void dismissDialog() {
        if (mDecor == null || !mShowing) {
            return;
        }
        try {
            //移除DecorView
            mWindowManager.removeViewImmediate(mDecor);
        } finally {
            //調(diào)用onStop
            onStop();
            mShowing = false;
            sendDismissMessage();
        }
    }

Dialog 生命周期如下:


image.png

PopupWindow 生命周期

同樣的簡(jiǎn)單demo

        //PopupWindow 寬、高
        popupWindow = new PopupWindow(400, 400);
        MyGroup myGroup = new MyGroup(v.getContext());
        popupWindow.setContentView(myGroup);
        //展示popupWindow
        popupWindow.showAsDropDown(button);

看得出來(lái)PopupWindow創(chuàng)建與Dialog類似。

先看看構(gòu)造函數(shù):

    public PopupWindow(View contentView, int width, int height, boolean focusable) {
        //contentView 為自定義View
        if (contentView != null) {
            mContext = contentView.getContext();
            //獲取WindowManager mContext 屬于Activity類型
            //與Dialog 一樣,WindowManager 就是Activity WindowManager
            mWindowManager = (WindowManager) mContext.getSystemService(Context.WINDOW_SERVICE);
        }
        //設(shè)置 mContentView = contentView;
        setContentView(contentView);
        //設(shè)置Window寬、高
        setWidth(width);
        setHeight(height);
        //設(shè)置獲取焦點(diǎn)與否
        setFocusable(focusable);
    }

注意,PopupWindow 默認(rèn)寬高為0,因此需要外部設(shè)置寬高值

setContentView(XX)

    public void setContentView(View contentView) {
        if (isShowing()) {
            return;
        }
        //賦值
        mContentView = contentView;
        if (mContext == null && mContentView != null) {
            //獲取Context
            mContext = mContentView.getContext();
        }
        if (mWindowManager == null && mContentView != null) {
            //根據(jù)Context獲取WindowManager
            mWindowManager = (WindowManager) mContext.getSystemService(Context.WINDOW_SERVICE);
        }
    }

popupWindow.showAsDropDown(View anchor)

View anchor 指的是先錨定一個(gè)View,PopupWindow根據(jù)這個(gè)View的位置來(lái)確定自己的位置。

    public void showAsDropDown(View anchor, int xoff, int yoff, int gravity) {
        if (isShowing() || !hasContentView()) {
            //正在展示,則不處理后續(xù)
            return;
        }
        //一系列監(jiān)聽(tīng)錨定的View
        attachToAnchor(anchor, xoff, yoff, gravity);
        //構(gòu)造 LayoutParams,并設(shè)置其一些參數(shù)
        final WindowManager.LayoutParams p =
                createPopupLayoutParams(anchor.getApplicationWindowToken());
        
        //構(gòu)造"DecorView",該DecorView不是我們常見(jiàn)的DecorView,而是PopupWindow里的內(nèi)部類
        //該View作為Window的根View
        preparePopup(p);
        
        //根據(jù)anchor確認(rèn)Window的起始位置
        final boolean aboveAnchor = findDropDownPosition(anchor, p, xoff, yoff,
                p.width, p.height, gravity, mAllowScrollingAnchorParent);
        updateAboveAnchor(aboveAnchor);
        //添加到Window里。WindowManager.addView(xx)
        invokePopup(p);
    }

至此,PopupWindow創(chuàng)建完畢,可以看出以上步驟包括了Window顯示的四個(gè)步驟。

如何關(guān)閉PopupWindow

與Dialog 類似,PopupWindow 有個(gè)方法:

public void dismiss();

該方法最后調(diào)用了WindowManager.removeViewImmediate(xx)方法移除Window。

Toast 生命周期

還是一個(gè)小demo:

Toast.makeText(App.getApplication(), "hello toast", Toast.LENGTH_LONG).show();

makeText(XX)是個(gè)靜態(tài)方法:

    public static Toast makeText(@android.annotation.NonNull Context context, @android.annotation.Nullable Looper looper,
                                 @android.annotation.NonNull CharSequence text, @Duration int duration) {
        //構(gòu)造 Toast對(duì)象
        Toast result = new Toast(context, looper);
        //加載View
        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是v的子View 設(shè)置顯示的內(nèi)容
        tv.setText(text);
        //記錄到Toast里
        result.mNextView = v;
        result.mDuration = duration;
        return result;
    }

Toast.show()方法

    public void show() {
        INotificationManager service = getService();
        String pkg = mContext.getOpPackageName();
        //構(gòu)造TN對(duì)象
        TN tn = mTN;
        tn.mNextView = mNextView;
        final int displayId = mContext.getDisplayId();
        
        try {
            //加入到隊(duì)列里
            service.enqueueToast(pkg, tn, mDuration, displayId);
        } catch (RemoteException e) {
            // Empty
        }
    }

到此Toast創(chuàng)建并顯示出來(lái),但是我們并沒(méi)有看到熟悉的WindowManager.addView(xx),繼續(xù)來(lái)看看。
show()方法里構(gòu)造了TN對(duì)象,最后該對(duì)象被加入到了INotificationManager里。該類是底層服務(wù)類,其實(shí)現(xiàn)類是:NotificationManagerService.java。既然傳給了底層,那么勢(shì)必要有傳回來(lái)的動(dòng)作,查看TN類發(fā)現(xiàn):

    public void show(IBinder windowToken) {
        if (localLOGV) Log.v(TAG, "SHOW: " + this);
        //發(fā)送到handler執(zhí)行
        mHandler.obtainMessage(SHOW, windowToken).sendToTarget();
    }

    public void handleShow(IBinder windowToken) {
        if (mView != mNextView) {
            // remove the old view if necessary
            handleHide();
            mView = mNextView;
            Context context = mView.getContext().getApplicationContext();
            String packageName = mView.getContext().getOpPackageName();
            if (context == null) {
                context = mView.getContext();
            }
            
            //獲取 WindowManager 對(duì)象
            mWM = (WindowManager)context.getSystemService(Context.WINDOW_SERVICE);
            final Configuration config = mView.getContext().getResources().getConfiguration();
            final int gravity = Gravity.getAbsoluteGravity(mGravity, config.getLayoutDirection());
            
            //WindowManager.LayoutParams mParams = new WindowManager.LayoutParams();
            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;
            }
            //設(shè)置Toast 坐標(biāo)等屬性
            mParams.x = mX;
            mParams.y = mY;
            mParams.verticalMargin = mVerticalMargin;
            mParams.horizontalMargin = mHorizontalMargin;
            mParams.packageName = packageName;
            mParams.hideTimeoutMilliseconds = mDuration ==
                    Toast.LENGTH_LONG ? LONG_DURATION_TIMEOUT : SHORT_DURATION_TIMEOUT;
            mParams.token = windowToken;
            if (mView.getParent() != null) {
                if (localLOGV) Log.v(TAG, "REMOVE! " + mView + " in " + this);
                mWM.removeView(mView);
            }
            try {
                //添加到Window
                mWM.addView(mView, mParams);
                trySendAccessibilityEvent();
            } catch (WindowManager.BadTokenException e) {
                /* ignore */
            }
        }
    }

又看到了熟悉的addView(xx)流程??偨Y(jié)來(lái)說(shuō):

make() 方法構(gòu)造Toast
show() 方法 將要顯示的內(nèi)容加入到service
service根據(jù)時(shí)間長(zhǎng)短通過(guò)handler通知UI進(jìn)行展示

如何關(guān)閉Toast

既然Toast顯示策略都在service里完成,那么當(dāng)時(shí)間到了之后讓Toast消失也是service通知上層銷毀Window

    public void cancel() {
        if (localLOGV) Log.v(TAG, "CANCEL: " + this);
        mHandler.obtainMessage(CANCEL).sendToTarget();
    }

    public void handleHide() {
        if (mView != null) {
            if (mView.getParent() != null) {
                //銷毀Window
                mWM.removeViewImmediate(mView);
            }
            try {
                getService().finishToken(mPackageName, this);
            } catch (RemoteException e) {
            }
            mView = null;
        }
    }

Dialog/PopupWindow/Toast 異同之處

上邊分析了三者的生命周期,了解到他們都是通過(guò)addView(xx)添加View到Window進(jìn)行展示的,那么他們各自的特點(diǎn)以及側(cè)重點(diǎn)是體現(xiàn)在哪些方面呢?接下來(lái)分析。
當(dāng)我們分別運(yùn)行上邊的三個(gè)demo,發(fā)現(xiàn):
Dialog 表現(xiàn):

居中展示、外部有蒙層、點(diǎn)擊屏幕外Dialog消失、點(diǎn)擊返回鍵Dialog消失、Dialog 攔截了屏幕上所有的touch/key 事件。
Dialog需要Activity類型的Context啟動(dòng)。
有動(dòng)畫(huà)。

PopupWindow 表現(xiàn)

基于某個(gè)錨點(diǎn)顯示,可以偏移任何距離。點(diǎn)擊屏幕外PopupWindow不消失,PopupWindow 僅僅攔截自身區(qū)域內(nèi)的touch/key 事件。
PopupWindow需要Activity類型的Context啟動(dòng)。
有動(dòng)畫(huà)。

Toast 表現(xiàn)

Toast 在屏幕底部彈出一段文本,該文本在展示指定的時(shí)間后消失。
Toast 不強(qiáng)制需要Activity類型的Context啟動(dòng)。
有動(dòng)畫(huà)。

接下來(lái)看看造成以上差異之處的原因:

Window 位置確定

WindowManager.LayoutParams.gravity
指定Window方位,如居中、居左、居右、居底、居頂。

WindowManager.LayoutParams.x
WindowManager.LayoutParams.y

這倆參數(shù)確定Window 距離"gravity"指定方位的偏移。
如當(dāng)gravity=Gravity.LEFT 那么layoutParams.x = 200(正數(shù)),表示X軸向右偏移的距離,負(fù)數(shù)反之。
當(dāng)gravity=Gravity.RIGHT 那么layoutParams.x = 200,表示X軸向左偏移的距離,負(fù)數(shù)反之。
同理垂直方向也是一樣道理。
因此Window 位置確定是通過(guò)gravity 和x/y屬性結(jié)合判斷的。
Dialog 位置確定

    Dialog(@android.annotation.NonNull Context context, @StyleRes int themeResId, boolean createContextThemeWrapper) {
        //省略
        final Window w = new PhoneWindow(mContext);
        //設(shè)置gravity
        w.setGravity(Gravity.CENTER);
    }

Dialog 構(gòu)造函數(shù)里設(shè)置Window居中,因此demo里表現(xiàn)出來(lái)的Dialog居中展示。
因此改變"gravity"默認(rèn)值:

dialog.getWindow().getAttributes().gravity = Gravity.XX

PopupWindow 位置確定

    public void showAsDropDown(View anchor, int xoff, int yoff, int gravity) {
        //省略...
        //確定layoutParams.x/layoutParams.y 的值
        //xoff/yoff 表示的是window 距離錨點(diǎn)anchor的偏移,默認(rèn)是anchor的左下角
        //gravity指的是window與anchor的對(duì)齊方式,比如Gravity.RIGHT,表示W(wǎng)indow與anchor右對(duì)齊
        //當(dāng)xoff/yoff、gravity同時(shí)設(shè)置時(shí),先按照anchor的左下角偏移xoff/yoff,得出當(dāng)前的layoutParams.x/layoutParams.y值
        //再根據(jù)gravity調(diào)整layoutParams.x/layoutParams.y值
        final boolean aboveAnchor = findDropDownPosition(anchor, p, xoff, yoff,
                p.width, p.height, gravity, mAllowScrollingAnchorParent);
        //省略...
    }

findDropDownPosition(xx) 該方法確定了PopupWindow 的WindowManager.LayoutParams.x/WindowManager.LayoutParams.y值。
再來(lái)看看WindowManager.LayoutParams.gravity如何確定的:

    protected final WindowManager.LayoutParams createPopupLayoutParams(IBinder token) {
        final WindowManager.LayoutParams p = new WindowManager.LayoutParams();
        //計(jì)算出LayoutParams.gravity
        p.gravity = computeGravity();
        //省略
        return p;
    }

    private int computeGravity() {
        //根據(jù)mGravity來(lái)確定gravity
        int gravity = mGravity == Gravity.NO_GRAVITY ?  Gravity.START | Gravity.TOP : mGravity;
        if (mIsDropdown && (mClipToScreen || mClippingEnabled)) {
            gravity |= Gravity.DISPLAY_CLIP_VERTICAL;
        }
        return gravity;
    }

而mGravity是可以在外部設(shè)置的:

    public void showAtLocation(View parent, int gravity, int x, int y) {d
        mParentRootView = new WeakReference<>(parent.getRootView());
        showAtLocation(parent.getWindowToken(), gravity, x, y);
    }

    public void showAtLocation(IBinder token, int gravity, int x, int y) {
        //省略...
        mGravity = gravity;
        //省略
    }

因此,可以通過(guò)showAtLocation(xx)設(shè)置PopupWindow的Gravity。
此處需要注意的是:
showAsDropDown(xx)參數(shù)里的gravity指的是PopupWindow與錨點(diǎn)View的對(duì)齊方式。
而showAtLocation(xx)參數(shù)里的gravity才是PopupWindow的Gravity。

Toast 位置確定
Toast 默認(rèn)底部水平居中。在Toast.TN 類里,當(dāng)展示Toast時(shí)調(diào)用handleShow(xx)方法:

    public void handleShow(IBinder windowToken) {
        //省略
        if (mView != mNextView) {
            // 省略
            //通過(guò)mGravity計(jì)算
            final int gravity = Gravity.getAbsoluteGravity(mGravity, config.getLayoutDirection());
            mParams.gravity = gravity;
            //x、y的值
            mParams.x = mX;
            mParams.y = mY;
        }
    }

而mGravity、mX、mY可以在外部設(shè)置:

    public void setGravity(int gravity, int xOffset, int yOffset) {
        mTN.mGravity = gravity;
        mTN.mX = xOffset;
        mTN.mY = yOffset;
    }

因此調(diào)用setGravity(xx)可以改變Toast展示的位置

Window外部區(qū)域變暗

Dialog彈出時(shí)外部區(qū)域會(huì)變暗,該效果由以下字段控制

WindowManager.LayoutParams.dimAmount
取值float類型
范圍[0-1]
值越大表示不透明度越高
0表示不變暗,1表示完全變暗
該值需要生效,需要配合另外字段使用:
layoutParams.flags |= WindowManager.LayoutParams.FLAG_DIM_BEHIND;

Dialog 外部變暗

    protected ViewGroup generateLayout(DecorView decor) {
        if (a.getBoolean(R.styleable.Window_backgroundDimEnabled,
                mIsFloating)) {
            if ((getForcedWindowFlags()&WindowManager.LayoutParams.FLAG_DIM_BEHIND) == 0) {
                //設(shè)置標(biāo)記,表示支持變暗
                params.flags |= WindowManager.LayoutParams.FLAG_DIM_BEHIND;
            }
            if (!haveDimAmount()) {
                //設(shè)置變暗的具體值
                params.dimAmount = a.getFloat(
                        android.R.styleable.Window_backgroundDimAmount, 0.5f);
            }
        }
    }

可以看出Dialog dimAmount值從style里獲取,該style里的默認(rèn)值是0.6。當(dāng)然我們可以在外部修改dimAmount值。

                dialog.setContentView(myGroup);
                dialog.getWindow().getAttributes().dimAmount = 0.3f;
                dialog.show();

需要注意的是,dimAmount賦值操作需要在setContentView(xx)之后進(jìn)行,否則設(shè)置的值會(huì)被setContentView(xx)重置。

PopupWindow和Toast 沒(méi)有對(duì)此設(shè)置相應(yīng)的值,因此就沒(méi)有外部區(qū)域變暗的說(shuō)法。

Window touch/key 事件

Dialog 事件接收
點(diǎn)擊Dialog 外部時(shí)(touch),Dialog消失;點(diǎn)擊物理返回鍵時(shí)(key),Dialog消失。因此我們可以猜測(cè)出Dialog是接收到了touch/key事件,并判斷如果touch事件在Window外部,那么關(guān)閉Dialog。
涉及到兩個(gè)步驟:

1、能接收到外部touch/key 事件
2、對(duì)事件進(jìn)行相應(yīng)的處理(是否關(guān)閉Dialog)

1、設(shè)置Dialog能否接收touch/key 事件
Window 默認(rèn)接收外部點(diǎn)擊事件和key事件,Dialog沒(méi)有更改此默認(rèn)值,因此能接收到touch/key 事件。
2、對(duì)接收的事件做處理
Dialog 實(shí)現(xiàn)了Window.Callback 接口,重寫(xiě)方法里對(duì)touch事件做處理

#Dialog.java
    public boolean dispatchTouchEvent(@android.annotation.NonNull MotionEvent ev) {
        //先交給Dialog可見(jiàn)區(qū)域處理
        if (mWindow.superDispatchTouchEvent(ev)) {
            return true;
        }
        //事件沒(méi)消費(fèi),繼續(xù)處理
        return onTouchEvent(ev);
    }

    public boolean onTouchEvent(@android.annotation.NonNull MotionEvent event) {
        //shouldCloseOnTouch(xx)
        //該方法判斷是否是up事件且是否點(diǎn)擊在Dialog外部區(qū)域且是否設(shè)置了可以關(guān)閉Dialog的標(biāo)記
        //都滿足,則返回true
        if (mCancelable && mShowing && mWindow.shouldCloseOnTouch(mContext, event)) {
            //符合條件,則關(guān)閉Dialog
            cancel();
            return true;
        }

        return false;
    }

同樣的,Dialog 實(shí)現(xiàn)了KeyEvent.Callback,重寫(xiě)方法里對(duì)key事件做處理

#Dialog.java
    public boolean dispatchKeyEvent(@android.annotation.NonNull KeyEvent event) {
        if ((mOnKeyListener != null) && (mOnKeyListener.onKey(this, event.getKeyCode(), event))) {
            return true;
        }
        //可見(jiàn)區(qū)域做處理
        if (mWindow.superDispatchKeyEvent(event)) {
            return true;
        }
        //繼續(xù)分發(fā)
        return event.dispatch(this, mDecor != null
                ? mDecor.getKeyDispatcherState() : null, this);
    }

    public boolean onKeyUp(int keyCode, @android.annotation.NonNull KeyEvent event) {
        if ((keyCode == KeyEvent.KEYCODE_BACK || keyCode == KeyEvent.KEYCODE_ESCAPE)
                && event.isTracking()
                && !event.isCanceled()) {
            onBackPressed();
            return true;
        }
        return false;
    }

    public void onBackPressed() {
        //標(biāo)記生效,則移除Dialog
        if (mCancelable) {
            cancel();
        }
    }

從上面可以看出,Dialog點(diǎn)擊外部和點(diǎn)擊物理返回鍵消失需要同時(shí)滿足兩個(gè)條件,那么想要Dialog不消失,只要不滿足其中某個(gè)條件即可。實(shí)際上Dialog是根據(jù)第二個(gè)條件設(shè)置標(biāo)記位,已經(jīng)為我們封裝好了方法:
點(diǎn)擊外部不消失:

dialog.setCanceledOnTouchOutside(false);

點(diǎn)擊物理返回鍵不消失:

dialog.setCancelable(false);

值得注意的是:調(diào)用了上述方法,Dialog還是接收了事件,只是不關(guān)閉Dialog而已。事件并沒(méi)有分發(fā)到其底下的Window。

PopupWindow 事件接收
與Dialog類似,看其是否滿足兩個(gè)條件。
先來(lái)看看PopupWindow 調(diào)用棧:

showAsDropDown(xx)->createPopupLayoutParams(xx)->computeFlags(xx)

#PopupWindow.java
    private int computeFlags(int curFlags) {
        //省略
        if (!mFocusable) {
            //焦點(diǎn)功能沒(méi)開(kāi)啟,則標(biāo)記FLAG_NOT_FOCUSABLE
            //該標(biāo)記下,Window不接收其外部區(qū)域的touch事件
            //也不接收key事件
            curFlags |= WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE;
            if (mInputMethodMode == INPUT_METHOD_NEEDED) {
                //鍵盤(pán)相關(guān)
                curFlags |= WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM;
            }
        } else if (mInputMethodMode == INPUT_METHOD_NOT_NEEDED) {
            curFlags |= WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM;
        }
        //省略
    }

computeFlags(xx)計(jì)算WindowManager.LayoutParams.flags的值。PopupWindow是否接收事件取決于"mFocusable",在我們的demo里并沒(méi)有對(duì)該值進(jìn)行設(shè)置,默認(rèn)為false,因此PopupWindow不能接收外部點(diǎn)擊事件與key事件,當(dāng)然也就不能處理是否關(guān)閉PopupWindow的邏輯了。
而"mFocusable"字段的賦值可以在PopupWindow構(gòu)造函數(shù)里指定或者調(diào)用

public void setFocusable(boolean focusable)

當(dāng)指定focusable=true時(shí),PopupWindow就能接收touch/key事件了,PopupDecorView 負(fù)責(zé)接收事件處理:

    @Override
    public boolean dispatchTouchEvent(MotionEvent ev) {
        //onTouch 優(yōu)先執(zhí)行
        if (mTouchInterceptor != null && mTouchInterceptor.onTouch(this, ev)) {
            return true;
        }
        return super.dispatchTouchEvent(ev);
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        final int x = (int) event.getX();
        final int y = (int) event.getY();

        //接收Down事件關(guān)閉
        if ((event.getAction() == MotionEvent.ACTION_DOWN)
                && ((x < 0) || (x >= getWidth()) || (y < 0) || (y >= getHeight()))) {
            dismiss();
            return true;
        } else if (event.getAction() == MotionEvent.ACTION_OUTSIDE) {
            //另一類事件
            dismiss();
            return true;
        } else {
            return super.onTouchEvent(event);
        }
    }

key事件差不多,此處略過(guò)。
總結(jié)來(lái)說(shuō):

設(shè)置focusable為true即可點(diǎn)擊外部消失PopupWindow,反之則不消失

網(wǎng)上一些文章說(shuō)的是PopupWindow 會(huì)阻塞程序,這種觀點(diǎn)是錯(cuò)誤的。實(shí)際上是下一層的Window(Activity)沒(méi)有接收到事件,當(dāng)然不會(huì)做任何處理了

Toast 事件接收
Toast 一般用來(lái)定時(shí)展示一個(gè)文本,因此一般無(wú)需接收事件。
在Toast 構(gòu)造函數(shù)里,會(huì)構(gòu)造TN對(duì)象,該對(duì)象里初始化WindowManager.LayoutParams.flags參數(shù):

    TN(String packageName, @android.annotation.Nullable Looper looper) {
        final WindowManager.LayoutParams params = mParams;
        //省略
        params.setTitle("Toast");
        //設(shè)置不接收外部的touch事件和key事件
        params.flags = WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON
                | WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
                | WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE;
        //省略
    }

關(guān)于Window touch/key 事件詳細(xì)字段內(nèi)容請(qǐng)移步:Window/WindowManager 不可不知之事
本篇只說(shuō)明設(shè)置了哪些參數(shù)。

啟動(dòng)Dialog/PopupWindow/Toast 所需的Context限制

請(qǐng)移步:Android各種Context的前世今生

Window 動(dòng)畫(huà)

控制Window 動(dòng)畫(huà)的字段是:

WindowManager.LayoutParams.windowAnimations

Dialog 動(dòng)畫(huà)
Dialog 默認(rèn)動(dòng)畫(huà):

    <style name="Animation.Dialog">
        <item name="windowEnterAnimation">@anim/dialog_enter</item>
        <item name="windowExitAnimation">@anim/dialog_exit</item>
    </style>

替換Dialog默認(rèn)動(dòng)畫(huà),定義Style

    <style name="myAnim">
        <item name="android:windowEnterAnimation">@anim/myanim</item>
    </style>

    <style name="myDialog" parent="myTheme">
        <item name="android:windowAnimationStyle">@style/myAnim</item>
    </style>

Dialog 構(gòu)造函數(shù)引用該Style。
當(dāng)然也可以單獨(dú)設(shè)置

dialog.getWindow().getAttributes().windowAnimations = R.style.myAnim;

PopupWindow 動(dòng)畫(huà)
PopupWindow 默認(rèn)沒(méi)有動(dòng)畫(huà),其加載動(dòng)畫(huà)時(shí)機(jī):

createPopupLayoutParams(xx)->computeAnimationResource(xx)

在外部指定其動(dòng)畫(huà):

    public void setAnimationStyle(int animationStyle) {
        mAnimationStyle = animationStyle;
    }

popupWindow.setAnimationStyle(R.style.myAnim);

Toast 動(dòng)畫(huà)
在Toast.TN的構(gòu)造函數(shù)里,有默認(rèn)動(dòng)畫(huà):

params.windowAnimations = com.android.internal.R.style.Animation_Toast;
    <style name="Animation.Toast">
        <item name="windowEnterAnimation">@anim/toast_enter</item>
        <item name="windowExitAnimation">@anim/toast_exit</item>
    </style>

Toast 沒(méi)有提供對(duì)外接口設(shè)置Window動(dòng)畫(huà)。

Dialog/PopupWindow/Toast 使用場(chǎng)合

從上邊分析可以看出,造成Window表現(xiàn)差異的實(shí)際上就是WindowManager.LayoutParams 參數(shù)的差異。因此重點(diǎn)是我們能否拿到WindowManager.LayoutParams對(duì)象。
對(duì)于Dialog:

可以通過(guò)dialog.getWindow().getAttributes() 獲取WindowManager.LayoutParams對(duì)象,對(duì)象獲取到了那么里邊的各種參數(shù)就可以設(shè)置了。
需要注意的是:setContentView(xx)可能會(huì)重置LayoutParams里的一些參數(shù),因此一般我們更改LayoutParams參數(shù)最好在setContentView(xx)之后。

對(duì)于PopupWindow/Toast
這兩者并沒(méi)有提供方法獲取WindowManager.LayoutParams對(duì)象,僅僅提供一些方法單獨(dú)設(shè)置WindowManager.LayoutParams對(duì)象里的一些變量。比如設(shè)置Window的位置、設(shè)置touch/key 事件接收、動(dòng)畫(huà)等。

使用建議

1、對(duì)于想要設(shè)置背景蒙層的,建議使用Dialog。PopupWindow/Toast并沒(méi)有提供方法設(shè)置該參數(shù)
2、對(duì)于想要基于某個(gè)錨點(diǎn)(View)位置展示W(wǎng)indow的,建議使用PopupWindow。當(dāng)然Dialog/Toast也是可以指定位置,只是PopupWindow已經(jīng)將這套封裝了,不用重復(fù)造輪子
3、對(duì)于想要監(jiān)聽(tīng)外部touch/key 事件的,建議使用Dialog;Dialog重寫(xiě)touch/key比較方便。
4、對(duì)于想要簡(jiǎn)單彈出提示,并且有時(shí)長(zhǎng)限制的,建議使用Toast。

如若對(duì)Dialog/PopupWindow/Toast 都不能解決你的需求,那就更容易了。這三者都是封裝了WindowManager的操作,我們直接使用原生的WindowManager,能拿到所有參數(shù),想要啥效果都可以設(shè)置。

Dialog/PopupWindow/Toast 默認(rèn)動(dòng)畫(huà)都是用了系統(tǒng)的屬性,對(duì)styleable/style/attr 有疑問(wèn)的,請(qǐng)移步:
全網(wǎng)最深入 Android Style/Theme/Attr/Styleable/TypedArray 清清楚楚明明白白

本文源碼基于Android 10.0

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

推薦閱讀更多精彩內(nèi)容