1. 程式人生 > >從源碼剖析PopupWindow 兼容Android 6.0以上版本點擊外部不消失

從源碼剖析PopupWindow 兼容Android 6.0以上版本點擊外部不消失

並且 gif upd 兼容 addview 初始 一個地方 || ping

PopupWindow可以說是Google坑最多的一個控件,使用PopupWindow的時候沒有遇到幾個坑你都不好意思說你用過它,說一個可能大多數人都遇到過的一個坑:那就是我們想觸摸PopupWindow 以外區域就隱藏PopupWindow,理論上我們只需要調用 setOutsideTouchable(ture)設置為ture就可以了,但是實際上只設置這個屬性是不行的,必須設置背景,也就是說要和setBackgroundDrawable(Drawable background)同時使用才有效,不然,點擊PopupWindow以外區域是不能隱藏掉的。

當時遇到這個坑的時候也是一臉懵逼,設不設背景跟我點擊外面消失有啥關系?看了源碼才知道,它是根據mBackground

這個值來判斷的,如果沒設置這個值,那麽就不會走到dispatchEvent 方法,就處理不了dismiss事件。在Android 6.0 以上,Google源碼進行了更改,去掉了mBackground是否為null 的這個判斷條件,並且在構造方法中初始化了mBackground這個值,因此在android 6.0以上,不用調

setBackgroundDrawable(Drawable background)
  • 1
  • 1

這個方法,就可以dismiss 了。那麽本篇文章將從源碼的角度,分析Android 6.0以上和Android 6.0 以下,如何控制點擊外部PopupWindow消失/不消失。

1 . 為何Android 6.0 以下要設置BackgroundDrawable 才能dismiss

這個問題在上面已經描述,在Android 6.0 以前,我們顯示出來的PopupWindow,在只設置setOutsideTouchable(ture)的情況下,觸摸PopupWindow以外區域是不能dismiss掉的(6.0以後已經可以了)。必須同時設置BackgroundDrawable,才能dismiss掉,以前可能我們找到了解決辦法,我們就沒有管造成它的原因,那麽今天就一起看一下源碼為什麽會這樣。從顯示PopupWindow的方法為入口,源碼分析如下(源碼為API 21 版本):

在showAsDropDown()方法 中調用了一個preparePopup(p)方法,我們看一下這個方法中做了什麽,如下:

技術分享

註意這個方法中,有一個判斷條件是mBackground != null,在裏面包裝了一個PopupViewContainer,我在再去看一下這個PopupViewContainer又幹了什麽,如下:(部分源碼)
技術分享

PopupViewContainer 其實就處理了PopupWindow的事件分發,在onTouch方法裏面,如果點擊PopupWindow之外的區域,先dismiss,然後消費掉了事件。

重點就在這兒了,前面在preparePopup方法中,判斷了,只有當mBackground不為null,才包裝了PopupViewContainer,處理了事件,在點擊 popupWindow外部的時候,會dismiss。而mBackground這個值只有在setBackgroundDrawable()這一個地方初始化的,因此必須調用setBackgroundDrawable方法設置了mBackground不為null,才能點擊PopupWindow外部關閉PopupWindow。這就解釋了為何Android 6.0 以下要設置BackgroundDrawable 才能dismiss

2 . 點擊PopupWindow以外區域不讓其消失

在我們使用PopupWindow的時候,我們可能有這樣一種需求:點擊PopupWindow以外的區域,不讓其消失(只能通過返回鍵和PopupWindow中的其他事件來DisMiss),但也不能響應頁面的其他事件,也就是模態,像AlertDialog一樣,只有當PopupWindow消失之後才能響應其他事件。

開始做這個需求的時候想得很簡單:

想到了2種方法:

1,設置setOutsideTouchable(false),測試過後,這種方法無效。

2,既然上面說了mBackground 這個屬性為null的時候,點擊popupWindow以外區域是取消不了的,那麽直接調用setBackgroundDrawable(null)不就行了?這種方式在Android 6.0以下是取消不了,但是,頁面的其他事件可以響應,也就是說沒有關閉彈出的 PopupWindow的情況下,還可以響應頁面其他事件。這當然不是我們想要的效果。如下圖:
技術分享

上面是我開始想到2種方式,測試過後都不行,那麽我們就得找其他方法。

2.1 Android6.0以下 點擊PopupWindow以外區域不讓其消失

試了一下上面兩種方式都不行之後,於是就找其他方法,第一時間進行了Google,嘿,還真找到了一種方法,代碼如下:

 LayoutInflater inflater = (LayoutInflater)getSystemService(Context.LAYOUT_INFLATER_SERVICE);
        View contentview = inflater.inflate(R.layout.pop_layout1, null);
        final PopupWindow popupWindow = new PopupWindow(contentview, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
        //popupWindow
        popupWindow.setFocusable(true);
        popupWindow.setOutsideTouchable(false);
        popupWindow.setBackgroundDrawable(null);

        popupWindow.getContentView().setFocusable(true); // 這個很重要
        popupWindow.getContentView().setFocusableInTouchMode(true);
        popupWindow.getContentView().setOnKeyListener(new View.OnKeyListener() {
            @Override
            public boolean onKey(View v, int keyCode, KeyEvent event) {
                if (keyCode == KeyEvent.KEYCODE_BACK) {
                    popupWindow.dismiss();

                    return true;
                }
                return false;
            }
        });
        popupWindow.showAsDropDown(mButton1, 0, 10);
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22

這種方法就是在我前面說的方法2的基礎上,獲取PopupWindow中的contentView,並且獲取焦點,並處理返回鍵事件,在按返回鍵的時候可以取消PopupWindow。

添加上面的代碼,運行,嘿,還挺好使,可以了,心裏一陣高興。接著在Android 7.0的手機運行一把,什麽鬼?7.0上還是不起作用,點擊PopupWindow之外的地方還是會取消。試了好多方法,都不行。

上面的方法既然在Android 6.0以下可以,在Andoid 7.0手機上無效,那麽就只有看源碼了在Android 6.0以上做了什麽更改了,分析一下看源碼是怎麽處理,為什麽在5.1的手機上運行正常,而在 7.0的手機上運行無效呢?

2.2 Android6.0以上 點擊PopupWindow以外區域不讓其消失

找不到解決辦法,就去分析一下源碼了,以API 25的源碼為例分析:

1,首先看showAtLocation這個方法:

 public void showAtLocation(IBinder token, int gravity, int x, int y) {
        if (isShowing() || mContentView == null) {
            return;
        }

        TransitionManager.endTransitions(mDecorView);

        detachFromAnchor();

        mIsShowing = true;
        mIsDropdown = false;
        mGravity = gravity;

        final WindowManager.LayoutParams p = createPopupLayoutParams(token);
       // 重點在preparePopup 裏
        preparePopup(p);

        p.x = x;
        p.y = y;

        invokePopup(p);
    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22

如上,在showAtLocation方法中有一個重要的方法preparePopup

2,進入preparePopup一探究竟:

 private void preparePopup(WindowManager.LayoutParams p) {
        if (mContentView == null || mContext == null || mWindowManager == null) {
            throw new IllegalStateException("You must specify a valid content view by "
                    + "calling setContentView() before attempting to show the popup.");
        }

        // The old decor view may be transitioning out. Make sure it finishes
        // and cleans up before we try to create another one.
        if (mDecorView != null) {
            mDecorView.cancelTransitions();
        }

        // When a background is available, we embed the content view within
        // another view that owns the background drawable.
        if (mBackground != null) {
            mBackgroundView = createBackgroundView(mContentView);
            mBackgroundView.setBackground(mBackground);
        } else {
            mBackgroundView = mContentView;
        }
       // 這個方法很關鍵
        mDecorView = createDecorView(mBackgroundView);

        // The background owner should be elevated so that it casts a shadow.
        mBackgroundView.setElevation(mElevation);

        // We may wrap that in another view, so we‘ll need to manually specify
        // the surface insets.
        p.setSurfaceInsets(mBackgroundView, true /*manual*/, true /*preservePrevious*/);

        mPopupViewInitialLayoutDirectionInherited =
                (mContentView.getRawLayoutDirection() == View.LAYOUT_DIRECTION_INHERIT);
    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33

對比:其實可以對比一下API 25的源碼和前文 API 21 的源碼,在preparePopup還是有很大區別的。這個區別是從Android 6.0改動的(因此本文都以Android 6.0為界限),前面第一節分析過了,在Android 6.0之前的preparePopup方法中,在mBackgroud不為null的情況下,包裝了一個PopupViewContainer ,在PopupViewContainer裏面處理的事件分發。

而在Android 6.0以上,在這裏更改了,在createDecorView這個方法裏做了統一處理,也就是不管mBackgroud為null或者不為null,都會走到這個方法,這也就是為什麽在Android 6.0以上不用調用seteBackgroudDrawable方法也可以點擊外部dismiss的原因。

3 ,接下來重點看一下createDecorView方法:

private PopupDecorView createDecorView(View contentView) {
        final ViewGroup.LayoutParams layoutParams = mContentView.getLayoutParams();
        final int height;
        if (layoutParams != null && layoutParams.height == WRAP_CONTENT) {
            height = WRAP_CONTENT;
        } else {
            height = MATCH_PARENT;
        }
        //包裝了一個PopupDecorView,其中做了事件分發處理
        final PopupDecorView decorView = new PopupDecorView(mContext);
        decorView.addView(contentView, MATCH_PARENT, height);
        decorView.setClipChildren(false);
        decorView.setClipToPadding(false);

        return decorView;
    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16

在這個方法中給ContentView 包裝了一個PopupDecorView類,我們看一下這個類幹了什麽。

private class PopupDecorView extends FrameLayout {

       ....
      // 前面省略
        @Override
        public boolean dispatchTouchEvent(MotionEvent ev) {
           //如果設置了攔截器,將事件交給攔截器處理
            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();
            //判斷ActionDown 事件,點擊區域在PopupWindow之外,dismiss PopupWindow
            if ((event.getAction() == MotionEvent.ACTION_DOWN)
                    && ((x < 0) || (x >= getWidth()) || (y < 0) || (y >= getHeight()))) {
                dismiss();
                return true;
            } else if (event.getAction() == MotionEvent.ACTION_OUTSIDE) {
                //如果是MotionEvent.ACTION_OUTSIDE 事件, dismiss PopupWindow
                dismiss();
                return true;
            } else {
                return super.onTouchEvent(event);
            }
        }

     //後面省略
      ...
   }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34

我們可以看到在Android 6.0以前,PopupWindow的事件分發邏輯是在PopupViewContainer裏面做的,而Android 6.0以後,是放在了PopupDecorView裏面。

我們來分析一下 它的onTouch處理邏輯:

  • 判斷ActionDown 事件,點擊區域在PopupWindow之外,dismiss PopupWindow。

  • 如果是MotionEvent.ACTION_OUTSIDE 事件, dismiss PopupWindow

有了上面的兩個條件,在Android 6.0以上版本,不管怎麽樣,只要你點擊了 PopupWindow以外區域,都會符合上面的兩個條件之一。因此都會dismiss 掉PopupWindow的(要是google工程師能用一個變量來控制就好了)。因此要想在Android 6.0以上,點擊PopupWindow之外部分,PopupWindow不消失,就只有一個辦法 :事件攔截。看一下這個方法:

 @Override
        public boolean dispatchTouchEvent(MotionEvent ev) {
            if (mTouchInterceptor != null && mTouchInterceptor.onTouch(this, ev)) {
                return true;
            }
            return super.dispatchTouchEvent(ev);
        }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

重點就在dispatchTouchEvent這個方法,如果我們設置了攔截器mTouchInterceptor,就會執行攔截器的onTouch方法,並且消費掉這個事件,也就是說,事件不會再傳遞到onTouchEvent這個方法,因此就不會調用dismiss方法來取消PopupWindow。

最後解決方案:

為PopupWindow設置攔截器,代碼如下:

           //註意下面這三個是contentView 不是PopupWindow
            mPopupWindow.getContentView().setFocusable(true);
            mPopupWindow.getContentView().setFocusableInTouchMode(true);
            mPopupWindow.getContentView().setOnKeyListener(new View.OnKeyListener() {
                @Override
                public boolean onKey(View v, int keyCode, KeyEvent event) {
                    if (keyCode == KeyEvent.KEYCODE_BACK) {
                        mPopupWindow.dismiss();

                        return true;
                    }
                    return false;
                }
            });
            //在Android 6.0以上 ,只能通過攔截事件來解決
            mPopupWindow.setTouchInterceptor(new View.OnTouchListener() {
                @Override
                public boolean onTouch(View v, MotionEvent event) {

                    final int x = (int) event.getX();
                    final int y = (int) event.getY();

                    if ((event.getAction() == MotionEvent.ACTION_DOWN)
                            && ((x < 0) || (x >= mWidth) || (y < 0) || (y >= mHeight))) { 
                         // donothing
                        // 消費事件
                        return true;
                    } else if (event.getAction() == MotionEvent.ACTION_OUTSIDE) {
                        Log.e(TAG,"out side ...");
                        return true;
                    }
                    return false;
                }
            });
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34

解釋:onTouch中的判斷條件和 onTouchEvent的判斷條件保持一致就行了,在符合點擊PopupWindow外部的的兩個條件中,直接返回ture,其他則返回false。返回true的時候,就不會走到PopupDecorViewonTouchEvent方法,就不會dismiss。反之,返回false,則會走到onTouchEvent方法,就會dismiss 掉PopupWindow。

最終效果如下:

技術分享

3 . CustomPopWindow 一行代碼控制

上面我們找到了方法,通過設置攔截器的方式,可以兼容Android 6.0 以上,點擊PopupWindow之外的區域不消失。因此我們就可以用一個變量來控制點擊PopupWindow 以外的區域 PopupWindow的消失/不消失
CustomPopwindow地址:https://github.com/pinguo-zhouwei/CustomPopwindow

使用如下:

 View view = LayoutInflater.from(this).inflate(R.layout.pop_layout_close,null);
        //處理PopupWindow中的點擊事件
        View.OnClickListener listener = new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                Log.e("TAG","onClick.....");
                mPopWindow.dissmiss();
            }
        };
        view.findViewById(R.id.close_pop).setOnClickListener(listener);

        mPopWindow = new CustomPopWindow.PopupWindowBuilder(this)
                .setView(view)
                .enableOutsideTouchableDissmiss(false)// 設置點擊PopupWindow之外的地方,popWindow不關閉,如果不設置這個屬性或者為true,則關閉
                .create();

        mPopWindow.showAsDropDown(mButton7,0,10);
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
技術分享
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17

如果需要點擊PopupWindow以外區域不消失,並且像 AlertDialog一樣是模態的話,只需要配置這個方法enableOutsideTouchableDissmiss(false)即可。

4 . 總結

本文從源碼的角度解析了為什麽在Android 6.0以下,需要設置setBackgroundDrawable()才能取消顯示的PopupoWindow。和在Android 6.0以後,Google 對PopupWindow 的改動,最終通過剖析源碼,找到了通過設置攔截器的方式來讓Android 6.0以上版本可以點擊PopupWindow 之外的區域不消失

從源碼剖析PopupWindow 兼容Android 6.0以上版本點擊外部不消失