1.問題背景
示例效果.png
如圖,各位看到該效果時,第一反應是使用什么方式實現呢?相信有一部分童鞋會嘗試使用PopupWindow+Spinner的方式來實現該效果,而Spinner控件的SpinnerMode屬性默認是dialog樣式,展現的顯示效果不是很難理想,這時,為了更方便的達到與上圖一致的效果,有人會嘗試去修改SpinnerMode為dropDown,我所認識的一位童鞋就是這樣操作的,就這樣。。。在點擊Spinner準備彈出下拉列表時,異常出現了。。。
錯誤日志如下:
FATAL EXCEPTION: main
Process: com.zihao.spinnerdemo, PID: 28778
android.view.WindowManager$BadTokenException: Unable to add window -- token android.view.ViewRootImpl$W@263c5a2 is not valid; is your activity running?
at android.view.ViewRootImpl.setView(ViewRootImpl.java:567)
at android.view.WindowManagerGlobal.addView(WindowManagerGlobal.java:310)
at android.view.WindowManagerImpl.addView(WindowManagerImpl.java:85)
at android.widget.PopupWindow.invokePopup(PopupWindow.java:1258)
at android.widget.PopupWindow.showAsDropDown(PopupWindow.java:1110)
at android.widget.ListPopupWindow.show(ListPopupWindow.java:658)
at android.widget.Spinner$DropdownPopup.show(Spinner.java:1223)
at android.widget.Spinner.performClick(Spinner.java:758)
at android.support.v7.widget.AppCompatSpinner.performClick(AppCompatSpinner.java:438)
at android.view.View$PerformClick.run(View.java:21153)
at android.os.Handler.handleCallback(Handler.java:739)
at android.os.Handler.dispatchMessage(Handler.java:95)
at android.os.Looper.loop(Looper.java:148)
at android.app.ActivityThread.main(ActivityThread.java:5417)
at java.lang.reflect.Method.invoke(Native Method)
at com.android.internal.os.ZygoteInit$MethodAndArgsCaller.run(ZygoteInit.java:726)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:616)
產生背景:在PopupWindow中使用Spinner且SpinnerMode="dropDown".
2.錯誤原因分析
通過對問題產生背景的描述,我們可以很輕松的定位到問題產生的主要原因——即SpinnerMode為dropDown時引發的該異常(但是在SpinnerMode值為dialog時則會正常彈出下拉列表彈窗),那么SpinnerMode="dialog"與SpinnerMode="dropDown"究竟有什么不同?下面我們來看下Spinner源碼,來仔細區分下兩者的區別。
- Spinner.java中關于下拉列表顯示方式(樣式)處理
/**
* Constructs a new spinner with the given context, the supplied attribute
* set, default styles, popup mode (one of {@link #MODE_DIALOG} or
* {@link #MODE_DROPDOWN}), and the theme against which the popup should be
* inflated.
*
* @param context The context against which the view is inflated, which
* provides access to the current theme, resources, etc.
* @param attrs The attributes of the XML tag that is inflating the view.
* @param defStyleAttr An attribute in the current theme that contains a
* reference to a style resource that supplies default
* values for the view. Can be 0 to not look for
* defaults.
* @param defStyleRes A resource identifier of a style resource that
* supplies default values for the view, used only if
* defStyleAttr is 0 or can not be found in the theme.
* Can be 0 to not look for defaults. * @param mode Constant describing how the user will select choices from
* the spinner.
* @param popupTheme The theme against which the dialog or dropdown popup
* should be inflated. May be {@code null} to use the
* view theme. If set, this will override any value
* specified by
* {@link android.R.styleable#Spinner_popupTheme}.
*
* @see #MODE_DIALOG // SpinnerMode="dialog"
* @see #MODE_DROPDOWN // SpinnerMode="dropDown"
*/
public Spinner(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes, int mode,
Theme popupTheme) {
super(context, attrs, defStyleAttr, defStyleRes);
final TypedArray a = context.obtainStyledAttributes(
attrs, R.styleable.Spinner, defStyleAttr, defStyleRes);
if (popupTheme != null) {
mPopupContext = new ContextThemeWrapper(context, popupTheme);
} else {
final int popupThemeResId = a.getResourceId(R.styleable.Spinner_popupTheme, 0);
if (popupThemeResId != 0) {
mPopupContext = new ContextThemeWrapper(context, popupThemeResId);
} else {
mPopupContext = context;
}
}
if (mode == MODE_THEME) {
mode = a.getInt(R.styleable.Spinner_spinnerMode, MODE_DIALOG);
}
switch (mode) {
case MODE_DIALOG: {// 當SpinnerMode為dialog時
mPopup = new DialogPopup();
mPopup.setPromptText(a.getString(R.styleable.Spinner_prompt));
break;
}
case MODE_DROPDOWN: {// 當SpinnerMode為dropDown時
// 在這里新建了一個DropdownPopup用來展示下拉列表的內容,關于DropdownPopup源碼請看下一片段
final DropdownPopup popup = new DropdownPopup(
mPopupContext, attrs, defStyleAttr, defStyleRes);
final TypedArray pa = mPopupContext.obtainStyledAttributes(
attrs, R.styleable.Spinner, defStyleAttr, defStyleRes);
mDropDownWidth = pa.getLayoutDimension(R.styleable.Spinner_dropDownWidth,
ViewGroup.LayoutParams.WRAP_CONTENT);
if (pa.hasValueOrEmpty(R.styleable.Spinner_dropDownSelector)) {
popup.setListSelector(pa.getDrawable(
R.styleable.Spinner_dropDownSelector));
}
popup.setBackgroundDrawable(pa.getDrawable(R.styleable.Spinner_popupBackground));
popup.setPromptText(a.getString(R.styleable.Spinner_prompt));
pa.recycle();
mPopup = popup;
mForwardingListener = new ForwardingListener(this) {
@Override
public ShowableListMenu getPopup() {
return popup;
}
@Override
public boolean onForwardingStarted() {
if (!mPopup.isShowing()) {
mPopup.show(getTextDirection(), getTextAlignment());
}
return true;
}
};
break;
}
}
mGravity = a.getInt(R.styleable.Spinner_gravity, Gravity.CENTER);
mDisableChildrenWhenDisabled = a.getBoolean(
R.styleable.Spinner_disableChildrenWhenDisabled, false);
a.recycle();
// Base constructor can call setAdapter before we initialize mPopup.
// Finish setting things up if this happened.
if (mTempAdapter != null) {
setAdapter(mTempAdapter);
mTempAdapter = null;
}
}
- Spinner.java中DropdownPopup類源碼
private class DropdownPopup extends ListPopupWindow implements SpinnerPopup {
private CharSequence mHintText;
private ListAdapter mAdapter;
public DropdownPopup(
Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
setAnchorView(Spinner.this);
setModal(true);
setPromptPosition(POSITION_PROMPT_ABOVE);
setOnItemClickListener(new OnItemClickListener() {
public void onItemClick(AdapterView parent, View v, int position, long id) {
Spinner.this.setSelection(position);
if (mOnItemClickListener != null) {
Spinner.this.performItemClick(v, position, mAdapter.getItemId(position));
}
dismiss();
}
});
}
@Override
public void setAdapter(ListAdapter adapter) {
super.setAdapter(adapter);
mAdapter = adapter;
}
public CharSequence getHintText() {
return mHintText;
}
public void setPromptText(CharSequence hintText) {
// Hint text is ignored for dropdowns, but maintain it here.
mHintText = hintText;
}
void computeContentWidth() {
final Drawable background = getBackground();
int hOffset = 0;
if (background != null) {
background.getPadding(mTempRect);
hOffset = isLayoutRtl() ? mTempRect.right : -mTempRect.left;
} else {
mTempRect.left = mTempRect.right = 0;
}
final int spinnerPaddingLeft = Spinner.this.getPaddingLeft();
final int spinnerPaddingRight = Spinner.this.getPaddingRight();
final int spinnerWidth = Spinner.this.getWidth();
if (mDropDownWidth == WRAP_CONTENT) {
int contentWidth = measureContentWidth(
(SpinnerAdapter) mAdapter, getBackground());
final int contentWidthLimit = mContext.getResources()
.getDisplayMetrics().widthPixels - mTempRect.left - mTempRect.right;
if (contentWidth > contentWidthLimit) {
contentWidth = contentWidthLimit;
}
setContentWidth(Math.max(
contentWidth, spinnerWidth - spinnerPaddingLeft - spinnerPaddingRight));
} else if (mDropDownWidth == MATCH_PARENT) {
setContentWidth(spinnerWidth - spinnerPaddingLeft - spinnerPaddingRight);
} else {
setContentWidth(mDropDownWidth);
}
if (isLayoutRtl()) {
hOffset += spinnerWidth - spinnerPaddingRight - getWidth();
} else {
hOffset += spinnerPaddingLeft;
}
setHorizontalOffset(hOffset);
}
public void show(int textDirection, int textAlignment) {
final boolean wasShowing = isShowing();
computeContentWidth();
setInputMethodMode(ListPopupWindow.INPUT_METHOD_NOT_NEEDED);
super.show();
final ListView listView = getListView();
listView.setChoiceMode(ListView.CHOICE_MODE_SINGLE);
listView.setTextDirection(textDirection);
listView.setTextAlignment(textAlignment);
setSelection(Spinner.this.getSelectedItemPosition());
if (wasShowing) {
// Skip setting up the layout/dismiss listener below. If we were previously
// showing it will still stick around.
return;
}
// Make sure we hide if our anchor goes away.
// TODO: This might be appropriate to push all the way down to PopupWindow,
// but it may have other side effects to investigate first. (Text editing handles, etc.)
final ViewTreeObserver vto = getViewTreeObserver();
if (vto != null) {
final OnGlobalLayoutListener layoutListener = new OnGlobalLayoutListener() {
@Override
public void onGlobalLayout() {
if (!Spinner.this.isVisibleToUser()) {
dismiss();
} else {
computeContentWidth();
// Use super.show here to update; we don't want to move the selected
// position or adjust other things that would be reset otherwise.
DropdownPopup.super.show();
}
}
};
vto.addOnGlobalLayoutListener(layoutListener);
setOnDismissListener(new OnDismissListener() {
@Override
public void onDismiss() {
final ViewTreeObserver vto = getViewTreeObserver();
if (vto != null) {
vto.removeOnGlobalLayoutListener(layoutListener);
}
}
});
}
}
}
通過對以上源碼的分析,我們可以看出,當SpinnerMode為dialog時,Spinner內部選擇用一個DialogPopup來顯示下拉列表內容;當SpinnerMode為dropDown時,Spinner選擇用一個DropdownPopup來顯示下拉列表內容——即在兩種不同的樣式下,Spinner分別選擇使用Dialog / PopupWindow來進行內容展示。
看到這里,我們會疑惑,為什么PopupWindow中嵌套使用Dialog沒問題,而嵌套使用PopupWindow就會出錯?關于PopupWindow、Dialog窗口添加機制的不同之處推薦閱讀Android 窗口添加機制系列2-Dialog,PopupWindow,Toast。
- PopupWindow
PopupWindow本身依附的WindowToken實際上是也是Activity所依附的WindowToken,這也就是說PopupWindow與Activity所使用的WindowToken是一致的。
PopupWindow內部不能再使用PopupWindow是因為它獲取不到父PopupWindow的WindowToke,從這里我們也可以分析出,一個視圖內部不能嵌套與之平級的視圖。 - Dialog
Dialog在初始化視圖時,在獲取到Activity的WindowToken后,會重新new一個Window,它與Activity分屬于不同的Window。
3.解決方式
搞明白PopupWindow、Dialog的區別以及與WindowsManager的關系后,我們可以通過以下方式來規避類似問題的發生:
- 將外層PopupWindow換為Activity
- 將外層PopupWindow替換為Dialog
- 修改SpinnerMode為dialog
- 使用自定義組件實現類Spinner效果
- 使用Dialog+PopupWindow
【拓展閱讀】
Android 窗口添加機制系列2-Dialog,PopupWindow,Toast
Spinner SpinnerAPI官方文檔翻譯
視圖加載到窗口的過程分析