前言

PopupWindow 和 Dialog 对于移动端来说都太平常了。 是最基础的控件, 所以对其的了解还是非常有必要的。

基本使用

1
2
3
4
5
6
7
class MyPopupWindow(context: Context) : PopupWindow(context) {
init {
contentView = View(context)
}
}

MyPopWindow(this).showAsDropDown(view)

不做太多的代码, 上面的代码足矣我们分析其流程。 所以我们对其进行分析下。

源码分析

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
35
36
37
38
39
40
41
42
43
44
public PopupWindow(Context context) {
this(context, null);
}

public PopupWindow(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
mContext = context;
mWindowManager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);

final TypedArray a = context.obtainStyledAttributes(
attrs, R.styleable.PopupWindow, defStyleAttr, defStyleRes);
final Drawable bg = a.getDrawable(R.styleable.PopupWindow_popupBackground);
mElevation = a.getDimension(R.styleable.PopupWindow_popupElevation, 0);
mOverlapAnchor = a.getBoolean(R.styleable.PopupWindow_overlapAnchor, false);

// Preserve default behavior from Gingerbread. If the animation is
// undefined or explicitly specifies the Gingerbread animation style,
// use a sentinel value.
if (a.hasValueOrEmpty(R.styleable.PopupWindow_popupAnimationStyle)) {
final int animStyle = a.getResourceId(R.styleable.PopupWindow_popupAnimationStyle, 0);
if (animStyle == R.style.Animation_PopupWindow) {
mAnimationStyle = ANIMATION_STYLE_DEFAULT;
} else {
mAnimationStyle = animStyle;
}
} else {
mAnimationStyle = ANIMATION_STYLE_DEFAULT;
}

final Transition enterTransition = getTransition(a.getResourceId(
R.styleable.PopupWindow_popupEnterTransition, 0));
final Transition exitTransition;
if (a.hasValueOrEmpty(R.styleable.PopupWindow_popupExitTransition)) {
exitTransition = getTransition(a.getResourceId(
R.styleable.PopupWindow_popupExitTransition, 0));
} else {
exitTransition = enterTransition == null ? null : enterTransition.clone();
}

a.recycle();

setEnterTransition(enterTransition);
setExitTransition(exitTransition);
setBackgroundDrawable(bg);
}

和 Dialog 中的一样, PopupWindow 也是持有 WMS。 那设置布局等也会跟 Dialog 一样吗? 我们接着看下 show 相关的代码。

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
public void showAsDropDown(View anchor) {
showAsDropDown(anchor, 0, 0);
}

public void showAsDropDown(View anchor, int xoff, int yoff) {
showAsDropDown(anchor, xoff, yoff, DEFAULT_ANCHORED_GRAVITY);
}

public void showAsDropDown(View anchor, int xoff, int yoff, int gravity) {
if (isShowing() || !hasContentView()) {
return;
}

TransitionManager.endTransitions(mDecorView);

attachToAnchor(anchor, xoff, yoff, gravity);

mIsShowing = true;
mIsDropdown = true;

final WindowManager.LayoutParams p =
createPopupLayoutParams(anchor.getApplicationWindowToken());
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;

invokePopup(p);
}

其中 attachToAnchor 是对相对的 view 做一些记录跟踪及保存。 中间会设置一些监听, 比如相对于的 view 发生了改变, 那么当前 popupWindow 也会跟着发生改变。 来我们来看下其实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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;
}

看着很清晰了, 先移出之前的 anchor, 然后在进行设置保存记录。
我们记着往下看。

1
2
3
4
5
6
7
8
9
10
11
private void preparePopup(WindowManager.LayoutParams p) {
if (mBackground != null) {
mBackgroundView = createBackgroundView(mContentView);
mBackgroundView.setBackground(mBackground);
} else {
mBackgroundView = mContentView;
}

mDecorView = createDecorView(mBackgroundView);
mDecorView.setIsRootNamespace(true);
}

看看我们看到了什么, 看到 DecorView, 还记得这个名词吧, 在 Activity 和 Dialog 是在 PhoneWindow 里创建的, 而 PopWindow 是在自身内部创建的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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;
}

我们从 createDecorView 可以发现一个事情哈, 就是我们设置的布局不再是像 Activity 和 Dialog 一样, 添加到 id 为 content 中了, 而是直接 addView 添加了我们设置的布局。
我们可以看到 mDecorView 是 PopupDecorView。

1
2
3
4
5
6
7
8
9
10
11
12
13
private class PopupDecorView extends FrameLayout {

public boolean dispatchKeyEvent(KeyEvent event) {
}

public boolean dispatchTouchEvent(MotionEvent ev) {
}

public boolean onTouchEvent(MotionEvent event) {
}

// ......
}

我们可以看到 PopupDecorView 是一个 FrameLayout, 其内部对事件进行了处理或者分发。
ok, 当这里, 我们还没有看到 View 添加到 WMS 进行管理显示呢, 我们接着往下看。
我们先看下 findDropDownPosition 方法。 这个方法在 invokePopup 之前。

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
protected boolean findDropDownPosition(View anchor, WindowManager.LayoutParams outParams,
int xOffset, int yOffset, int width, int height, int gravity, boolean allowScroll) {
final int anchorHeight = anchor.getHeight();
final int anchorWidth = anchor.getWidth();
if (mOverlapAnchor) {
yOffset -= anchorHeight;
}

// Initially, align to the bottom-left corner of the anchor plus offsets.
final int[] appScreenLocation = mTmpAppLocation;
final View appRootView = getAppRootView(anchor);
appRootView.getLocationOnScreen(appScreenLocation);

final int[] screenLocation = mTmpScreenLocation;
anchor.getLocationOnScreen(screenLocation);

final int[] drawingLocation = mTmpDrawingLocation;
drawingLocation[0] = screenLocation[0] - appScreenLocation[0];
drawingLocation[1] = screenLocation[1] - appScreenLocation[1];
outParams.x = drawingLocation[0] + xOffset;
outParams.y = drawingLocation[1] + anchorHeight + yOffset;

final Rect displayFrame = new Rect();
appRootView.getWindowVisibleDisplayFrame(displayFrame);
if (width == MATCH_PARENT) {
width = displayFrame.right - displayFrame.left;
}
if (height == MATCH_PARENT) {
height = displayFrame.bottom - displayFrame.top;
}

// Let the window manager know to align the top to y.
outParams.gravity = computeGravity();
outParams.width = width;
outParams.height = height;

// If we need to adjust for gravity RIGHT, align to the bottom-right
// corner of the anchor (still accounting for offsets).
final int hgrav = Gravity.getAbsoluteGravity(gravity, anchor.getLayoutDirection())
& Gravity.HORIZONTAL_GRAVITY_MASK;
if (hgrav == Gravity.RIGHT) {
outParams.x -= width - anchorWidth;
}

// First, attempt to fit the popup vertically without resizing.
final boolean fitsVertical = tryFitVertical(outParams, yOffset, height,
anchorHeight, drawingLocation[1], screenLocation[1], displayFrame.top,
displayFrame.bottom, false);

// Next, attempt to fit the popup horizontally without resizing.
final boolean fitsHorizontal = tryFitHorizontal(outParams, xOffset, width,
anchorWidth, drawingLocation[0], screenLocation[0], displayFrame.left,
displayFrame.right, false);

// If the popup still doesn't fit, attempt to scroll the parent.
if (!fitsVertical || !fitsHorizontal) {
final int scrollX = anchor.getScrollX();
final int scrollY = anchor.getScrollY();
final Rect r = new Rect(scrollX, scrollY, scrollX + width + xOffset,
scrollY + height + anchorHeight + yOffset);
if (allowScroll && anchor.requestRectangleOnScreen(r, true)) {
// Reset for the new anchor position.
anchor.getLocationOnScreen(screenLocation);
drawingLocation[0] = screenLocation[0] - appScreenLocation[0];
drawingLocation[1] = screenLocation[1] - appScreenLocation[1];
outParams.x = drawingLocation[0] + xOffset;
outParams.y = drawingLocation[1] + anchorHeight + yOffset;

// Preserve the gravity adjustment.
if (hgrav == Gravity.RIGHT) {
outParams.x -= width - anchorWidth;
}
}

// Try to fit the popup again and allowing resizing.
tryFitVertical(outParams, yOffset, height, anchorHeight, drawingLocation[1],
screenLocation[1], displayFrame.top, displayFrame.bottom, mClipToScreen);
tryFitHorizontal(outParams, xOffset, width, anchorWidth, drawingLocation[0],
screenLocation[0], displayFrame.left, displayFrame.right, mClipToScreen);
}

// Return whether the popup's top edge is above the anchor's top edge.
return outParams.y < drawingLocation[1];
}

可以看到该方法根据传入的偏移等数据计算和更新了当前 popupWindow 的一些 x、 y 等数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private void invokePopup(WindowManager.LayoutParams p) {
if (mContext != null) {
p.packageName = mContext.getPackageName();
}

final PopupDecorView decorView = mDecorView;
decorView.setFitsSystemWindows(mLayoutInsetDecor);

setLayoutDirectionFromAnchor();

mWindowManager.addView(decorView, p);

if (mEnterTransition != null) {
decorView.requestEnterTransition(mEnterTransition);
}
}

终于看到 WMS 添加 view 了。WMS 添加 view 的操作跟 Activity 和 Dialog 的一样, 这里不再多说了。 你可能会发现, 跟 Activity 和 Dialog 中间差了一个 window 层, 在具体点就是 popupWIndow 没有 PhoneWindow。
show 方法看过了, 我们看下 dismiss 方法。

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
public void dismiss() {
if (!isShowing() || isTransitioningToDismiss()) {
return;
}

final PopupDecorView decorView = mDecorView;
final View contentView = mContentView;

final ViewGroup contentHolder;
final ViewParent contentParent = contentView.getParent();
if (contentParent instanceof ViewGroup) {
contentHolder = ((ViewGroup) contentParent);
} else {
contentHolder = null;
}

// Ensure any ongoing or pending transitions are canceled.
decorView.cancelTransitions();

mIsShowing = false;
mIsTransitioningToDismiss = true;

// This method may be called as part of window detachment, in which
// case the anchor view (and its root) will still return true from
// isAttachedToWindow() during execution of this method; however, we
// can expect the OnAttachStateChangeListener to have been called prior
// to executing this method, so we can rely on that instead.
final Transition exitTransition = mExitTransition;
if (exitTransition != null && decorView.isLaidOut()
&& (mIsAnchorRootAttached || mAnchorRoot == null)) {
// The decor view is non-interactive and non-IME-focusable during exit transitions.
final LayoutParams p = (LayoutParams) decorView.getLayoutParams();
p.flags |= LayoutParams.FLAG_NOT_TOUCHABLE;
p.flags |= LayoutParams.FLAG_NOT_FOCUSABLE;
p.flags &= ~LayoutParams.FLAG_ALT_FOCUSABLE_IM;
mWindowManager.updateViewLayout(decorView, p);

final View anchorRoot = mAnchorRoot != null ? mAnchorRoot.get() : null;
final Rect epicenter = getTransitionEpicenter();

// Once we start dismissing the decor view, all state (including
// the anchor root) needs to be moved to the decor view since we
// may open another popup while it's busy exiting.
decorView.startExitTransition(exitTransition, anchorRoot, epicenter,
new TransitionListenerAdapter() {
@Override
public void onTransitionEnd(Transition transition) {
dismissImmediate(decorView, contentHolder, contentView);
}
});
} else {
dismissImmediate(decorView, contentHolder, contentView);
}

// Clears the anchor view.
detachFromAnchor();

if (mOnDismissListener != null) {
mOnDismissListener.onDismiss();
}
}

private void dismissImmediate(View decorView, ViewGroup contentHolder, View contentView) {
// If this method gets called and the decor view doesn't have a parent,
// then it was either never added or was already removed. That should
// never happen, but it's worth checking to avoid potential crashes.
if (decorView.getParent() != null) {
mWindowManager.removeViewImmediate(decorView);
}

if (contentHolder != null) {
contentHolder.removeView(contentView);
}

// This needs to stay until after all transitions have ended since we
// need the reference to cancel transitions in preparePopup().
mDecorView = null;
mBackgroundView = null;
mIsTransitioningToDismiss = false;
}

可以看到基本都是类似的, 调用 WMS 的 removeViewImmediate 方法。