0%

关于ListPopupWindow的问题分析

前言

最近做需求,使用了ListPopupWindow,但是发现在ListView列表中,显示PopupWindow时,有可能会往上滚动(见下图),觉得有点奇怪,就花了点时间去找原因。

分析过程

从哪里入手呢?当然是一步一步来,从ListPopupWindow.show()源码开始看起

ListPopupWindow.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
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
/**
* Show the popup list. If the list is already showing, this method
* will recalculate the popup's size and position.
*/
public void show() {
int height = buildDropDown();
boolean noInputMethod = isInputMethodNotNeeded();
PopupWindowCompat.setWindowLayoutType(mPopup, mDropDownWindowLayoutType);
if (mPopup.isShowing()) {
final int widthSpec;
if (mDropDownWidth == ViewGroup.LayoutParams.MATCH_PARENT) {
// The call to PopupWindow's update method below can accept -1 for any
// value you do not want to update.
widthSpec = -1;
} else if (mDropDownWidth == ViewGroup.LayoutParams.WRAP_CONTENT) {
widthSpec = getAnchorView().getWidth();
} else {
widthSpec = mDropDownWidth;
}
final int heightSpec;
if (mDropDownHeight == ViewGroup.LayoutParams.MATCH_PARENT) {
// The call to PopupWindow's update method below can accept -1 for any
// value you do not want to update.
heightSpec = noInputMethod ? height : ViewGroup.LayoutParams.MATCH_PARENT;
if (noInputMethod) {
mPopup.setWidth(mDropDownWidth == ViewGroup.LayoutParams.MATCH_PARENT ?
ViewGroup.LayoutParams.MATCH_PARENT : 0);
mPopup.setHeight(0);
} else {
mPopup.setWidth(mDropDownWidth == ViewGroup.LayoutParams.MATCH_PARENT ?
ViewGroup.LayoutParams.MATCH_PARENT : 0);
mPopup.setHeight(ViewGroup.LayoutParams.MATCH_PARENT);
}
} else if (mDropDownHeight == ViewGroup.LayoutParams.WRAP_CONTENT) {
heightSpec = height;
} else {
heightSpec = mDropDownHeight;
}
mPopup.setOutsideTouchable(!mForceIgnoreOutsideTouch && !mDropDownAlwaysVisible);
mPopup.update(getAnchorView(), mDropDownHorizontalOffset,
mDropDownVerticalOffset, (widthSpec < 0)? -1 : widthSpec,
(heightSpec < 0)? -1 : heightSpec);
} else {
final int widthSpec;
if (mDropDownWidth == ViewGroup.LayoutParams.MATCH_PARENT) {
widthSpec = ViewGroup.LayoutParams.MATCH_PARENT;
} else {
if (mDropDownWidth == ViewGroup.LayoutParams.WRAP_CONTENT) {
widthSpec = getAnchorView().getWidth();
} else {
widthSpec = mDropDownWidth;
}
}
final int heightSpec;
if (mDropDownHeight == ViewGroup.LayoutParams.MATCH_PARENT) {
heightSpec = ViewGroup.LayoutParams.MATCH_PARENT;
} else {
if (mDropDownHeight == ViewGroup.LayoutParams.WRAP_CONTENT) {
heightSpec = height;
} else {
heightSpec = mDropDownHeight;
}
}
mPopup.setWidth(widthSpec);
mPopup.setHeight(heightSpec);
setPopupClipToScreenEnabled(true);
// use outside touchable to dismiss drop down when touching outside of it, so
// only set this if the dropdown is not always visible
mPopup.setOutsideTouchable(!mForceIgnoreOutsideTouch && !mDropDownAlwaysVisible);
mPopup.setTouchInterceptor(mTouchInterceptor);
PopupWindowCompat.showAsDropDown(mPopup, getAnchorView(), mDropDownHorizontalOffset,
mDropDownVerticalOffset, mDropDownGravity);
mDropDownList.setSelection(ListView.INVALID_POSITION);
if (!mModal || mDropDownList.isInTouchMode()) {
clearListSelection();
}
if (!mModal) {
mHandler.post(mHideSelector);
}
}
}

PopupWindowCompat.showAsDropDown()

1
2
3
4
public static void showAsDropDown(PopupWindow popup, View anchor, int xoff, int yoff,
int gravity) {
IMPL.showAsDropDown(popup, anchor, xoff, yoff, gravity);
}

BasePopupWindowImpl.showAsDropDown()

1
2
3
4
5
@Override
public void showAsDropDown(PopupWindow popup, View anchor, int xoff, int yoff,
int gravity) {
popup.showAsDropDown(anchor, xoff, yoff);
}
1
2
3
public void showAsDropDown(View anchor, int xoff, int yoff) {
showAsDropDown(anchor, xoff, yoff, DEFAULT_ANCHORED_GRAVITY);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public void showAsDropDown(View anchor, int xoff, int yoff, int gravity) {
if (isShowing() || mContentView == null) {
return;
}
TransitionManager.endTransitions(mDecorView);
attachToAnchor(anchor, xoff, yoff, gravity);
mIsShowing = true;
mIsDropdown = true;
final WindowManager.LayoutParams p = createPopupLayoutParams(anchor.getWindowToken());
preparePopup(p);
final boolean aboveAnchor = findDropDownPosition(anchor, p, xoff, yoff,
p.width, p.height, gravity);
updateAboveAnchor(aboveAnchor);
p.accessibilityIdOfAnchor = (anchor != null) ? anchor.getAccessibilityViewId() : -1;
invokePopup(p);
}
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
private boolean findDropDownPosition(View anchor, WindowManager.LayoutParams outParams,
int xOffset, int yOffset, int width, int height, int gravity) {
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[] drawingLocation = mTmpDrawingLocation;
anchor.getLocationInWindow(drawingLocation);
outParams.x = drawingLocation[0] + xOffset;
outParams.y = drawingLocation[1] + anchorHeight + yOffset;
final Rect displayFrame = new Rect();
anchor.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 = Gravity.LEFT | Gravity.TOP;
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;
}
final int[] screenLocation = mTmpScreenLocation;
anchor.getLocationOnScreen(screenLocation);
// 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 (mAllowScrollingAnchorParent && anchor.requestRectangleOnScreen(r, true)) {
// Reset for the new anchor position.
anchor.getLocationInWindow(drawingLocation);
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];
}

anchor(View).requestRectangleOnScreen(r, true)

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
/**
* Request that a rectangle of this view be visible on the screen,
* scrolling if necessary just enough.
*
* <p>A View should call this if it maintains some notion of which part
* of its content is interesting. For example, a text editing view
* should call this when its cursor moves.
* <p>The Rectangle passed into this method should be in the View's content coordinate space.
* It should not be affected by which part of the View is currently visible or its scroll
* position.
* <p>When <code>immediate</code> is set to true, scrolling will not be
* animated.
*
* @param rectangle The rectangle in the View's content coordinate space
* @param immediate True to forbid animated scrolling, false otherwise
* @return Whether any parent scrolled.
*/
public boolean requestRectangleOnScreen(Rect rectangle, boolean immediate) {
if (mParent == null) {
return false;
}
View child = this;
RectF position = (mAttachInfo != null) ? mAttachInfo.mTmpTransformRect : new RectF();
position.set(rectangle);
ViewParent parent = mParent;
boolean scrolled = false;
while (parent != null) {
rectangle.set((int) position.left, (int) position.top,
(int) position.right, (int) position.bottom);
scrolled |= parent.requestChildRectangleOnScreen(child, rectangle, immediate);
if (!(parent instanceof View)) {
break;
}
// move it from child's content coordinate space to parent's content coordinate space
position.offset(child.mLeft - child.getScrollX(), child.mTop -child.getScrollY());
child = (View) parent;
parent = child.getParent();
}
return scrolled;
}

parent(ViewParent).requestChildRectangleOnScreen(child, rectangle, immediate)

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
/**
* Called when a child of this group wants a particular rectangle to be
* positioned onto the screen. {@link ViewGroup}s overriding this can trust
* that:
* <ul>
* <li>child will be a direct child of this group</li>
* <li>rectangle will be in the child's content coordinates</li>
* </ul>
*
* <p>{@link ViewGroup}s overriding this should uphold the contract:</p>
* <ul>
* <li>nothing will change if the rectangle is already visible</li>
* <li>the view port will be scrolled only just enough to make the
* rectangle visible</li>
* <ul>
*
* @param child The direct child making the request.
* @param rectangle The rectangle in the child's coordinates the child
* wishes to be on the screen.
* @param immediate True to forbid animated or delayed scrolling,
* false otherwise
* @return Whether the group scrolled to handle the operation
*/
public boolean requestChildRectangleOnScreen(View child, Rect rectangle,
boolean immediate);

the view port will be scrolled only just enough to make the rectangle visible
到这里我们可以确定确实ListPopupWindow在显示过程中有可能使父控件滑动,具体滑动多少距离,我们继续往下看

因为我的代码中使用了ListView,这里就接着往下看ListView.requestChildRectangleOnScreen()方法

ListView.requestChildRectangleOnScreen()

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
@Override
public boolean requestChildRectangleOnScreen(View child, Rect rect, boolean immediate) {
int rectTopWithinChild = rect.top;
// offset so rect is in coordinates of the this view
rect.offset(child.getLeft(), child.getTop());
rect.offset(-child.getScrollX(), -child.getScrollY());
final int height = getHeight();
int listUnfadedTop = getScrollY();
int listUnfadedBottom = listUnfadedTop + height;
final int fadingEdge = getVerticalFadingEdgeLength();
if (showingTopFadingEdge()) {
// leave room for top fading edge as long as rect isn't at very top
if ((mSelectedPosition > 0) || (rectTopWithinChild > fadingEdge)) {
listUnfadedTop += fadingEdge;
}
}
int childCount = getChildCount();
int bottomOfBottomChild = getChildAt(childCount - 1).getBottom();
if (showingBottomFadingEdge()) {
// leave room for bottom fading edge as long as rect isn't at very bottom
if ((mSelectedPosition < mItemCount - 1)
|| (rect.bottom < (bottomOfBottomChild - fadingEdge))) {
listUnfadedBottom -= fadingEdge;
}
}
int scrollYDelta = 0;
if (rect.bottom > listUnfadedBottom && rect.top > listUnfadedTop) {
// need to MOVE DOWN to get it in view: move down just enough so
// that the entire rectangle is in view (or at least the first
// screen size chunk).
if (rect.height() > height) {
// just enough to get screen size chunk on
scrollYDelta += (rect.top - listUnfadedTop);
} else {
// get entire rect at bottom of screen
scrollYDelta += (rect.bottom - listUnfadedBottom);
}
// make sure we aren't scrolling beyond the end of our children
int distanceToBottom = bottomOfBottomChild - listUnfadedBottom;
scrollYDelta = Math.min(scrollYDelta, distanceToBottom);
} else if (rect.top < listUnfadedTop && rect.bottom < listUnfadedBottom) {
// need to MOVE UP to get it in view: move up just enough so that
// entire rectangle is in view (or at least the first screen
// size chunk of it).
if (rect.height() > height) {
// screen size chunk
scrollYDelta -= (listUnfadedBottom - rect.bottom);
} else {
// entire rect at top
scrollYDelta -= (listUnfadedTop - rect.top);
}
// make sure we aren't scrolling any further than the top our children
int top = getChildAt(0).getTop();
int deltaToTop = top - listUnfadedTop;
scrollYDelta = Math.max(scrollYDelta, deltaToTop);
}
final boolean scroll = scrollYDelta != 0;
if (scroll) {
scrollListItemsBy(-scrollYDelta);
positionSelector(INVALID_POSITION, child);
mSelectedTop = child.getTop();
invalidate();
}
return scroll;
}

到这里我们可以发现,滑动的距离就是scrollYDelta.如果ListPopupWindow整个View高度较低,就滑动刚好可以使得ListPopupWindow整个View显示出来的距离。

ListView.scrollListItemsBy(-scrollYDelta);

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
/**
* Scroll the children by amount, adding a view at the end and removing
* views that fall off as necessary.
*
* @param amount The amount (positive or negative) to scroll.
*/
private void scrollListItemsBy(int amount) {
offsetChildrenTopAndBottom(amount);
final int listBottom = getHeight() - mListPadding.bottom;
final int listTop = mListPadding.top;
final AbsListView.RecycleBin recycleBin = mRecycler;
if (amount < 0) {
// shifted items up
// may need to pan views into the bottom space
int numChildren = getChildCount();
View last = getChildAt(numChildren - 1);
while (last.getBottom() < listBottom) {
final int lastVisiblePosition = mFirstPosition + numChildren - 1;
if (lastVisiblePosition < mItemCount - 1) {
last = addViewBelow(last, lastVisiblePosition);
numChildren++;
} else {
break;
}
}
// may have brought in the last child of the list that is skinnier
// than the fading edge, thereby leaving space at the end. need
// to shift back
if (last.getBottom() < listBottom) {
offsetChildrenTopAndBottom(listBottom - last.getBottom());
}
// top views may be panned off screen
View first = getChildAt(0);
while (first.getBottom() < listTop) {
AbsListView.LayoutParams layoutParams = (LayoutParams) first.getLayoutParams();
if (recycleBin.shouldRecycleViewType(layoutParams.viewType)) {
recycleBin.addScrapView(first, mFirstPosition);
}
detachViewFromParent(first);
first = getChildAt(0);
mFirstPosition++;
}
} else {
// shifted items down
View first = getChildAt(0);
// may need to pan views into top
while ((first.getTop() > listTop) && (mFirstPosition > 0)) {
first = addViewAbove(first, mFirstPosition);
mFirstPosition--;
}
// may have brought the very first child of the list in too far and
// need to shift it back
if (first.getTop() > listTop) {
offsetChildrenTopAndBottom(listTop - first.getTop());
}
int lastIndex = getChildCount() - 1;
View last = getChildAt(lastIndex);
// bottom view may be panned off screen
while (last.getTop() > listBottom) {
AbsListView.LayoutParams layoutParams = (LayoutParams) last.getLayoutParams();
if (recycleBin.shouldRecycleViewType(layoutParams.viewType)) {
recycleBin.addScrapView(last, mFirstPosition+lastIndex);
}
detachViewFromParent(last);
last = getChildAt(--lastIndex);
}
}
recycleBin.fullyDetachScrapViews();
removeUnusedFixedViews(mHeaderViewInfos);
removeUnusedFixedViews(mFooterViewInfos);
}

至此,已经找到问题根源:
如果ListPopupWindow当前内容无法显示,为了保证ListPopupWindow显示内容尽可能可见,ListPopupWindow会自动挪动anchorView所在的可滑动ViewGroup的位置。

解决方案

知道问题根源所在,解决方案也不在话下了。自己写个方法判断,根据anchorView位置显示即可。这里以屏幕一半作为分割线,源码如下,当然具体应该如何调整,看需求。

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
public void showMenuView(Context context, View anchorView) {
if (context == null || anchorView == null ) {
return;
}
mPopupWindow = new ListPopupWindow(context);
mPopupWindow.setModal(true);
mPopupWindow.setInputMethodMode(PopupWindow.INPUT_METHOD_NOT_NEEDED);
mPopupWindow.setContentWidth(measureContentWidth(context));
mPopupWindow.setAnchorView(anchorView);
mPopupWindow.setDropDownGravity(Gravity.END);
mPopupWindow.setHorizontalOffset(getHorizontalOffset(context, anchorView));
mPopupWindow.setVerticalOffset(getVerticalOffset(context, anchorView));
mPopupWindow.show();
}
protected int getHorizontalOffset(Context context, View anchorView){
return 0;
}
protected int getVerticalOffset(Context context, View anchorView) {
int popupWindowHeight = 0;
if (isExceedHalf(anchorView)) {
if (mAdapter instanceof CommonMenuAdapter) {
popupWindowHeight = mAdapter.getCount() * ((CommonMenuAdapter) mAdapter)
.getItemHeight();
}
return -anchorView.getHeight() - popupWindowHeight;
}
return 0;
}
private boolean isExceedHalf(View anchorView) {
int[] locations = new int[2];
anchorView.getLocationOnScreen(locations);
// 控件在屏幕的y坐标
int y = locations[1];
// 系统屏幕高度一半
int halfHeight = SystemUtils.getScreenHeight() / 2;
return y > halfHeight;
}

最终效果