从源码剖析PopupWindow 兼容Android 6.0以上版本点击外部不消失

前言

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)

这个方法,就可以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的情况下,还可以响应页面其他事件。这当然不是我们想要的效果。如下图:

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);

这种方法就是在我前面说的方法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);
    }

如上,在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);
    }

对比:其实可以对比一下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;
    }

在这个方法中给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);
            }
        }

     //后面省略
      ...
   }

我们可以看到在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);
        }

重点就在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;
                }
            });

解释:onTouch中的判断条件和 onTouchEvent的判断条件保持一致就行了,在符合点击PopupWindow外部的的两个条件中,直接返回ture,其他则返回false。返回true的时候,就不会走到PopupDecorViewonTouchEvent方法,就不会dismiss。反之,返回false,则会走到onTouchEvent方法,就会dismiss 掉PopupWindow。

最终效果如下:

左边为5.1,右边为7.0

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);

如果需要点击PopupWindow以外区域不消失,并且像 AlertDialog一样是模态的话,只需要配置这个方法enableOutsideTouchableDissmiss(false)即可。

4 . 总结

本文从源码的角度解析了为什么在Android 6.0以下,需要设置setBackgroundDrawable()才能取消显示的PopupoWindow。和在Android 6.0以后,Google 对PopupWindow 的改动,最终通过剖析源码,找到了通过设置拦截器的方式来让Android 6.0以上版本可以点击PopupWindow 之外的区域不消失。以上,如有问题,欢迎指正。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,324评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,303评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,192评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,555评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,569评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,566评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,927评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,583评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,827评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,590评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,669评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,365评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,941评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,928评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,159评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,880评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,399评论 2 342

推荐阅读更多精彩内容

  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,392评论 25 707
  • ¥开启¥ 【iAPP实现进入界面执行逐一显】 〖2017-08-25 15:22:14〗 《//首先开一个线程,因...
    小菜c阅读 6,350评论 0 17
  • 在Android中弹出式菜单(以下称弹窗)是使用十分广泛一种菜单呈现的方式,弹窗为用户交互提供了便利。关于弹窗的实...
    OzanShareing阅读 3,097评论 2 10
  • afinalAfinal是一个android的ioc,orm框架 https://github.com/yangf...
    passiontim阅读 15,394评论 2 45
  • 2017-04-05毛毛虫蝶变第15天 【今日学习】 妈妈读《易经》1-8卦、《黄帝内经》素问上古天真论篇第一、《...
    王海艳阅读 314评论 0 1