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技術雜貨鋪