PopupWindow能够说是Google坑最多的一个控件,使用PopupWindow的时候没有遇到几个坑你都很差意思说你用过它,说一个可能大多数人都遇到过的一个坑:那就是咱们想触摸PopupWindow 之外区域就隐藏PopupWindow,理论上咱们只须要调用 setOutsideTouchable(ture)设置为ture就能够了,可是实际上只设置这个属性是不行的,必须设置背景,也就是说要和setBackgroundDrawable(Drawable background)同时使用才有效,否则,点击PopupWindow之外区域是不能隐藏掉的。java
当时遇到这个坑的时候也是一脸懵逼,设不设背景跟我点击外面消失有啥关系?看了源码才知道,它是根据mBackground
这个值来判断的,若是没设置这个值,那么就不会走到dispatchEvent 方法,就处理不了dismiss事件。在Android 6.0 以上,Google源码进行了更改,去掉了mBackground
是否为null 的这个判断条件,而且在构造方法中初始化了mBackground
这个值,所以在Android 6.0以上,不用调android
setBackgroundDrawable(Drawable background)复制代码
这个方法,就能够dismiss 了。那么本篇文章将从源码的角度,分析Android 6.0以上和Android 6.0 如下,如何控制点击外部PopupWindow消失/不消失。git
这个问题在上面已经描述,在Android 6.0 之前,咱们显示出来的PopupWindow,在只设置setOutsideTouchable(ture)
的状况下,触摸PopupWindow之外区域是不能dismiss掉的(6.0之后已经能够了)。必须同时设置BackgroundDrawable,才能dismiss掉,之前可能咱们找到了解决办法,咱们就没有管形成它的缘由,那么今天就一块儿看一下源码为何会这样。从显示PopupWindow的方法为入口,源码分析以下(源码为API 21 版本):github
在showAsDropDown()方法 中调用了一个preparePopup(p)
方法,咱们看一下这个方法中作了什么,以下:微信
注意这个方法中,有一个判断条件是mBackground != null
,在里面包装了一个PopupViewContainer
,我在再去看一下这个PopupViewContainer
又干了什么,以下:(部分源码)
ide
PopupViewContainer
其实就处理了PopupWindow的事件分发,在onTouch
方法里面,若是点击PopupWindow以外的区域,先dismiss,而后消费掉了事件。源码分析
重点就在这儿了,前面在
preparePopup
方法中,判断了,只有当mBackground不为null,才包装了PopupViewContainer,处理了事件,在点击 popupWindow外部的时候,会dismiss。而mBackground
这个值只有在setBackgroundDrawable()这一个地方初始化的,所以必须调用setBackgroundDrawable方法设置了mBackground
不为null,才能点击PopupWindow外部关闭PopupWindow。这就解释了为什么Android 6.0 如下要设置BackgroundDrawable 才能dismiss测试
在咱们使用PopupWindow的时候,咱们可能有这样一种需求:点击PopupWindow之外的区域,不让其消失(只能经过返回键和PopupWindow中的其余事件来DisMiss),但也不能响应页面的其余事件,也就是模态,像AlertDialog同样,只有当PopupWindow消失以后才能响应其余事件。ui
开始作这个需求的时候想得很简单:this
想到了2种方法:
1,设置setOutsideTouchable(false)
,测试事后,这种方法无效。
2,既然上面说了mBackground 这个属性为null的时候,点击popupWindow之外区域是取消不了的,那么直接调用setBackgroundDrawable(null)
不就好了?这种方式在Android 6.0如下是取消不了,可是,页面的其余事件能够响应,也就是说没有关闭弹出的 PopupWindow的状况下,还能够响应页面其余事件。这固然不是咱们想要的效果。以下图:
上面是我开始想到2种方式,测试事后都不行,那么咱们就得找其余方法。
试了一下上面两种方式都不行以后,因而就找其余方法,第一时间进行了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的手机上运行无效呢?
找不到解决办法,就去分析一下源码了,以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的时候,就不会走到PopupDecorView
的onTouchEvent
方法,就不会dismiss。反之,返回false,则会走到onTouchEvent
方法,就会dismiss 掉PopupWindow。
最终效果以下:
上面咱们找到了方法,经过设置拦截器的方式,能够兼容Android 6.0 以上,点击PopupWindow以外的区域不消失。所以咱们就能够用一个变量来控制点击PopupWindow 之外的区域 PopupWindow的消失/不消失
CustomPopwindow地址:github.com/pinguo-zhou…
使用以下:
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)
便可。
本文从源码的角度解析了为何在Android 6.0如下,须要设置setBackgroundDrawable()
才能取消显示的PopupoWindow。和在Android 6.0之后,Google 对PopupWindow 的改动,最终经过剖析源码,找到了经过设置拦截器的方式来让Android 6.0以上版本能够点击PopupWindow 以外的区域不消失。以上,若有问题,欢迎指正。
若是你喜欢个人文章,欢迎关注个人微信公众号:Android技术杂货铺,第一时间获取有价值的Android干货文章。
微信公众号:Android技术杂货铺