Android - Understand Spinner

2023/11/07 spinner android beginner 38615 words,~ 111 min

序章

昨天,我朋友剛好問我是否有做過這樣的 Spinner

原本想要自己試試看做出來,誰知道最後還是請出 stackoverflow 大神指點迷津。

發現自己對 Spinner 真的很不熟,所以決定寫一篇文章來分析 Spinner 的行為了。

Spinner 的架構

其實 Spinner 本身是一個 ViewGroup :

它之所以可以顯示選單是因為 Adapter 的緣故。我們會先從 Adapter 開起,最後會將整個 Spinner 的顯示流程推導出來。

Adapter

Adpater 是一個介面,但他的行為與 RecyclerView.Adapter 極為相似:

public interface Adapter {
    void registerDataSetObserver(DataSetObserver observer);
    void unregisterDataSetObserver(DataSetObserver observer);

    int getCount();
    Object getItem(int position);
    long getItemId(int position);
    
    /**
     * Indicates whether the item ids are stable across changes to the
     * underlying data.
     * 
     * @return True if the same id always refers to the same object.
     */
    boolean hasStableIds();
    View getView(int position, View convertView, ViewGroup parent);

    static final int IGNORE_ITEM_VIEW_TYPE = AdapterView.ITEM_VIEW_TYPE_IGNORE;
    int getItemViewType(int position);
    int getViewTypeCount();
    
    static final int NO_SELECTION = Integer.MIN_VALUE;
    boolean isEmpty();

    default @Nullable CharSequence[] getAutofillOptions() {
        return null;
    }
}

繼承 Adapter 的兩個介面分別是 ListAdapterSpinnerAdapter

要注意的是 Spinner 新增了一個 getDropDownView(int, View, VieGroup) 的方法。這也是提供我們取得選單上選項的 View 的接口。

竟然我們想要暸解 Spinner 我們就專注在 SpinnerAdapter 的實作與運用即可。

SpinnerAdapter

public interface SpinnerAdapter extends Adapter {
    public View getDropDownView(int position, View convertView, ViewGroup parent);
}

他共有 6 個實作者:

ImplsDescription
DropDownAdapter這是一個繼承 ListAdapterSpinnerAdapter 的靜態類別。

與其說是實作者,更應該稱它為 Wrapper。 因為他會把全部實作委推給 SpinnerAdapter mAdapterListAdapter mListAdapter
BaseAdapter這是一個繼承 ListAdapterSpinnerAdapter 的抽象類別。而他還新增了通知監控者的功能 (notifyDataSetChanged, notifyDataSetInvalidated )。
ArrayAdapter他是一個繼承 BaseAdapter 並實作 FilterableThemedSpinnerAdapter 的類別。

我們可以在 MaterialArrayAdapterAlertController 中找到他。
SimpleAdapter他與 ArrayAdapter 繼承與實作相同的類別。 但 SimpleAdapter 缺乏對資料的更改功能,無法進行新增、插入、移除、排序等等的功能。
CursorAdapter這是一個繼承 BaseAdapter 並實作 Filterable 的抽象類別。

我們可以在 AlertController 找到他。 而他還有一個繼承者 (ResourceCursorAdapter) 並由 SimpleCursorAdapter 實作。

從名字就知道這個 Adapter 是配合 Database 或是 ContentProvider 來運作的。有機會會做個 demo 來展現他的作用。
SuggestionAdapter這是實作 ResourceCursorAdapter 的類別。 我們可以在 SearchView 看到他的作用。

以上的類別都可以使用在 AbsSpinner 內。 而 AbsSpinner 則會由 SpinnerGalleryAppCompatSpinner 實作。

他們實作 SpinnerAdapter 的方法分別是以下:

// DropDownAdapter
@Override
public View getDropDownView(int position, View convertView, ViewGroup parent) {
    // 直接耍廢,委託給 SpinnerAdapter mAdapter 執行
    // 當然這是為了更好的減少耦合性
    return (mAdapter == null) ? null
            : mAdapter.getDropDownView(position, convertView, parent);
}

// BaseAdapter 
public View getDropDownView(int position, View convertView, ViewGroup parent) {
    // 調用 Adapter 的 getView
    return getView(position, convertView, parent);
}


// ArrayAdapter
@Override
public View getDropDownView(int position, @Nullable View convertView,
        @NonNull ViewGroup parent) {
    
    // 通過 LayoutInflator 與參數來創建一個 TextView
    final LayoutInflater inflater = mDropDownInflater == null ? mInflater : mDropDownInflater;
    return createViewFromResource(inflater, position, convertView, parent, mDropDownResource);
}

// SimpleAdapter
@Override
public View getDropDownView(int position, View convertView, ViewGroup parent) {

    // 這裡的行為與 ArrayAdapter 一樣,但 SimpleAdapter 的 createViewFromResources 不僅僅可以創建出 TextView  
    // 還可以是 ImageView 或 擁有 Clickable 功能的 TextView
    final LayoutInflater inflater = mDropDownInflater == null ? mInflater : mDropDownInflater;
    return createViewFromResource(inflater, position, convertView, parent, mDropDownResource);
}

// CursorAdapter
@Override
public View getDropDownView(int position, View convertView, ViewGroup parent) {
    if (mDataValid) {
        mCursor.moveToPosition(position);
        View v;
        if (convertView == null) {
            
            // 這裡會由實作 CursorAdapter#newView(Context, Cursor, ViewGroup) 功能的類別創建想要的 View
            v = newDropDownView(mContext, mCursor, parent);
        } else {
            v = convertView;
        }

        // 另外, bindView 也需要被實作來正確展示資料
        bindView(v, mContext, mCursor);
        return v;
    } else {
        return null;
    }
}

// SuggestionAdapter
@Override
public View getDropDownView(int position, View convertView, ViewGroup parent) {
    try {

        // 也是直接耍廢,讓 CursorAdapter 執行
        return super.getDropDownView(position, convertView, parent);
    } catch (RuntimeException e) {
        Log.w(LOG_TAG, "Search suggestions cursor threw exception.", e);

        // 若有 exception 就創建 TextViw 並顯示出來
        // Put exception string in item title
        final View v = newDropDownView(mProviderContext, getCursor(), parent);
        if (v != null) {
            final ChildViewCache views = (ChildViewCache) v.getTag();
            final TextView tv = views.mText1;
            tv.setText(e.toString());
        }
        return v;
    }
}

在看 Spinner 之前,我們要先看 AbsSpinner 的父類別 **AdapterView** 的結構與運作。

AdapterView

AdapterView 是一個 ViewGroup 的抽象類別。

public abstract class AdapterView<T extends Adapter> extends ViewGroup

在建構子中,我們可以發現 AdapterView 只會做兩件事:

public AdapterView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
    super(context, attrs, defStyleAttr, defStyleRes);

    // 1. 確保 Accessibility 被開啟與
    // If not explicitly specified this view is important for accessibility.
    if (getImportantForAccessibility() == IMPORTANT_FOR_ACCESSIBILITY_AUTO) {
        setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_YES);
    }

    // 2. 在沒有 Adapter 之前,會確保不會被 Focus
    mDesiredFocusableState = getFocusable();
    if (mDesiredFocusableState == FOCUSABLE_AUTO) {
        // Starts off without an adapter, so NOT_FOCUSABLE by default.
        super.setFocusable(NOT_FOCUSABLE);
    }
}

除了建構子之外,內部有以下幾個重要的參數。 其中除了 SelectionNotifier 外都可以通過 setter/getter 設定與讀取:

參數功能
View mEmptyView在沒有資料時顯示的 View
OnItemSelectedListener mOnItemSelectedListener
OnItemClickListener mOnItemClickListener
OnItemLongClickListener mOnItemLongClickListener
不同方式點擊物件時的監聽者
SelectionNotifier mSelectionNotifier這是一個用來通知 selection event 的 Runnable
SelectionNotifier mPendingSelectionNotifier這是正在等待被通知的 selection notifier

以下是 OnItemClickListnerOnItemLongClickListenerOnItemSelectedListener 的介面:

public interface OnItemClickListener {
    void onItemClick(AdapterView<?> parent, View view, int position, long id);
}

public interface OnItemLongClickListener {
    boolean onItemLongClick(AdapterView<?> parent, View view, int position, long id);
}

public interface OnItemSelectedListener {
    // id - The row id of the item that is selected
    void onItemSelected(AdapterView<?> parent, View view, int position, long id);
    // Callback method to be invoked when the selection disappears from this view.
    // The selection can disappear for instance when touch is activated or when the adapter becomes empty.
    void onNothingSelected(AdapterView<?> parent);
}

雖說這是一個 ViewGroup 但他並不支援 addViewremoveView 的功能。

由於 AdapterView 只是一個抽象類別,所以他的作用只是為了進行行為上的基礎設定。 而這些設定主要是與選取行為相關:

handleDataChanged() 被調用時會尋找之前被挑選的物件位置。 若出現選取或取消行為便會調用 checkSelectionChanged() 進行通知
void handleDataChanged() {
    final int count = mItemCount;
    boolean found = false;

    if (count > 0) {

        int newPos;

        // Find the row we are supposed to sync to
        if (mNeedSync) {
            // Update this first, since setNextSelectedPositionInt inspects
            // it
            mNeedSync = false;

            // See if we can find a position in the new data with the same
            // id as the old selection
            newPos = findSyncPosition();
            if (newPos >= 0) {
                // Verify that new selection is selectable
                int selectablePos = lookForSelectablePosition(newPos, true);
                if (selectablePos == newPos) {
                    // Same row id is selected
                    setNextSelectedPositionInt(newPos);
                    found = true;
                }
            }
        }
        if (!found) {
            // Try to use the same position if we can't find matching data
            newPos = getSelectedItemPosition();

            // Pin position to the available range
            if (newPos >= count) {
                newPos = count - 1;
            }
            if (newPos < 0) {
                newPos = 0;
            }

            // Make sure we select something selectable -- first look down
            int selectablePos = lookForSelectablePosition(newPos, true);
            if (selectablePos < 0) {
                // Looking down didn't work -- try looking up
                selectablePos = lookForSelectablePosition(newPos, false);
            }
            if (selectablePos >= 0) {
                setNextSelectedPositionInt(selectablePos);
                checkSelectionChanged();
                found = true;
            }
        }
    }
    if (!found) {
        // Nothing is selected
        mSelectedPosition = INVALID_POSITION;
        mSelectedRowId = INVALID_ROW_ID;
        mNextSelectedPosition = INVALID_POSITION;
        mNextSelectedRowId = INVALID_ROW_ID;
        mNeedSync = false;
        checkSelectionChanged();
    }

    notifySubtreeAccessibilityStateChangedIfNeeded();
}
rememberSyncState() - remember enough information to restore the screen state when the data has changed.
void rememberSyncState() {
    if (getChildCount() > 0) {
        mNeedSync = true;
        mSyncHeight = mLayoutHeight;
        if (mSelectedPosition >= 0) {
            // Sync the selection state
            View v = getChildAt(mSelectedPosition - mFirstPosition);
            mSyncRowId = mNextSelectedRowId;
            mSyncPosition = mNextSelectedPosition;
            if (v != null) {
                mSpecificTop = v.getTop();
            }
            mSyncMode = SYNC_SELECTED_POSITION;
        } else {
            // Sync the based on the offset of the first view
            View v = getChildAt(0);
            T adapter = getAdapter();
            if (mFirstPosition >= 0 && mFirstPosition < adapter.getCount()) {
                mSyncRowId = adapter.getItemId(mFirstPosition);
            } else {
                mSyncRowId = NO_ID;
            }
            mSyncPosition = mFirstPosition;
            if (v != null) {
                mSpecificTop = v.getTop();
            }
            mSyncMode = SYNC_FIRST_POSITION;
        }
    }
}

rememberSyncState() 中, 若有資料就會將 mNeedSync 設為 true。 而 findSyncPosition() 會在這個情況下被 handleData() 調用並用來尋找之前所選的選項。雖然說不是什麼特別的方法,但其中的演算法還是可以學習的。基本上就是 Bubble Search,但卻會隨機從任一位置開始往右尋找。 找不到再往左尋找。主要目的就是用機率的方式來加快尋找速度。
int findSyncPosition() {
    int count = mItemCount;

    if (count == 0) {
        return INVALID_POSITION;
    }

    long idToMatch = mSyncRowId;
    int seed = mSyncPosition;

    // If there isn't a selection don't hunt for it
    if (idToMatch == INVALID_ROW_ID) {
        return INVALID_POSITION;
    }

    // Pin seed to reasonable values
    // 確保 0 <= seed <= count - 1
    seed = Math.max(0, seed);
    seed = Math.min(count - 1, seed);

    long endTime = SystemClock.uptimeMillis() + SYNC_MAX_DURATION_MILLIS;

    long rowId;

    // first position scanned so far
    int first = seed;

    // last position scanned so far
    int last = seed;

    // True if we should move down on the next iteration
    boolean next = false;

    // True when we have looked at the first item in the data
    boolean hitFirst;

    // True when we have looked at the last item in the data
    boolean hitLast;

    // Get the item ID locally (instead of getItemIdAtPosition), so
    // we need the adapter
    T adapter = getAdapter();
    if (adapter == null) {
        return INVALID_POSITION;
    }

    while (SystemClock.uptimeMillis() <= endTime) {
        rowId = adapter.getItemId(seed);
        if (rowId == idToMatch) {
            // Found it!
            return seed;
        }

        hitLast = last == count - 1;
        hitFirst = first == 0;

        if (hitLast && hitFirst) {
            // Looked at everything
            break;
        }

        if (hitFirst || (next && !hitLast)) {
            // Either we hit the top, or we are trying to move down
            last++;
            seed = last;
            // Try going up next time
            next = false;
        } else if (hitLast || (!next && !hitFirst)) {
            // Either we hit the bottom, or we are trying to move up
            first--;
            seed = first;
            // Try going down next time
            next = true;
        }

    }

    return INVALID_POSITION;
}
checkSelectionChanged() 會通過隱藏的 selectionChanged() 進行立即通知 ( dispatchOnItemSelected() ) 或延後通知 ( post(mSelectionNotifier) )
void checkSelectionChanged() {
    if ((mSelectedPosition != mOldSelectedPosition) || (mSelectedRowId != mOldSelectedRowId)) {
        selectionChanged();
        mOldSelectedPosition = mSelectedPosition;
        mOldSelectedRowId = mSelectedRowId;
    }

    // If we have a pending selection notification -- and we won't if we
    // just fired one in selectionChanged() -- run it now.
    if (mPendingSelectionNotifier != null) {
        mPendingSelectionNotifier.run();
    }
}

@UnsupportedAppUsage
void selectionChanged() {
    // We're about to post or run the selection notifier, so we don't need
    // a pending notifier.
    mPendingSelectionNotifier = null;

    if (mOnItemSelectedListener != null
            || AccessibilityManager.getInstance(mContext).isEnabled()) {
        if (mInLayout || mBlockLayoutRequests) {
            // If we are in a layout traversal, defer notification
            // by posting. This ensures that the view tree is
            // in a consistent state and is able to accommodate
            // new layout or invalidate requests.
            if (mSelectionNotifier == null) {
                mSelectionNotifier = new SelectionNotifier();
            } else {
                removeCallbacks(mSelectionNotifier);
            }
            post(mSelectionNotifier);
        } else {
            dispatchOnItemSelected();
        }
    }
    // Always notify AutoFillManager - it will return right away if autofill is disabled.
    final AutofillManager afm = mContext.getSystemService(AutofillManager.class);
    if (afm != null) {
        afm.notifyValueChanged(this);
    }
}

private class SelectionNotifier implements Runnable {
    public void run() {
        mPendingSelectionNotifier = null;

        if (mDataChanged && getViewRootImpl() != null
                && getViewRootImpl().isLayoutRequested()) {
            // Data has changed between when this SelectionNotifier was
            // posted and now. Postpone the notification until the next
            // layout is complete and we run checkSelectionChanged().
            if (getAdapter() != null) {
                mPendingSelectionNotifier = this;
            }
        } else {
            dispatchOnItemSelected();
        }
    }
}

// dispatchOnItemSelected 會通知 OnItemSelectedListener
// 若 selection == INVALID_POSITION == -1 就會通知 onNothingSelected.
// selection 其實就是 mNextSelectedPosition.
// mNextSelectedPosition 會在 onInvalidated() 或在 handleData 沒找到資料或沒資料時設為 INVALID_POSITION
private void dispatchOnItemSelected() {
    fireOnSelected();
    performAccessibilityActionsOnSelected();
}

private void fireOnSelected() {
    if (mOnItemSelectedListener == null) {
        return;
    }
    final int selection = getSelectedItemPosition();
    if (selection >= 0) {
        View v = getSelectedView();
        mOnItemSelectedListener.onItemSelected(this, v, selection,
                getAdapter().getItemId(selection));
    } else {
        mOnItemSelectedListener.onNothingSelected(this);
    }
}

現在我們大致上暸解 AdapterView 做了什麼。 接下來我們就要看看 AbsSpinner 了。

AbsSpinner相較於 AdapterView, AbsSpinner

AbsSpinner 是一個繼承 AdapterView 的抽象類別。一般來說,我們不會特地實作這個類別。

public abstract class AbsSpinner extends AdapterView<SpinnerAdapter>

相較於 AdapterView, AbsSpinner 除了指定 T:Adapter 為 SpinnerAdapter, 還增加了 UI 相關的參數以及回收機制。

參數作用
mHeightMeasureSpec
mWidthMeasureSpec
onMeasure 中紀錄當下的 spec,僅此。
mSelectionLeftPadding
mSelectionTopPadding
mSelectionRightPadding
mSelectionBottomPadding
Paddings。 會在 onMeasure 中設定 mSpinnerPadding 的值。
Rect mSpinnerPadding他的值都是 paddings,而這些值會在 onMeasure 中算出 preferredHeightpreferredWidth
RecycleBin mRecycler這是一個 AbsSpinner 內部的類別。 其主要目的是存取 View。
DataSetObserver mDataSetObserver這個 DataSetObserver 會註冊在 Adapter 上。
SpinnerAdapter mAdapter這個就是核心的 Adapter 了。 AbsSpinner 會通過他在 onMeasure創建 新的 View。而 mDataSetObserver 也會通過他來監聽 onInvalidated()onChanged()

看 AbsSpinner 時,我們可以在建構子上看到他的預設:

private void initAbsSpinner() {
    setFocusable(true); // 可被 Focus
    setWillNotDraw(false); // 這個 View 的 onDraw 會被調用,但 AbsSpinner 並沒由實作。
}

通過 setWillNotDraw(Boolean) View 會在 setFlags(int flags, int mask) 裡面定義是否要跳過 Draw :

if ((changed & DRAW_MASK) != 0) {
    if ((mViewFlags & WILL_NOT_DRAW) != 0) {
        if (mBackground != null
                || mDefaultFocusHighlight != null
                || (mForegroundInfo != null && mForegroundInfo.mDrawable != null)) {

            // 若有背景、可被 Highlight 或有前景時,就會被強制需要畫
            mPrivateFlags &= ~PFLAG_SKIP_DRAW;
        } else {
            // 否則就跳過
            mPrivateFlags |= PFLAG_SKIP_DRAW;
        }
    } else {
        mPrivateFlags &= ~PFLAG_SKIP_DRAW;
    }
    requestLayout();
    invalidate(true);
}

通過以上的設定, mPrivateFlags 若設定為 PFLAG_SKIP_DRAW 變會在 draw(@NonNull Canvas canvas, ViewGroup parent, long drawingTime) 中調用 dispatchDraw(Canvas) 而非 draw(Canvas)

  • dispatchDraw(Canvas) 預設沒有行為。 他的主要功能是讓 View 可以控制他的 Children 如何被畫。而他的調用時間點是在 View 被畫出之後。

  • draw(Canvas) 會先調用 onDraw(Canvas) 來讓 View 決定要在 dispatchDraw(Canvas) 被調用前畫些什麼。他的預設也是沒有行為。

接下來,我們看看 onMeasure 吧。

其實 onMeasure 的實作並不複雜,因為他只會顯示被選取的物件。並不包括 popup。
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    int widthMode = MeasureSpec.getMode(widthMeasureSpec);
    int widthSize;
    int heightSize;

    // 1. 設定 mSpinnerPadding
    mSpinnerPadding.left = mPaddingLeft > mSelectionLeftPadding ? mPaddingLeft
            : mSelectionLeftPadding;
    mSpinnerPadding.top = mPaddingTop > mSelectionTopPadding ? mPaddingTop
            : mSelectionTopPadding;
    mSpinnerPadding.right = mPaddingRight > mSelectionRightPadding ? mPaddingRight
            : mSelectionRightPadding;
    mSpinnerPadding.bottom = mPaddingBottom > mSelectionBottomPadding ? mPaddingBottom
            : mSelectionBottomPadding;

    // 2. mDataChanged == true 表示 onRestoreInstanceState 被執行,並且之前有選取 item
    //    此時就要調用 handleDataChanged
    if (mDataChanged) {
        handleDataChanged();
    }

    int preferredHeight = 0;
    int preferredWidth = 0;
    boolean needsMeasuring = true;

    // 3. 若選取的位置在合理範圍中,就會通過 RecycleBin 與 SpinnerAdapter 的合力創建及存取 View
    int selectedPosition = getSelectedItemPosition();
    if (selectedPosition >= 0 && mAdapter != null && selectedPosition < mAdapter.getCount()) {
        // Try looking in the recycler. (Maybe we were measured once already)
        View view = mRecycler.get(selectedPosition);
        if (view == null) {
            // Make a new one
            view = mAdapter.getView(selectedPosition, null, this);

            if (view.getImportantForAccessibility() == IMPORTANT_FOR_ACCESSIBILITY_AUTO) {
                view.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_YES);
            }
        }

        if (view != null) {
            // Put in recycler for re-measuring and/or layout
            mRecycler.put(selectedPosition, view);

            // 4. 當 view 的 layoutParams 為 null,這表示他是剛被創建的
            //    所以我們需要設定他的 LayoutParams。
            //    在此之前,我們需要先把 mBlockLayoutRequests 設為 true 來防止 requestLayout 的調用。
            //    mBlockLayoutRequests 還會在選取新的物件時被設為 true。
            if (view.getLayoutParams() == null) {
                mBlockLayoutRequests = true;

                // 5. LayoutParams 預設為 MATCH_PARENT , WRAP_CONTENT
                view.setLayoutParams(generateDefaultLayoutParams());
                mBlockLayoutRequests = false;
            }

            // 6. 這會由 ViewGroup 預設行為來取得 view 的計算後的大小
            measureChild(view, widthMeasureSpec, heightMeasureSpec);

            // 7. 通過 padding 與 width, height 的配合,我們可以算出此 view 的適當大小
            preferredHeight = getChildHeight(view) + mSpinnerPadding.top + mSpinnerPadding.bottom;
            preferredWidth = getChildWidth(view) + mSpinnerPadding.left + mSpinnerPadding.right;

            needsMeasuring = false;
        }
    }

    if (needsMeasuring) {
        // No views -- just use padding
        preferredHeight = mSpinnerPadding.top + mSpinnerPadding.bottom;
        if (widthMode == MeasureSpec.UNSPECIFIED) {
            preferredWidth = mSpinnerPadding.left + mSpinnerPadding.right;
        }
    }

    preferredHeight = Math.max(preferredHeight, getSuggestedMinimumHeight());
    preferredWidth = Math.max(preferredWidth, getSuggestedMinimumWidth());

    heightSize = resolveSizeAndState(preferredHeight, heightMeasureSpec, 0);
    widthSize = resolveSizeAndState(preferredWidth, widthMeasureSpec, 0);

    setMeasuredDimension(widthSize, heightSize);
    mHeightMeasureSpec = heightMeasureSpec;
    mWidthMeasureSpec = widthMeasureSpec;
}

目前我們看到 AbsSpinner 對 UI 的作用,但其實他也會在得到 Adapter 時進行一些設置喔。

通過 setAdapter(SpinnerAdapter) AbsSpinner 會重新註冊 DataSetObserver,並重設 selected position。 最後再進行 requestLayout() 來啟動 measuredraw
@Override
public void setAdapter(SpinnerAdapter adapter) {
    if (null != mAdapter) {
        mAdapter.unregisterDataSetObserver(mDataSetObserver);
        resetList();
    }

    mAdapter = adapter;

    mOldSelectedPosition = INVALID_POSITION;
    mOldSelectedRowId = INVALID_ROW_ID;

    if (mAdapter != null) {
        mOldItemCount = mItemCount;
        mItemCount = mAdapter.getCount();
        checkFocus();

        mDataSetObserver = new AdapterDataSetObserver();
        mAdapter.registerDataSetObserver(mDataSetObserver);

        int position = mItemCount > 0 ? 0 : INVALID_POSITION;

        setSelectedPositionInt(position);
        setNextSelectedPositionInt(position);

        if (mItemCount == 0) {
            // Nothing selected
            checkSelectionChanged();
        }

    } else {
        checkFocus();
        resetList();
        // Nothing selected
        checkSelectionChanged();
    }

    requestLayout();
}

現在我們已經知道 AbsSpinner 的作用了,是時候看看 Spinner 了。

Spinner

public class Spinner extends AbsSpinner implements OnClickListener 

目前前面兩者都各有處理的事項,包括資料的存取與 UI 的更新。 但 Spinner 的功能最主要就是顯示選單上的選項。為此, Spinner 提供了兩種選單的顯示模式 (MODE_DIALOGMODE_DROPDOWN)。 根據不同的顯示模式 Spinner 需要用到不同的類別與參數。

我們簡單地介紹一下 Spinner 中的主要參數、類別與功能吧。

參數功能
Context mPopupContext這是 dialog 或 popup 進行 inflation 所需要的內容
ForwardingListener mForwardingListener這是一個繼承 View.OnTouchListenerView.OnAttachStateChangeListener 的抽象類別。

他的主要功能是接收 Spinner 的 onTouch(Event) 並按情況決定是否要處理此 Event。 我之後會講解一下。
SpinnerPopup mPopup這是一個負責顯示選單的介面。通過這個介面,Spinner 實現了 DialogPopupDropdownPopup。由於他的實作很值得去了解,所以會在下一個章節特別研究。

ForwardingListener

public abstract class ForwardingListener
        implements View.OnTouchListener, View.OnAttachStateChangeListener

這個 Listener 會通過 onTouch(View, Event) 來決定是否要將此 Event 傳遞下去:

從流程便可以知道其實就知道 forward 與否取決於 Popup 是否有顯示或將 event 處理掉。

Spinner 建構子

我們要看一下 Spinner 變數的預設,這就需要從建構子中看了:

public Spinner(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes, int mode,
        Theme popupTheme) {

    // 1. defStyleAttr 預設為 com.android.internal.R.attr.spinnerStyle
    super(context, attrs, defStyleAttr, defStyleRes);

    final TypedArray a = context.obtainStyledAttributes(
            attrs, R.styleable.Spinner, defStyleAttr, defStyleRes);
    
    saveAttributeDataForStyleable(context, R.styleable.Spinner,
            attrs, a, defStyleAttr, defStyleRes);

    // 2. 創建 mPopupContext
    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;
        }
    }

    // 3. 預設 mode = MODE_THEME,所以預設 Spinner 會以 Dialog 方式顯示 popup
    if (mode == MODE_THEME) {
        mode = a.getInt(R.styleable.Spinner_spinnerMode, MODE_DIALOG);
    }

    // 4. 創建對應的 mPopup
    switch (mode) {
        case MODE_DIALOG: {
            mPopup = new DialogPopup();
            mPopup.setPromptText(a.getString(R.styleable.Spinner_prompt));
            break;
        }

        case MODE_DROPDOWN: {

            // 5. 這裡會創建 DropdownPopup
            //      其中的 mDropDownWidth 是 WRAP_CONTENT
            //      
            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;
    }
}

SpinnerPopup

就如之前所說, SpinnerPopup 是一個負責顯示 popup 的介面。 想要知道如何實作就需要看看 DropdownPopupDialogPopup 了。

private interface SpinnerPopup {
    public void setAdapter(ListAdapter adapter);
    public void show(int textDirection, int textAlignment);
    public void dismiss();

    @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.R, trackingBug = 170729553)
    public boolean isShowing();

    public void setPromptText(CharSequence hintText);
    public CharSequence getHintText();

    public void setBackgroundDrawable(Drawable bg);
    public void setVerticalOffset(int px);
    public void setHorizontalOffset(int px);
    public Drawable getBackground();
    public int getVerticalOffset();
    public int getHorizontalOffset();
}

DialogPopup

private class DialogPopup implements SpinnerPopup, DialogInterface.OnClickListener

DialogPopup 在調用 show 時會直接顯示一個 AlertDialog 。 也因為如此,他在以下方法只需要回傳預設行為或值即可:

public void setBackgroundDrawable(Drawable bg);
public void setVerticalOffset(int px);
public void setHorizontalOffset(int px);
public Drawable getBackground();
public int getVerticalOffset();
public int getHorizontalOffset();

重點在 show()onClick(DialogInterface, int)。 通過 show,一個擁有 ListView 的 AlertDialog 便可被展現出來:

public void show(int textDirection, int textAlignment) {
    if (mListAdapter == null) {
        return;
    }
    AlertDialog.Builder builder = new AlertDialog.Builder(getPopupContext());
    if (mPrompt != null) {
        builder.setTitle(mPrompt);
    }
    // 預設為 SingleChoiceItems,並將 ListAdapter 傳給 AlertController
    mPopup = builder.setSingleChoiceItems(mListAdapter,
            getSelectedItemPosition(), this).create();

    // 有了 ListAdapter,AlertController 便會創建一個 RecycleListView: ListView
    // 其實他就是一個 ListView 並沒看到實作任何回收機制。
    final ListView listView = mPopup.getListView();

    // 進行 Text 顯示的設定
    listView.setTextDirection(textDirection);
    listView.setTextAlignment(textAlignment);
    mPopup.show();
}

public void onClick(DialogInterface dialog, int which) {
    // 保存被挑選者。 這是由 AbsSpinner 實現的。
    setSelection(which);
    
    // 這裡會傳回給 AdapterView 來處理。
    // 若 AdapterView 中有 OnItemClickListener 那就會啟動音效並調用他的  
    // OnItemClickListener (AdapterView<?> parent, View view, int position, long id)
    if (mOnItemClickListener != null) {
        performItemClick(null, which, mListAdapter.getItemId(which));
    }
    dismiss();
}
private class DropdownPopup extends ListPopupWindow implements SpinnerPopup

簡簡單單的一行其實含金量很多。 DropdownPopup 其實是由兩個類別組成的:

類別功能
PopupWindow他的主要功能就是客製化並顯示 popup。 而客製化則包括以下:
-動畫
- anchor 點
- 背景
- ContentView
-Elevate
等等…
ListPopupWindow這裡包含兩個重要參數: DropDownListViewPopupWindow
他的功能是通過這兩者進行互動與顯示。
DropdownPopup這類別擁有一個 ListAdapter,通過他 DropdownPopup 可以取得 itemView 的大小。

他的作用我們會在 Spinner 的顯示流程中繼續探討。

Spinner 的顯示流程

Spinner 的創建

當畫面被創建時, Android 會通過 xml 知道 Spinner 需要被創建,然後就會經過以下流程:

當我們在創建時,我們可以通過 AdapterCompatSpinnerSpinnersetAdapter(SpinnerAdapter) 將 Adapter 傳入。

傳入的時候, 這個 Adapter 會在不同 Spinner 類別進行不同的行為。

  • AbsSpinner 中,他會對這個 Adapter 進行 registerDataSetObserver
  • Spinner 中,他先傳給 AbsSpinner。 之後會創建一個 Spinner.DropDownAdapter :
      private static class DropDownAdapter implements ListAdapter, SpinnerAdapter {
          private SpinnerAdapter mAdapter;
          private ListAdapter mListAdapter;
          // ... rest of the code
      }
    


    在創建之後,他會先被傳入 ListPopupWindow 進行 registerDataObserver,再設為 DropdownPopupmAdapter

      private class DropdownPopup extends ListPopupWindow implements SpinnerPopup {
          private CharSequence mHintText;
          private ListAdapter mAdapter;
      }
    
  • AppCompatSpinner 中,他會先傳給 Spinner。 之後會創建一個 AppCompatSpinner.DropDownAdapter
      private static class DropDownAdapter implements ListAdapter, SpinnerAdapter {
          private SpinnerAdapter mAdapter;
          private ListAdapter mListAdapter;
      }
    
      @VisibleForTesting
      class DropdownPopup extends ListPopupWindow implements SpinnerPopup {
          private CharSequence mHintText;
          ListAdapter mAdapter;
          private final Rect mVisibleRect = new Rect();
          private int mOriginalHorizontalOffset;
      }
    

    基本上與 Spinner 一樣。

從他們的行為我們可以看出, AppCompatSpinner 與 Spinner 雖然共用一個 Adapter,但他們各自會有自己的 DropdownPopup

Spinner 的顯示

我將 onMeaureonLayoutonDraw 的流程分開來看。

首先就是 onMeasure

從流程你可以看見他與 itemView 的 height 相比,更注重 itemView 的 width。 而 Spinner 也會從 SpinnerAdapter 取得 itemView 來進行測量 。

值得注意的是,在 Measure 流程中, Spinner 會在 makeView() 中通過我們傳入的 Adapter,調用他的 mAdapter.getView(selectedPosition, null, this); 來取得第一個 itemView:

object : ArrayAdapter<String?>(this, android.R.layout.simple_spinner_item, resources.getStringArray(R.array.planets_array)) {

    // 這個方法主要是影響 width 的計算
    override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
        return super.getView(position, convertView, parent)
    }
}

這個 itemView 主要是用來取得 baseline。

進入 onMeasure 後,Spinner 會通過多次的 measureContentWidth(SpinnerAdapter, Drawable) 調用跟 Adapter 取得 selectedItemPosition 至 end 的全部 itemView 的 最大寬度 來設定 Spinner 的顯示寬度。

但這還沒完,之後 AppCompatSpinner 還會在 onMeasure 中通過 compatMeasureContentWidth 進行相同行為。

從兩者的 onMeasure 可以看出其實行為是一樣的:

// Spinner 
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    super.onMeasure(widthMeasureSpec, heightMeasureSpec);
    if (mPopup != null && MeasureSpec.getMode(widthMeasureSpec) == MeasureSpec.AT_MOST) {
        final int measuredWidth = getMeasuredWidth();
        setMeasuredDimension(
            Math.min(Math.max(measuredWidth,
                              measureContentWidth(getAdapter(), getBackground())),
                     MeasureSpec.getSize(widthMeasureSpec)),
           getMeasuredHeight());
    }
}

// AppCompatSpinner
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    super.onMeasure(widthMeasureSpec, heightMeasureSpec);

    if (mPopup != null && MeasureSpec.getMode(widthMeasureSpec) == MeasureSpec.AT_MOST) {
        final int measuredWidth = getMeasuredWidth();
        setMeasuredDimension(
            Math.min(Math.max(measuredWidth,
                              compatMeasureContentWidth(getAdapter(), getBackground())),
                     MeasureSpec.getSize(widthMeasureSpec)),
            getMeasuredHeight());
    }
}
相同的,我們來比較 Spinner 的 measureContentWidth 與 AppCompatSpinner 的 compatMeasureContentWidth 也會發現是一模一樣。


// Spinner 
int measureContentWidth(SpinnerAdapter adapter, Drawable background) {
    if (adapter == null) {
        return 0;
    }

    int width = 0;
    View itemView = null;
    int itemType = 0;
    final int widthMeasureSpec =
        MeasureSpec.makeSafeMeasureSpec(getMeasuredWidth(), MeasureSpec.UNSPECIFIED);
    final int heightMeasureSpec =
        MeasureSpec.makeSafeMeasureSpec(getMeasuredHeight(), MeasureSpec.UNSPECIFIED);

    // Make sure the number of items we'll measure is capped. If it's a huge data set
    // with wildly varying sizes, oh well.
    int start = Math.max(0, getSelectedItemPosition());
    final int end = Math.min(adapter.getCount(), start + MAX_ITEMS_MEASURED);
    final int count = end - start;
    start = Math.max(0, start - (MAX_ITEMS_MEASURED - count));
    for (int i = start; i < end; i++) {
        final int positionType = adapter.getItemViewType(i);
        if (positionType != itemType) {
            itemType = positionType;
            itemView = null;
        }
        itemView = adapter.getView(i, itemView, this);
        if (itemView.getLayoutParams() == null) {
            itemView.setLayoutParams(new ViewGroup.LayoutParams(
                    ViewGroup.LayoutParams.WRAP_CONTENT,
                    ViewGroup.LayoutParams.WRAP_CONTENT));
        }
        itemView.measure(widthMeasureSpec, heightMeasureSpec);
        width = Math.max(width, itemView.getMeasuredWidth());
    }

    // Add background padding to measured width
    if (background != null) {
        background.getPadding(mTempRect);
        width += mTempRect.left + mTempRect.right;
    }

    return width;
}


// AppCompatSpinner
int compatMeasureContentWidth(SpinnerAdapter adapter, Drawable background) {
    if (adapter == null) {
        return 0;
    }

    int width = 0;
    View itemView = null;
    int itemType = 0;
    final int widthMeasureSpec =
            MeasureSpec.makeMeasureSpec(getMeasuredWidth(), MeasureSpec.UNSPECIFIED);
    final int heightMeasureSpec =
            MeasureSpec.makeMeasureSpec(getMeasuredHeight(), MeasureSpec.UNSPECIFIED);

    // Make sure the number of items we'll measure is capped. If it's a huge data set
    // with wildly varying sizes, oh well.
    int start = Math.max(0, getSelectedItemPosition());
    final int end = Math.min(adapter.getCount(), start + MAX_ITEMS_MEASURED);
    final int count = end - start;
    start = Math.max(0, start - (MAX_ITEMS_MEASURED - count));
    for (int i = start; i < end; i++) {
        final int positionType = adapter.getItemViewType(i);
        if (positionType != itemType) {
            itemType = positionType;
            itemView = null;
        }
        itemView = adapter.getView(i, itemView, this);
        if (itemView.getLayoutParams() == null) {
            itemView.setLayoutParams(new LayoutParams(
                    LayoutParams.WRAP_CONTENT,
                    LayoutParams.WRAP_CONTENT));
        }
        itemView.measure(widthMeasureSpec, heightMeasureSpec);
        width = Math.max(width, itemView.getMeasuredWidth());
    }

    // Add background padding to measured width
    if (background != null) {
        background.getPadding(mTempRect);
        width += mTempRect.left + mTempRect.right;
    }

    return width;
}
當然, AbsSpinneronMeasure 也是有類似行為。 但他會處理 padding,並且會確保資料變更已完畢才計算寬與高度。
// AbsSpinner
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    int widthMode = MeasureSpec.getMode(widthMeasureSpec);
    int widthSize;
    int heightSize;

    mSpinnerPadding.left = mPaddingLeft > mSelectionLeftPadding ? mPaddingLeft
            : mSelectionLeftPadding;
    mSpinnerPadding.top = mPaddingTop > mSelectionTopPadding ? mPaddingTop
            : mSelectionTopPadding;
    mSpinnerPadding.right = mPaddingRight > mSelectionRightPadding ? mPaddingRight
            : mSelectionRightPadding;
    mSpinnerPadding.bottom = mPaddingBottom > mSelectionBottomPadding ? mPaddingBottom
            : mSelectionBottomPadding;

    if (mDataChanged) {
        handleDataChanged();
    }

    int preferredHeight = 0;
    int preferredWidth = 0;
    boolean needsMeasuring = true;

    int selectedPosition = getSelectedItemPosition();
    if (selectedPosition >= 0 && mAdapter != null && selectedPosition < mAdapter.getCount()) {
        // Try looking in the recycler. (Maybe we were measured once already)
        View view = mRecycler.get(selectedPosition);
        if (view == null) {
            // Make a new one
            view = mAdapter.getView(selectedPosition, null, this);

            if (view.getImportantForAccessibility() == IMPORTANT_FOR_ACCESSIBILITY_AUTO) {
                view.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_YES);
            }
        }

        if (view != null) {
            // Put in recycler for re-measuring and/or layout
            mRecycler.put(selectedPosition, view);

            if (view.getLayoutParams() == null) {
                mBlockLayoutRequests = true;
                view.setLayoutParams(generateDefaultLayoutParams());
                mBlockLayoutRequests = false;
            }
            measureChild(view, widthMeasureSpec, heightMeasureSpec);

            preferredHeight = getChildHeight(view) + mSpinnerPadding.top + mSpinnerPadding.bottom;
            preferredWidth = getChildWidth(view) + mSpinnerPadding.left + mSpinnerPadding.right;

            needsMeasuring = false;
        }
    }

    if (needsMeasuring) {
        // No views -- just use padding
        preferredHeight = mSpinnerPadding.top + mSpinnerPadding.bottom;
        if (widthMode == MeasureSpec.UNSPECIFIED) {
            preferredWidth = mSpinnerPadding.left + mSpinnerPadding.right;
        }
    }

    preferredHeight = Math.max(preferredHeight, getSuggestedMinimumHeight());
    preferredWidth = Math.max(preferredWidth, getSuggestedMinimumWidth());

    heightSize = resolveSizeAndState(preferredHeight, heightMeasureSpec, 0);
    widthSize = resolveSizeAndState(preferredWidth, widthMeasureSpec, 0);

    setMeasuredDimension(widthSize, heightSize);
    mHeightMeasureSpec = heightMeasureSpec;
    mWidthMeasureSpec = widthMeasureSpec;
}


再來就是進行 onLayout

onLayout 這裡 Spinner 只會顯示被選中的 View。 也是因為如此, onMeasure 中會比較介意 itemView 的寬度。

由於通過以上流程, childView 的所在位置都已經確定了。 所以無需特別進行 onDraw

onLayout 流程中, Spinner 會在 layout 中通過 makeViewAdapter.getView 取得 mSelectedPosition 的 itemView。 當然, AppCompatSpinner 也會再次重複這個行為。

除了找出目前被挑選的 itemView 外,在這個流程中還會多次調用 handleDataChanged()checkSelectionChanged() 來確定 selection 或 資料是否有更新。 一旦資料更新, Spinner 會通過 OnItemSelectedListenerSelectionNotifier 進行通知。

因為在不知道誰被選取時,預設都會是 0 ,所以我們第一次收到的就是 item position == 0。

Spinner 點擊行為

現在 Spinner 已被顯示,接下來就看看他被點擊後的行為。

我們可以看見當我們點選 Spinner 後, Spinner 會通過 SpinnerPopup mPopup ,也就是在創建時創建的 DropdownPopup ,算出最大寬度並設定 content width。

在設定好後, ListPopupWindow 會負責計算出 popup 的 高度。在計算高度時, ListPopupWindow 會創建並設定 DropdownListView。而高度的計算有以下考量:

  • DropdownListView 的 list content
  • hint view
  • background

算好高度後,ListPopupWindow 還會查看是否有 Input Method, 並重新設定 AppCompatPopupWindow 的 hight 與 width spec。

最後就會通過 showAsDropDown 將 AppCommpatPopupWindow 顯示出來:

PopupWindowCompat.showAsDropDown(mPopup, getAnchorView(), mDropDownHorizontalOffset,    
    mDropDownVerticalOffset, mDropDownGravity);

也就是說,這個流程包含了以下重要的角色:

  • DropdownPopup : 負責從 SpinnerAdapter 中取得 itemView 並計算出最大寬度
  • ListPopupWindow : 負責畫面的建設,包括創造與設定 DropdownListView
  • DropdownListView : 負責互動的行為
  • PopupWindow : 負責最後將 ListPopupWindow 顯示在 Window 上

這些物件的關係如下:

  • ListPopupWindow 會包含一個 PopupWindow 以及一個 DropdownListView。
  • Spinner 則會有一個 DropdownPopup,一個 ListPopupWindow 的子類別。 而 DropdownPopup 會將互動行為委託 Adpaters 來執行。

統整一下,他們的關係如下:

Spinner 點選的行為

此時 Spinner 已被展開,這裡我們看看當我們點選 itemView 會發生什麼事吧。

這裡的流程很簡單,當我們 touch 時,他會通過 PopupWindow.PopupDecorView 來判斷是否在 DecorView 之內。 若不在之內,就會調用 dismiss() 關閉 PopupWindow。 若在之內,就會傳給 PopupDecorView 的 content,也就是 PopupWindow.PopupBackgroundView

但 PopupBackgroundView 只有覆寫了 onCreateDrawableState(int) ,其他都是 FrameLayout 的預設行為。所以最後會將 touch 拋給 DropdownListViewAbsListView 來處理。

如果只看這流程表,也許你會以為點選的行為會由 DropdownPopup.OnItemClickListener 傳遞回我們設定的 OnItemClickListener。 但其實這是錯誤的。

當我們手指按下時, AbsListView 會通過 onInterceptTouchEvent 進行攔截並更新 mMotionPosition

int motionPosition = findMotionRow(y);

// ListView
@Override
int findMotionRow(int y) {
    int childCount = getChildCount();
    if (childCount > 0) {
        if (!mStackFromBottom) {
            for (int i = 0; i < childCount; i++) {
                View v = getChildAt(i);
                if (y <= v.getBottom()) {
                    return mFirstPosition + i;
                }
            }
        } else {
            for (int i = childCount - 1; i >= 0; i--) {
                View v = getChildAt(i);
                if (y >= v.getTop()) {
                    return mFirstPosition + i;
                }
            }
        }
    }
    return INVALID_POSITION;
}

之後,AbsListView 會在 onTouchDown 中創建一個 CheckForTabRunnable 來處理 LongPress 的行為 :

private final class CheckForTap implements Runnable {
    float x;
    float y;

    @Override
    public void run() {
        if (mTouchMode == TOUCH_MODE_DOWN) {
            mTouchMode = TOUCH_MODE_TAP;
            final View child = getChildAt(mMotionPosition - mFirstPosition);
            if (child != null && !child.hasExplicitFocusable()) {
                mLayoutMode = LAYOUT_NORMAL;

                if (!mDataChanged) {
                    final float[] point = mTmpPoint;
                    point[0] = x;
                    point[1] = y;
                    transformPointToViewLocal(point, child);
                    child.drawableHotspotChanged(point[0], point[1]);
                    child.setPressed(true);
                    setPressed(true);
                    layoutChildren();
                    positionSelector(mMotionPosition, child);
                    refreshDrawableState();

                    final int longPressTimeout = ViewConfiguration.getLongPressTimeout();
                    final boolean longClickable = isLongClickable();

                    if (mSelector != null) {
                        final Drawable d = mSelector.getCurrent();
                        if (d != null && d instanceof TransitionDrawable) {
                            if (longClickable) {
                                ((TransitionDrawable) d).startTransition(longPressTimeout);
                            } else {
                                ((TransitionDrawable) d).resetTransition();
                            }
                        }
                        mSelector.setHotspot(x, y);
                    }

                    if (longClickable) {
                        if (mPendingCheckForLongPress == null) {
                            mPendingCheckForLongPress = new CheckForLongPress();
                        }
                        mPendingCheckForLongPress.setCoords(x, y);
                        mPendingCheckForLongPress.rememberWindowAttachCount();
                        postDelayed(mPendingCheckForLongPress, longPressTimeout);
                    } else {
                        mTouchMode = TOUCH_MODE_DONE_WAITING;
                    }
                } else {
                    mTouchMode = TOUCH_MODE_DONE_WAITING;
                }
            }
        }
    }
}

同時,他也會在 onTouchDown 中更新 mMotionPosition

當我們手指起來時,他會先調用 AbsListView.onTouchUp。 這方法中,他會創建一個 mTouchModeReset 的 Runnable 來負責點選的行為:

mTouchModeReset = new Runnable() {
    @Override
    public void run() {
        mTouchModeReset = null;
        mTouchMode = TOUCH_MODE_REST;
        child.setPressed(false);
        setPressed(false);
        if (!mDataChanged && !mIsDetaching
                && isAttachedToWindow()) {
            performClick.run();
        }
    }
};
postDelayed(mTouchModeReset,
        ViewConfiguration.getPressedStateDuration());

run() 被調用時,他會進行 performClick.run()

private class PerformClick extends WindowRunnnable implements Runnable {
    int mClickMotionPosition;

    @Override
    public void run() {
        // The data has changed since we posted this action in the event queue,
        // bail out before bad things happen
        if (mDataChanged) return;

        final ListAdapter adapter = mAdapter;
        final int motionPosition = mClickMotionPosition;
        if (adapter != null && mItemCount > 0 &&
                motionPosition != INVALID_POSITION &&
                motionPosition < adapter.getCount() && sameWindow() &&
                adapter.isEnabled(motionPosition)) {
            final View view = getChildAt(motionPosition - mFirstPosition);
            // If there is no view, something bad happened (the view scrolled off the
            // screen, etc.) and we should cancel the click
            if (view != null) {
                performItemClick(view, motionPosition, adapter.getItemId(motionPosition));
            }
        }
    }
}

重點在 PerformClick 中的 int mClickMotionPosition。 這個參數會在 onTouchUp 時進行更新 :

final AbsListView.PerformClick performClick = mPerformClick;
performClick.mClickMotionPosition = motionPosition;
performClick.rememberWindowAttachCount();

也因為如此,所以最後 PerformClick 被進行時,才可以將挑選的 index 傳出去。

以下便是如何將 selected position 傳遞出去的流程:

最後 Spinner 會在 layout 時,由於 mSelectedPosition 更新了,所以便會通過 AdapterView.selectionChanged 通知 SelectionNotifier 調用 dispatchOnItemSelected 進而通知我們設定的 OnItemSelectedListener。

override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
    selectedPosition = position
    Toast.makeText(this, "Selected ${parent?.selectedItem}", Toast.LENGTH_SHORT).show()
}

FAQ

如何在點擊選項時顯示不同的顏色?

如果我們看 官方的資料 你會想說使用 android:dropDownSelector 應該是可行的。

但你會錯得非常離譜。 當然,錯不在你,而是 Google。 這個屬性從 2012 年就存在了,但這個 bug 也從未離開過。

之所以會如此,我們就要暸解 Spinner 顯示的流程了。

我先給急著想要答案的各位一個解答吧。 我們所要做的就是在 theme.xml 中定義客製化 theme 並將他指定為 android:dropDownListViewStyle:

<style name="Base.Theme.SpinnerDemo" parent="Theme.Material3.DayNight.NoActionBar">
    <item name="android:dropDownListViewStyle">@style/My.Theme.Spinner</item>
</style>

<style name="My.Theme.Spinner" parent= "android:Widget.ListView">
    <item name="android:divider">@null</item>
    <item name="android:listSelector">@drawable/spinner_selector</item>
</style>

接下來就是 解釋 了。

首先,當我們在 xml 中設定 android:dropDownSelector 時,這個設定的確會通過 mDropDownList.setSelector(Drawable) 傳給 DropdownPopup 。 記住,是 DropdownPopup

但是當我們按下 Spinner,他會通過以下流程創建 DropDownListView

DropDownListView 才是會影響選項被點選時的反應。

從他的建構子可以看出他會帶入一個 defStyleAttr R.attr.dropDownListViewStyle

DropDownListView(@NonNull Context context, boolean hijackFocus) {
    super(context, null, R.attr.dropDownListViewStyle);
    mHijackFocus = hijackFocus;
    setCacheColorHint(0); // Transparent, since the background drawable could be anything.
}

後來在建構 AbsListView 時便會從這個 defStyleAttr 取得 selector :

final Drawable selector = a.getDrawable(R.styleable.AbsListView_listSelector);
if (selector != null) {
    setSelector(selector);
}

而按照我此時所使用的主題, Theme.Material3.DayNight.NoActionBar,通過順籐摸瓜,我們便可找到他的父類別中的 dropDownListViewStyle 的定義了:

<style name="Base.V7.Theme.AppCompat.Light" parent="Platform.AppCompat.Light">
    <!-- 其他屬性 -->
    <item name="dropDownListViewStyle">?android:attr/dropDownListViewStyle</item>
    <!-- 其他屬性 -->
</style>

?android:attr/dropDownListViewStyle 表示使用 Android build-in 的 attr:

<style name="Theme.Holo.Light" parent="Theme.Light">
    <!-- ... -->
    <item name="dropDownListViewStyle">@style/Widget.Holo.ListView.DropvDown</item>
    <item name="listChoiceBackgroundIndicator">@drawable/list_selector_holo_light</item>
</style>

繼續追下去就會找到:

<style name="Widget.Holo.ListView" parent="Widget.ListView">
    <item name="divider">?attr/listDivider</item>
    <item name="listSelector">?attr/listChoiceBackgroundIndicator</item>
</style>

如何在顯示 Spinner Dropdown 時標出上次的選項?

這個很簡單,我們只需要定義 AdapterView.OnItemSelectedListener 與 覆寫 Adapter 的 getDropDownView(position: Int, convertView: View?, parent: ViewGroup)

之所以使用這兩者即可是因為他的職責如下:

  • getDropDownView : 定義 DropdownListView 中的 itemView 並回傳。 這是在顯示前最後客製化的時機。
  • OnItemSelectedListener : 這會在 Spinner 重新 layout 時調用,並將當下 selected position 傳回。

範例:


object : ArrayAdapter<String?>(this, android.R.layout.simple_spinner_item, resources.getStringArray(R.array.planets_array)) {
    override fun getDropDownView(
        position: Int,
        convertView: View?,
        parent: ViewGroup
    ): View {

        val v =  super.getDropDownView(position, convertView, parent)

        // 進行標記
        if (position == selectedPosition) {
            v.setBackgroundColor(ResourcesCompat.getColor(resources, android.R.color.holo_blue_dark, theme))
        }

        return v
    }
}.also {
    it.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
    spinner.adapter = it
}

// OnItemSelectedListener
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
    selectedPosition = position
    Toast.makeText(this, "Selected ${parent?.selectedItem}", Toast.LENGTH_SHORT).show()
}

override fun onNothingSelected(parent: AdapterView<*>?) {
    Toast.makeText(this, "NothingSelected", Toast.LENGTH_SHORT).show()
}







About Post

Search

    Table of Contents