本系列博客基于android-28版本
【W(wǎng)indow系列】——Toast源碼解析
【W(wǎng)indow系列】——PopupWindow的前世今生
【W(wǎng)indow系列】——Dialog源碼解析
【W(wǎng)indow系列】——Window中的Token
前言
上一篇博客分析了Toast的源碼,一提到Window必然少不了本篇博客分析的PopupWindow
酪呻,本來我以為是一樣的流程段磨,創(chuàng)建Window履植,設置View到DecorView,加入Window倒槐,完事兒...但卻發(fā)現(xiàn)PopupWindow
卻沒有按照這種實現(xiàn)方式實現(xiàn)的热幔。
大綱
本篇博客會分析一下幾點:
- PopupWindow的實現(xiàn)原理源碼
- PopupWindow關于BackgroundDrawable的版本差異導致的問題
- PopupWindow的觸摸事件處理
源碼分析
我們平時使用PopupWindow
主要涉及以下三個核心方法:
PopupWindow window = new PopupWindow();
window.setContentView(...);
window.showAsDropDown(...);
所以首先看一下構(gòu)造函數(shù)
public PopupWindow(View contentView, int width, int height, boolean focusable) {
if (contentView != null) {
mContext = contentView.getContext();
mWindowManager = (WindowManager) mContext.getSystemService(Context.WINDOW_SERVICE);
}
setContentView(contentView);
setWidth(width);
setHeight(height);
setFocusable(focusable);
}
如果在構(gòu)造函數(shù)設置了ContentView
蛉谜,那么直接獲取Context
對象和WindowManager
,調(diào)用setContentView
方法姻采,設置寬高雅采,和Focusable
,這里要注意一下Focusable
這個變量,后面會講到這個變量在PopupWindow
中的作用。
如果我們調(diào)用的是最基礎的構(gòu)造函數(shù)婚瓜,一般我們下一步會調(diào)用setContentView
方法設置我們的布局宝鼓,那么這里我們就來看一下這個方法。
public void setContentView(View contentView) {
if (isShowing()) {
return;
}
//保存ContentView
mContentView = contentView;
if (mContext == null && mContentView != null) {
mContext = mContentView.getContext();
}
if (mWindowManager == null && mContentView != null) {
mWindowManager = (WindowManager) mContext.getSystemService(Context.WINDOW_SERVICE);
}
// Setting the default for attachedInDecor based on SDK version here
// instead of in the constructor since we might not have the context
// object in the constructor. We only want to set default here if the
// app hasn't already set the attachedInDecor.
if (mContext != null && !mAttachedInDecorSet) {
// Attach popup window in decor frame of parent window by default for
// {@link Build.VERSION_CODES.LOLLIPOP_MR1} or greater. Keep current
// behavior of not attaching to decor frame for older SDKs.
setAttachedInDecor(mContext.getApplicationInfo().targetSdkVersion
>= Build.VERSION_CODES.LOLLIPOP_MR1);
}
}
可以看到巴刻,和剛才看到的構(gòu)造函數(shù)基本相同愚铡,保存了ContentView
變量后,獲取Context
和WindowManger
對象胡陪。
可以看到上面兩個步驟基本上都是做的準備工作沥寥,那么接下來看一下最核心的展示方法showAsDropDown
public void showAsDropDown(View anchor) {
showAsDropDown(anchor, 0, 0);
}
public void showAsDropDown(View anchor, int xoff, int yoff, int gravity) {
if (isShowing() || !hasContentView()) {
return;
}
TransitionManager.endTransitions(mDecorView);
//綁定監(jiān)聽,設置變量
attachToAnchor(anchor, xoff, yoff, gravity);
mIsShowing = true;
mIsDropdown = true;
//創(chuàng)建布局參數(shù)
final WindowManager.LayoutParams p =
createPopupLayoutParams(anchor.getApplicationWindowToken());
//包裹布局督弓,構(gòu)建布局層級
preparePopup(p);
final boolean aboveAnchor = findDropDownPosition(anchor, p, xoff, yoff,
p.width, p.height, gravity, mAllowScrollingAnchorParent);
updateAboveAnchor(aboveAnchor);
p.accessibilityIdOfAnchor = (anchor != null) ? anchor.getAccessibilityViewId() : -1;
//添加布局到Window中
invokePopup(p);
}
可以看到营曼,這個方法其實還是利用了重載,實現(xiàn)了很多方法愚隧,最終都是到了最后這個方法里蒂阱。
上面大概分了四部分,我分別寫了注釋狂塘,這里來單獨看一下录煤。
protected void attachToAnchor(View anchor, int xoff, int yoff, int gravity) {
detachFromAnchor();
final ViewTreeObserver vto = anchor.getViewTreeObserver();
if (vto != null) {
vto.addOnScrollChangedListener(mOnScrollChangedListener);
}
anchor.addOnAttachStateChangeListener(mOnAnchorDetachedListener);
final View anchorRoot = anchor.getRootView();
anchorRoot.addOnAttachStateChangeListener(mOnAnchorRootDetachedListener);
anchorRoot.addOnLayoutChangeListener(mOnLayoutChangeListener);
//弱引用
mAnchor = new WeakReference<>(anchor);
mAnchorRoot = new WeakReference<>(anchorRoot);
mIsAnchorRootAttached = anchorRoot.isAttachedToWindow();
mParentRootView = mAnchorRoot;
mAnchorXoff = xoff;
mAnchorYoff = yoff;
mAnchoredGravity = gravity;
}
可以看到這個方法主要是設置我們傳入到參數(shù)的,但是這里要注意的是Google在這里使用了弱引用荞胡,這個我感覺是比較少見的妈踊,目前我所了解的FrameWork層的源碼里,很少看到Google使用弱引用泪漂,這里利用弱引用保存了傳入的布局和頂層父布局廊营。
protected final WindowManager.LayoutParams createPopupLayoutParams(IBinder token) {
final WindowManager.LayoutParams p = new WindowManager.LayoutParams();
// These gravity settings put the view at the top left corner of the
// screen. The view is then positioned to the appropriate location by
// setting the x and y offsets to match the anchor's bottom-left
// corner.
p.gravity = computeGravity();
p.flags = computeFlags(p.flags);
p.type = mWindowLayoutType;
//設置Token
p.token = token;
p.softInputMode = mSoftInputMode;
//設置動畫
p.windowAnimations = computeAnimationResource();
if (mBackground != null) {
p.format = mBackground.getOpacity();
} else {
p.format = PixelFormat.TRANSLUCENT;
}
//設置寬高
if (mHeightMode < 0) {
p.height = mLastHeight = mHeightMode;
} else {
p.height = mLastHeight = mHeight;
}
if (mWidthMode < 0) {
p.width = mLastWidth = mWidthMode;
} else {
p.width = mLastWidth = mWidth;
}
p.privateFlags = PRIVATE_FLAG_WILL_NOT_REPLACE_ON_RELAUNCH
| PRIVATE_FLAG_LAYOUT_CHILD_WINDOW_IN_PARENT_FRAME;
// Used for debugging.
p.setTitle("PopupWindow:" + Integer.toHexString(hashCode()));
return p;
}
createPopupLayoutParams
是用來創(chuàng)建一個LayoutParam
,這里注重注意一下token
這個變量萝勤,看過前一篇博客的應該都記得露筒,Toast
組件也需要一個token
變量,這里這個token
可以看到是用anchor.getApplicationWindowToken()
獲取的敌卓,也就是父布局的token
慎式。關于token
后面會抽出一篇博客來專門分析一下,token
對于Window類型的影響趟径。
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.");
}
if (p.accessibilityTitle == null) {
p.accessibilityTitle = mContext.getString(R.string.popup_window_default_title);
}
// 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.
//設置Background包裹
if (mBackground != null) {
mBackgroundView = createBackgroundView(mContentView);
mBackgroundView.setBackground(mBackground);
} else {
mBackgroundView = mContentView;
}
//再用DecorView包裹
mDecorView = createDecorView(mBackgroundView);
mDecorView.setIsRootNamespace(true);
//設置elevation
// 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);
}
這個方法可以說是popupwindow
的最核心的方法了瘪吏,首先我們可以看到,對mBackgroud
變量進行了判空蜗巧,如果設置了backgroud
掌眠,則執(zhí)行createBackgroundView
方法。
private PopupBackgroundView createBackgroundView(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;
}
final PopupBackgroundView backgroundView = new PopupBackgroundView(mContext);
final PopupBackgroundView.LayoutParams listParams = new PopupBackgroundView.LayoutParams(
MATCH_PARENT, height);
backgroundView.addView(contentView, listParams);
return backgroundView;
}
這里可以看到幕屹,構(gòu)建了一個寬高相同的布局參數(shù)扇救,并且創(chuàng)建了一個PopupBackgroundView
刑枝,利用addView
方法,將我們的ContentView
包裹了起來迅腔。
private class PopupBackgroundView extends FrameLayout {
public PopupBackgroundView(Context context) {
super(context);
}
@Override
protected int[] onCreateDrawableState(int extraSpace) {
if (mAboveAnchor) {
final int[] drawableState = super.onCreateDrawableState(extraSpace + 1);
View.mergeDrawableStates(drawableState, ABOVE_ANCHOR_STATE_SET);
return drawableState;
} else {
return super.onCreateDrawableState(extraSpace);
}
}
}
這里的PopupBackgroundView
其實就是一個FrameLayout
装畅,單純的只是為了設置Backgroud
。
接下來執(zhí)行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;
}
final PopupDecorView decorView = new PopupDecorView(mContext);
decorView.addView(contentView, MATCH_PARENT, height);
decorView.setClipChildren(false);
decorView.setClipToPadding(false);
return decorView;
}
可以看到和剛才大同小異掠兄,哪這回為什么又要包裹一層呢?這里就要看一下PopupDecorView
private class PopupDecorView extends FrameLayout {
/** Runnable used to clean up listeners after exit transition. */
private Runnable mCleanupAfterExit;
public PopupDecorView(Context context) {
super(context);
}
@Override
public boolean dispatchKeyEvent(KeyEvent event) {
//對返回鍵做了特殊處理
if (event.getKeyCode() == KeyEvent.KEYCODE_BACK) {
if (getKeyDispatcherState() == null) {
return super.dispatchKeyEvent(event);
}
if (event.getAction() == KeyEvent.ACTION_DOWN && event.getRepeatCount() == 0) {
final KeyEvent.DispatcherState state = getKeyDispatcherState();
if (state != null) {
state.startTracking(event, this);
}
return true;
} else if (event.getAction() == KeyEvent.ACTION_UP) {
final KeyEvent.DispatcherState state = getKeyDispatcherState();
if (state != null && state.isTracking(event) && !event.isCanceled()) {
dismiss();
return true;
}
}
return super.dispatchKeyEvent(event);
} else {
return super.dispatchKeyEvent(event);
}
}
@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();
if ((event.getAction() == MotionEvent.ACTION_DOWN)
&& ((x < 0) || (x >= getWidth()) || (y < 0) || (y >= getHeight()))) {
//觸摸位置在外部锌雀,則直接dismiss()
dismiss();
return true;
} else if (event.getAction() == MotionEvent.ACTION_OUTSIDE) {
dismiss();
return true;
} else {
return super.onTouchEvent(event);
}
}
...
}
這里就內(nèi)容很多了蚂夕,首先這個還是一個繼承了FrameLayout
的布局,唯一不同的是腋逆,這里重寫了兩個關鍵方法dispatchKeyEvent
和onTouchEvent
婿牍,所以我們應該知道這里對鍵盤事件和觸摸事件做了特殊處理,當是返回鍵時或者觸摸位置在View的外部的時候則調(diào)用dismiss()
方法惩歉。
這也就是為什么Popupwindow點擊外部可以消失的原因等脂,也就是觸摸事件處理。
這里還有一個地方值得我們注意
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
if (mTouchInterceptor != null && mTouchInterceptor.onTouch(this, ev)) {
return true;
}
return super.dispatchTouchEvent(ev);
}
可以看到這里還重寫了dispatchTouchEvent
方法撑蚌,熟悉Android事件分發(fā)流程的應該清楚上遥,這里是事件分發(fā)的頂層,這里多出了一個mTouchInterceptor
這個概念争涌,其實就是一個攔截器粉楚,也就是說,對于PopupWindow
亮垫,我們是可以自定義事件的處理的模软。
做完這所有的準備后,就是最后一個方法了饮潦。
private void invokePopup(WindowManager.LayoutParams p) {
if (mContext != null) {
p.packageName = mContext.getPackageName();
}
final PopupDecorView decorView = mDecorView;
decorView.setFitsSystemWindows(mLayoutInsetDecor);
setLayoutDirectionFromAnchor();
//通過WindowManger加入View
mWindowManager.addView(decorView, p);
if (mEnterTransition != null) {
decorView.requestEnterTransition(mEnterTransition);
}
}
終于看到了最核心的顯示方法燃异,我們可以確定PopupWindow
是通過WindowManger
的addView
方法加入的『蓿可以發(fā)現(xiàn)特铝,其實PopupWindow并沒有重新創(chuàng)建新的Window,而是在當前Window上暑中,利用WindowManger.addView
加入的壹瘟。,這可以說就是PopupWindow
的顯示原理鳄逾。
PopupWindow關于BackgroundDrawable的版本差異導致的問題
最開始學習PopupWindow
的使用方法的時候稻轨,我們經(jīng)常會看到這樣的一個注釋。
// 如果不設置PopupWindow的背景,就會出現(xiàn)一個問題:無論是點擊外部區(qū)域還是Back鍵都無法dismiss彈框
popupWindow.setBackgroundDrawable(new ColorDrawable());
通過上面的源碼分析雕凹,我們本沒有發(fā)現(xiàn)BackgroundDrawable
會有這么大的影響殴俱,只是單純的印象一個包裝View的背景政冻,這里就要說一下PopupWindow
的版本差異了,本篇博客是基于android-28
,通過源碼我們能知道backgrounddrawable
不會有這樣的影響线欲。但是我們來看一下Android4.2.2
的源碼
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.");
}
if (mBackground != null) {
final ViewGroup.LayoutParams layoutParams = mContentView.getLayoutParams();
int height = ViewGroup.LayoutParams.MATCH_PARENT;
if (layoutParams != null &&
layoutParams.height == ViewGroup.LayoutParams.WRAP_CONTENT) {
height = ViewGroup.LayoutParams.WRAP_CONTENT;
}
// when a background is available, we embed the content view
// within another view that owns the background drawable
PopupViewContainer popupViewContainer = new PopupViewContainer(mContext);
PopupViewContainer.LayoutParams listParams = new PopupViewContainer.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, height
);
popupViewContainer.setBackgroundDrawable(mBackground);
popupViewContainer.addView(mContentView, listParams);
mPopupView = popupViewContainer;
} else {
mPopupView = mContentView;
}
mPopupViewInitialLayoutDirectionInherited =
(mPopupView.getRawLayoutDirection() == View.LAYOUT_DIRECTION_INHERIT);
mPopupWidth = p.width;
mPopupHeight = p.height;
}
可以看到這里在preparePopup
方法里明场,就有了不同,這里如果設置了mBackground
就會使用PopupViewContainer
保存李丰。
private class PopupViewContainer extends FrameLayout {
1542 private static final String TAG = "PopupWindow.PopupViewContainer";
1543
1544 public PopupViewContainer(Context context) {
1545 super(context);
1546 }
1547
1548 @Override
1549 protected int[] onCreateDrawableState(int extraSpace) {
1550 if (mAboveAnchor) {
1551 // 1 more needed for the above anchor state
1552 final int[] drawableState = super.onCreateDrawableState(extraSpace + 1);
1553 View.mergeDrawableStates(drawableState, ABOVE_ANCHOR_STATE_SET);
1554 return drawableState;
1555 } else {
1556 return super.onCreateDrawableState(extraSpace);
1557 }
1558 }
1559
1560 @Override
1561 public boolean dispatchKeyEvent(KeyEvent event) {
1562 if (event.getKeyCode() == KeyEvent.KEYCODE_BACK) {
1563 if (getKeyDispatcherState() == null) {
1564 return super.dispatchKeyEvent(event);
1565 }
1566
1567 if (event.getAction() == KeyEvent.ACTION_DOWN
1568 && event.getRepeatCount() == 0) {
1569 KeyEvent.DispatcherState state = getKeyDispatcherState();
1570 if (state != null) {
1571 state.startTracking(event, this);
1572 }
1573 return true;
1574 } else if (event.getAction() == KeyEvent.ACTION_UP) {
1575 KeyEvent.DispatcherState state = getKeyDispatcherState();
1576 if (state != null && state.isTracking(event) && !event.isCanceled()) {
1577 dismiss();
1578 return true;
1579 }
1580 }
1581 return super.dispatchKeyEvent(event);
1582 } else {
1583 return super.dispatchKeyEvent(event);
1584 }
1585 }
1586
1587 @Override
1588 public boolean dispatchTouchEvent(MotionEvent ev) {
1589 if (mTouchInterceptor != null && mTouchInterceptor.onTouch(this, ev)) {
1590 return true;
1591 }
1592 return super.dispatchTouchEvent(ev);
1593 }
1594
1595 @Override
1596 public boolean onTouchEvent(MotionEvent event) {
1597 final int x = (int) event.getX();
1598 final int y = (int) event.getY();
1599
1600 if ((event.getAction() == MotionEvent.ACTION_DOWN)
1601 && ((x < 0) || (x >= getWidth()) || (y < 0) || (y >= getHeight()))) {
1602 dismiss();
1603 return true;
1604 } else if (event.getAction() == MotionEvent.ACTION_OUTSIDE) {
1605 dismiss();
1606 return true;
1607 } else {
1608 return super.onTouchEvent(event);
1609 }
1610 }
1611
1612 @Override
1613 public void sendAccessibilityEvent(int eventType) {
1614 // clinets are interested in the content not the container, make it event source
1615 if (mContentView != null) {
1616 mContentView.sendAccessibilityEvent(eventType);
1617 } else {
1618 super.sendAccessibilityEvent(eventType);
1619 }
1620 }
1621 }
1622
可以看到苦锨,這里就直接處理的鍵盤事件和觸摸事件,那么就意味著如果我們沒有設置Background
那么在低版本的情況下將會出現(xiàn)無法點擊外部消失這個功能趴泌,雖然后面的修復了這個問題舟舒,但是Google也留了一個很大的坑啊,而且為了包裝Background
在展示上的一致性嗜憔,在高版本無奈只能選擇使用兩次包裹來實現(xiàn)秃励,也是費盡心思了。吉捶。夺鲜。
總結(jié)
本篇博客主要分析了PopupWindow
的實現(xiàn)原理,總的來看帚稠,PopupWindow主要是以下幾個步驟:
- 設置ContentView
- 利用自定義View包裹我們的ContentView谣旁,自定義View重寫了鍵盤事件和觸摸事件分發(fā),實現(xiàn)了點擊外部消失
- 最終利用WindowManger的addView加入布局滋早,并沒有創(chuàng)建新的Window