#BottomNavigationView使用及原始碼分析

叢逍遙發表於2017-11-03

BottomNavigationView是design包提供的底部導航欄,樣子跟市面上常見的底欄差不多,但是點選的時候會帶有一點動畫效果,放張圖:

三個選項的底欄
三個選項的底欄

BottomNavigationView是構建在系統的menu模組之上的,所以可以通過配置menu檔案的方式使用它,但是,BottomNavigationView最多隻支援五個選項,當選項數量為四個或五個時,表現出的效果是與三個不同的,看圖:
四個選項的底欄
四個選項的底欄

圖示在點選的時候會有漂移動畫,沒有被選中的選單項是沒有標題文字的。如果你想讓他的表現的像是三個選項時那樣,可以參考這篇文章來操作一下,其間用到了一次反射,所以大家自行斟酌。下面講解使用方式。

BottomNavigationView使用方式

首先保證design包被專案引入

compile 'com.android.support:design:26.0.0-alpha1'複製程式碼

之後建立menu資原始檔,以上圖為例:
/res/menu/bottom_navigation.xml中加入如下程式碼

<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">

    <item android:title="camera"
        android:icon="@drawable/ic_camera_black_24dp"
        android:id="@+id/menu_camera"/>

    <item android:title="palette"
        android:icon="@drawable/ic_palette_black_24dp"
        android:id="@+id/menu_palette"/>

    <item android:title="security"
        android:icon="@drawable/ic_security_black_24dp"
        android:id="@+id/menu_security"/>

    <item android:title="setting"
        android:icon="@drawable/ic_settings_black_24dp"
        android:id="@+id/menu_setting"/>
</menu>複製程式碼

在佈局檔案中使用BottomNavigationView:

    <android.support.design.widget.BottomNavigationView
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        app:menu="@menu/bottom_navigation"
        android:background="?android:attr/windowBackground"
        android:id="@+id/bottom_navigation_view"/>複製程式碼

只需要使用app:menu="@menu/bottom_navigation"把選單配置進來就可以看到gif中的效果了。BottomNavigationView為我們提供了幾個自定義屬性

  • itemIconTint 圖示著色,圖示選中/未選中時的顏色
  • itemTextColor 文字著色,選項文字選中/未選中時的顏色
  • itemBackground 選項背景,就是gif中的ripple效果

以圖示著色為例,在res/color/bottom_nav_icon_color.xml中新增如下程式碼:

<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:state_checked="true"
        android:color="@color/colorAccent"/>
    <item android:state_checked="false"
        android:color="@android:color/black"/>
</selector>複製程式碼

selector中只需要定義state_checked為true/false的item就可以了,BottomNavigationView只會用到這兩種狀態,所以上述程式碼會將選中的圖示染為colorAccent,未選中染為黑色。itemTextColor 與他的定義方式完全一樣,就不貼程式碼了。如果對選項的background不滿意,可以自行定義drawable,舉個例子:
res/drawable-v21/item_background.xml中加入如下程式碼

<?xml version="1.0" encoding="utf-8"?>
<ripple xmlns:android="http://schemas.android.com/apk/res/android"  
        android:color="@android:color/holo_red_light">
    </ripple>複製程式碼

res/drawable/item_background.xml中加入如下程式碼

<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
    android:shape="rectangle">
    <solid android:color="@android:color/holo_red_light" />
</shape>複製程式碼

什麼,你問我為什麼寫兩套drawable?哈哈哈哈哈哈哈哈哈...嗝

之後我們把寫過的資源都配置進去,就可以看到下面這個小可愛啦!

    app:itemIconTint="@color/bottom_nav_icon_color"
    app:itemTextColor="@color/bottom_nav_text_color"
    app:itemBackground="@drawable/item_background"複製程式碼

小可愛
小可愛

好了我承認這一點也不可愛,而且配置很麻煩,所以這裡給出一種稍微簡單點的配置方式,但不能像上面那種可以控制那麼多細節,大概是這個樣子:

就醬
就醬

如果你能接受每個Item的圖示與文字顏色時刻保持一致的話,可以考慮如下配置:
res/values/styles.xml中新增一個style

    <style name="MyBottomNavigationStyle" parent="Widget.Design.BottomNavigationView">
        //ripple的顏色
        <item name="colorControlHighlight">@android:color/holo_red_light</item>
        //選中時的顏色
        <item name="colorPrimary">@android:color/holo_green_dark</item>
        //未選中的顏色
        <item name="android:textColorSecondary" >@android:color/black</item>
    </style>複製程式碼

之後將這個style配置進去就好了,程式碼如下:

    <android.support.design.widget.BottomNavigationView
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        app:menu="@menu/bottom_navigation"
        android:theme="@style/MyBottomNavigationStyle"
        android:background="?android:attr/windowBackground"
        android:id="@+id/bottom_navigation_view"/>複製程式碼

之後就是點選監聽的問題了,看程式碼

        BottomNavigationView navigationView = findViewById(R.id.bottom_navigation_view);
        navigationView.setOnNavigationItemSelectedListener(new BottomNavigationView.OnNavigationItemSelectedListener() {
            @Override
            public boolean onNavigationItemSelected(@NonNull MenuItem item) {
                switch (item.getItemId()) {
                    case R.id.menu_camera:
                        break;
                    case R.id.menu_palette:
                        break;
                }
                return true;
            }
        });複製程式碼

被點選的menuItem會在onNavigationItemSelected方法中回撥,之後根據id做操作就好了,注意,如果此方法返回true,則認為事件被處理,BottomNavigationView將播放選項切換動畫,如果返回false,點選之後是沒有效果的。
BottomNavigationView 還提供了一個OnNavigationItemReselectedListener用於監聽已選中的Item被重複點選的情況,在這種情況下,如果設定了此監聽,BottomNavigationView 將不回撥OnNavigationItemSelectedListener,比如我們可以使用一個OnNavigationItemReselectedListener的空實現來遮蔽item被重複點選的情況。
最後需要說明的是,BottomNavigationView 支援通過程式碼的方式切換選單選項,以上圖舉例,如果我們想切換到palette選單的話:

navigationView.setSelectedItemId(R.id.menu_palette);複製程式碼

傳入選項id即可。

好了下面進入正題。

BottomNavigationView原始碼分析

BottomNavigationView基於Android的Menu框架構建,所以,檢視方面,主要角色為MenuView、ItemView兩個介面,對應的實現分別是BottomNavigationMenuView、BottomNavigationItemView,一個負責選項檢視,一個負責整體佈局。資料及互動處理方面,主要角色為Menu(MenuBuilder)、MenuItem、MenuPresenter三個介面,實現類分別為BottomNavigationMenu、MenuItemImpl、BottomNavigationPresenter。他們之間的依賴關係見UML圖

UML圖
UML圖

至於這到底是不是MVP模式,見仁見智,也不能僅就類圖加以判斷。有幾個類的職責需要先說明一下,MenuBuilder負責儲存item資料以及對外暴露操作介面,Presenter幫助MenuBuilder操作檢視。比如我們通過MenuBuilder獲取一個MenuItem,然後呼叫他的setChecked方法,此時MenuItem會通知MenuBuilder資料更新,之後MenuBuilder就會通過MenuPresenter來操作MenuView,然後MenuView再根據具體情況去操作ItemView完成檢視重新整理。所以BottomNavigationMenuView會維護和操作BottomNavigationItemView,BottomNavigationPresenter會幫助BottomNavigationMenu更新檢視。相信這樣大家就會對整體架構有一個巨集觀的瞭解。

BottomNavigationView

其實剛剛並沒有提到BottomNavigationView,所以我們從這個類入手,瞭解一下整個原始碼的細節。BottomNavigationView的工作不多,主要用於為使用者暴露互動api,比如設定著色、設定背景等等,另外一個作用就是建立上面提到的各種角色。我們來看一下他的建構函式:

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

        ThemeUtils.checkAppCompatTheme(context);

        //1、建立MenuBuilder
        mMenu = new BottomNavigationMenu(context);

        //2、建立MenuView
        mMenuView = new BottomNavigationMenuView(context);
        FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(
                ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
        params.gravity = Gravity.CENTER;
        //wrapContent並居中(BottomNavigationView本身是一個FrameLayout)
        mMenuView.setLayoutParams(params);

        //3、進行注入
        mPresenter.setBottomNavigationMenuView(mMenuView);
        mPresenter.setId(MENU_PRESENTER_ID);
        mMenuView.setPresenter(mPresenter);
        mMenu.addMenuPresenter(mPresenter);
        mPresenter.initForMenu(getContext(), mMenu);

        // 4、解析xml屬性並設定
        TintTypedArray a = TintTypedArray.obtainStyledAttributes(context, attrs,
                R.styleable.BottomNavigationView, defStyleAttr,
                R.style.Widget_Design_BottomNavigationView);

        //省略設定各種屬性的程式碼...
        //大概操作就是如果沒有在xml中配置,就建立預設的

        if (a.hasValue(R.styleable.BottomNavigationView_menu)) {
            //載入選單並建立相應View
            inflateMenu(a.getResourceId(R.styleable.BottomNavigationView_menu, 0));
        }
        a.recycle();

        addView(mMenuView, params);
        if (Build.VERSION.SDK_INT < 21) {
            //5.0以前的在頂部加一個灰色的View當做陰影
            addCompatibilityTopDivider(context);
        }
        //5、監聽選單點選並向外傳遞事件
        mMenu.setCallback(new MenuBuilder.Callback() {...});
    }複製程式碼

程式碼不復雜,但有幾個細節需要注意一下。第一部分中建立的BottomNavigationMenu是MenuBuilder的子類,繼承的目的是為了控制選項數量及設定item的屬性,他覆寫了父類的addInternal方法:

    @Override
    protected MenuItem addInternal(int group, int id, int categoryOrder, CharSequence title) {
        //數量限制
        if (size() + 1 > MAX_ITEM_COUNT) {
            throw new IllegalArgumentException(
                    "Maximum number of items supported by BottomNavigationView is " + MAX_ITEM_COUNT
                            + ". Limit can be checked with BottomNavigationView#getMaxItemCount()");
        }
        stopDispatchingItemsChanged();
        final MenuItem item = super.addInternal(group, id, categoryOrder, title);
        if (item instanceof MenuItemImpl) {
            //設為唯一可點選
            ((MenuItemImpl) item).setExclusiveCheckable(true);
        }
        startDispatchingItemsChanged();
        return item;
    }複製程式碼

此方法在解析menu檔案時被呼叫。第一句的MAX_ITEM_COUNT是5,限制選項個數,多了就拋異常,之後注意這一句
((MenuItemImpl) item).setExclusiveCheckable(true);
將這個Item設定為唯一可選中的,可以理解為將這個選項設定為單選的。舉個例子,對於一個menu group來說,當某個帶有Exclusive標記的Item被點選時,menu框架會自動取消選中其他的帶有Exclusive標記的選項,從而達到單選的目的。對應的我們的BottomNavigationView其實就是這種情況,他在這個方法裡將每個Item設定為ExclusiveCheckable,這樣就很方便的實現一個item被checked,另一個就unchecked的效果了。
大家可能注意到原始碼中的stopDispatchingItemsChanged()startDispatchingItemsChanged()兩個方法了,坦率的講,我是實在沒看出有什麼用,大家也不要糾結了,這鍋26-alpha版本來背。
然後我們回來看第三部分,一通注入,不跟原始碼了,直接解釋一下:
mPresenter.setBottomNavigationMenuView(mMenuView);將MenuView注入到presenter中
mMenuView.setPresenter(mPresenter);將presenter注入到MenuView中
這樣兩者互相持有了。之後是mMenu.addMenuPresenter(mPresenter)將presenter注入到menu中,最後呼叫
mPresenter.initForMenu(getContext(), mMenu)將menu注入到presenter中,這樣他倆也互相持有了,同時presenter會將menu注入到MenuView中,這樣整個流程就結束了,大家可以對照uml圖再捋一遍。
第四部分中傳入的預設style為R.style.Widget_Design_BottomNavigationView,原始碼位置為sdk/extra/android/m2repository/com/android/support/design/26.0.0-alpha1。解壓aar檔案後可以在res/values/values.xml中找到如下定義:

    <style name="Widget.Design.BottomNavigationView" parent="">
        <item name="itemBackground">?attr/selectableItemBackgroundBorderless</item>
        <item name="elevation">@dimen/design_bottom_navigation_elevation</item>
    </style>複製程式碼

所以其實安卓幫我們預設定義了background和elevation,我們才可以直接看到陰影和使用colorControlHighlight來改變ripple的顏色。關於elevation這裡在說一句,原始碼中使用的是ViewCompat的setElevation方法設定的,但在5.0之前的版本,對應的方法是空實現的,所以才會有addCompatibilityTopDivider(context);手動做一步相容處理。在5.0之後的版本,記得一定要給BottomNavigationView設定背景色,否則elevation就無效了。
最後關於inflateMenu(a.getResourceId(R.styleable.BottomNavigationView_menu, 0));這句,涉及的內容比較多,除了載入選單之外,還包括了建立檢視等操作,後面會再提到。
到此為止,BottomNavigationView比較主線的工作就完成了,下面再來看一下BottomNavigationMenuView。

BottomNavigationMenuView

其實它才是我們真正看到的底欄,繼承自ViewGroup,完成對底欄中每一個選項檢視(BottomNavigationItemView)的建立、測量、佈局、更新等操作。下面給出幾個全域性變數的含義:

    private final int mInactiveItemMaxWidth;    //未選中ItemView最大寬度
    private final int mInactiveItemMinWidth;    //未選中ItemView的最小寬度
    private final int mActiveItemMaxWidth;      //選中的ItemView的最大寬度
    private final int mItemHeight;              //ItemView高度
    private final Pools.Pool<BottomNavigationItemView> mItemPool //ItemView回收池
    private boolean mShiftingMode               //是否為漂移模式
    private BottomNavigationItemView[] mButtons;    //儲存ItemView陣列複製程式碼

不出意外的話,前四個變數是設計師給出的引數。從這幾個引數中我們可以大概推斷出設計師的意圖:ItemView的高度是定死的,而寬度的話會比較靈活。由於只給出了選中的ItemView的最大寬度,所以,在漂移模式的情況下,演算法上應儘量讓選中的Item越大越好,但不要超過maxWidth,有些情況下(如橫屏)底欄的空間會很充足,這時候也要對未選中的選項的最大寬度加以限制,避免圖示間距過大。當然這也只是個人的猜測,大家權當參考。我們直接來看一下onMeasure方法:

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        //父佈局寬度
        final int width = MeasureSpec.getSize(widthMeasureSpec);
        //Item個數
        final int count = getChildCount();
        //Item高度佈局引數
        final int heightSpec = MeasureSpec.makeMeasureSpec(mItemHeight, MeasureSpec.EXACTLY);
        //如果為漂移模式
        if (mShiftingMode) {
            final int inactiveCount = count - 1;
            final int activeMaxAvailable = width - inactiveCount * mInactiveItemMinWidth;
            final int activeWidth = Math.min(activeMaxAvailable, mActiveItemMaxWidth);
            final int inactiveMaxAvailable = (width - activeWidth) / inactiveCount;
            final int inactiveWidth = Math.min(inactiveMaxAvailable, mInactiveItemMaxWidth);
            int extra = width - activeWidth - inactiveWidth * inactiveCount;
            for (int i = 0; i < count; i++) {
                mTempChildWidths[i] = (i == mSelectedItemPosition) ? activeWidth : inactiveWidth;
                if (extra > 0) {
                    mTempChildWidths[i]++;
                    extra--;
                }
            }
        //非漂移模式
        } else {
            final int maxAvailable = width / (count == 0 ? 1 : count);
            final int childWidth = Math.min(maxAvailable, mActiveItemMaxWidth);
            int extra = width - childWidth * count;
            for (int i = 0; i < count; i++) {
                mTempChildWidths[i] = childWidth;
                if (extra > 0) {
                    mTempChildWidths[i]++;
                    extra--;
                }
            }
        }
        //呼叫每一個子View的measure確立子佈局寬高
        int totalWidth = 0;
        for (int i = 0; i < count; i++) {
            final View child = getChildAt(i);
            if (child.getVisibility() == GONE) {
                continue;
            }
            child.measure(MeasureSpec.makeMeasureSpec(mTempChildWidths[i], MeasureSpec.EXACTLY),
                    heightSpec);
            ViewGroup.LayoutParams params = child.getLayoutParams();
            params.width = child.getMeasuredWidth();
            totalWidth += child.getMeasuredWidth();
        }
        //確立自己的寬高
        setMeasuredDimension(
                View.resolveSizeAndState(totalWidth,
                        MeasureSpec.makeMeasureSpec(totalWidth, MeasureSpec.EXACTLY), 0),
                View.resolveSizeAndState(mItemHeight, heightSpec, 0));
    }複製程式碼

大家可以看到,漂移模式跟非漂移模式的寬度測量方式是不同的,通過mShiftingMode控制。有沒有很疑惑mShiftingMode在什麼時機被賦值的?坦率的講,mShiftingMode一定在onMeasure之前就被賦值了,而且觸發的時機就是前面沒有詳細解釋的inflate方法。但按照我們正常的編碼思路,mShiftingMode完全可以在onMeasure方法中根據引數count來決定,而且有很高的安全性,但Google為什麼沒有這樣做?還記得開頭提到的修改樣式的那篇文章嗎,有沒有想過憑什麼簡簡單單的反射一個變數就能把樣式改了?為什麼那波操作看似驚心動魄卻又穩如狗?細思極恐了吧。扯遠了,下面我們來分析一下寬度計算的演算法,以漂移模式為例,我把程式碼摘出來:

    //未選中的item的數量
    final int inactiveCount = count - 1;
    //先根 據未選中的item 的最小寬度來計算一個 選中的item 的寬度
    final int activeMaxAvailable = width - inactiveCount * mInactiveItemMinWidth;
    //如果這個寬度太大,則限制為mActiveItemMaxWidth
    final int activeWidth = Math.min(activeMaxAvailable, mActiveItemMaxWidth);
    //選中的item的 寬度 確立下來之後,平分剩餘寬度作為 未選中的item的 寬度
    final int inactiveMaxAvailable = (width - activeWidth) / inactiveCount;
    //但這個寬度可能過大,限制為mInactiveItemMaxWidth
    final int inactiveWidth = Math.min(inactiveMaxAvailable, mInactiveItemMaxWidth);
    //extra部分
    int extra = width - activeWidth - inactiveWidth * inactiveCount;
    for (int i = 0; i < count; i++) {
      mTempChildWidths[i] = (i == mSelectedItemPosition) ? activeWidth : inactiveWidth;
      if (extra > 0) {
        mTempChildWidths[i]++;
        extra--;
        }
    }複製程式碼

前幾行的註釋已經寫的非常清楚了,至於為什麼會出現一個extra部分,我想是在做除法的過程中,可能會產生精度損失,所以理想情況下,extra的值應該為零。大家可以看到,在for迴圈中,每一趟迴圈都會從extra中拿出一個畫素(如果extra一直大於0的話)來彌補這個損失,相當於把不能整除的餘數一個個的分給子View,送完即止。
非漂移的情況下演算法更為簡單,這裡就不再分析了,經過measure之後會在onLayout方法中橫向排列他們,測量和佈局的流程就結束了。下面來看一下buildMenuView方法:

    public void buildMenuView() {
        //移除所有子View
        removeAllViews();
        //回收移除的View
        if (mButtons != null) {
            for (BottomNavigationItemView item : mButtons) {
                mItemPool.release(item);
            }
        }
        if (mMenu.size() == 0) {
            mSelectedItemId = 0;
            mSelectedItemPosition = 0;
            mButtons = null;
            return;
        }
        mButtons = new BottomNavigationItemView[mMenu.size()];
        //在這裡設定ShiftingMode
        mShiftingMode = mMenu.size() > 3;
        for (int i = 0; i < mMenu.size(); i++) {
            //掛起presenter
            mPresenter.setUpdateSuspended(true);
            mMenu.getItem(i).setCheckable(true);
            //啟用presenter
            mPresenter.setUpdateSuspended(false);
            //從緩衝池中獲取或直接new一個BottomNavigationItemView
            BottomNavigationItemView child = getNewItem();
            mButtons[i] = child;
            child.setIconTintList(mItemIconTint);
            child.setTextColor(mItemTextColor);
            child.setItemBackground(mItemBackgroundRes);
            child.setShiftingMode(mShiftingMode);
            //根據MenuItem的資料設定BottomNavigationItemView的顯示效果
            child.initialize((MenuItemImpl) mMenu.getItem(i), 0);
            child.setItemPosition(i);
            //新增點選監聽
            child.setOnClickListener(mOnClickListener);
            addView(child);
        }
        mSelectedItemPosition = Math.min(mMenu.size() - 1, mSelectedItemPosition);
        //將mSelectedItemPosition位置的MenuItem設定為選中狀態,這將會引起檢視更新
        mMenu.getItem(mSelectedItemPosition).setChecked(true);
    }複製程式碼

buildMenuView的主要作用是建立多個BottomNavigationItemView並通過addView新增為自己的子View。與之對應的一個方法是updateMenuView,他會一次性更新所有的BottomNavigationItemView。注意,更新有可能是選單項的新增或刪除引起的,所以每當出現這種情況,他的做法是把所有View都刪掉,重建選單,於是你會看到開頭的第一句。但重建選單很粗暴,會影響效能,於是這裡又引入了一個回收池。這個回收池是v4包提供的一個工具類,還是非常實用的,大家可以嘗試用起來。可能大家注意到了下面這段程式碼:

    //掛起presenter
    mPresenter.setUpdateSuspended(true);
    //設定為可選中的
    mMenu.getItem(i).setCheckable(true);
    //啟用presenter
    mPresenter.setUpdateSuspended(false);複製程式碼

這個掛起顯得非常扎眼,畢竟安卓的UI操作是單執行緒的。而且這個所謂的掛起,只是設定一個Boolean型別的變數mUpdateSuspended

    public void setUpdateSuspended(boolean updateSuspended) {
        mUpdateSuspended = updateSuspended;
    }複製程式碼

那這就顯得很蹊蹺,讓我們看一下“臨界區”中都做了些什麼:

    @Override
    public MenuItem setCheckable(boolean checkable) {
        final int oldFlags = mFlags;
        //根據checkable設定標誌位,位與取反是清空,或操作是設定標誌位
        mFlags = (mFlags & ~CHECKABLE) | (checkable ? CHECKABLE : 0);
        if (oldFlags != mFlags) {
            //看這裡,傳入了false
            mMenu.onItemsChanged(false);
        }

        return this;
    }複製程式碼

在checkable發生變化的情況下會呼叫到mMenu.onItemsChanged,跟進之:

    public void onItemsChanged(boolean structureChanged) {
        if (!mPreventDispatchingItemsChanged) {
            if (structureChanged) {
                mIsVisibleItemsStale = true;
                mIsActionItemsStale = true;
            }
            //structureChanged此時為false
            dispatchPresenterUpdate(structureChanged);
        } else {
            mItemsChangedWhileDispatchPrevented = true;
            if (structureChanged) {
                mStructureChangedWhileDispatchPrevented = true;
            }
        }
    }複製程式碼

onItemsChanged方法中會呼叫到dispatchPresenterUpdate方法:

    private void dispatchPresenterUpdate(boolean cleared) {
        if (mPresenters.isEmpty()) return;

        stopDispatchingItemsChanged();
        for (WeakReference<MenuPresenter> ref : mPresenters) {
            final MenuPresenter presenter = ref.get();
            if (presenter == null) {
                mPresenters.remove(ref);
            } else {
                //看這裡!此時cleared為false
                presenter.updateMenuView(cleared);
            }
        }
        startDispatchingItemsChanged();
    }複製程式碼

之後會呼叫到presenter.updateMenuView(cleared);,繼續跟進

    @Override
    public void updateMenuView(boolean cleared) {
        //因為我剛好遇見你?
        if (mUpdateSuspended) return;
        if (cleared) {
            //mMenuView就是BottomNavigationMenuView
            mMenuView.buildMenuView();
        } else {
            //如果沒有第一句的話,按照clear的值應該會走這裡
            mMenuView.updateMenuView();
        }
    }複製程式碼

我們終於發現了mUpdateSuspended的作用,你應該還沒亂吧?重新梳理一下,在setCheckable()之前,首先呼叫了presenter的setUpdateSuspended()mUpdateSuspended置為false,之後的setCheckable()會輾轉呼叫到presenter的updateMenuView(),此時因為mUpdateSuspended為false,函式直接return了,並沒有執行,否則可能會呼叫到BottomNavigationMenuViewupdateMenuView方法,也就是那個一次性更新所有ItemView的方法,這是我們不願意看到的,畢竟setCheckable()出現在一個迴圈之中,我們完全有理由讓這個迴圈結束再統一更新他們。
再重新回過頭來看這個函式的命名,就顯得很有意思了,雖然不是真的操作程式,但presenter確實不工作了,等到mUpdateSuspended設定為true的時候再啟用它。值得一說的是,有的時候掛起presenter不是為了效能,而是不掛起presenter程式碼就會死迴圈。。比如我們在mMenuViewupdateMenuView方法中呼叫setCheckable(),就會輾轉呼叫回updateMenuView。。具體我就不多說了,把程式碼寫成這樣也是沒誰了。。
再回到buildMenuView,其中的最後一句mMenu.getItem(mSelectedItemPosition).setChecked(true);setCheckable()有差不多的呼叫鏈,但因為沒有掛起,所以會輾轉呼叫到mMenuView.updateMenuView();,我們還是看一下吧:

    public void updateMenuView() {
        final int menuSize = mMenu.size();
        if (menuSize != mButtons.length) {
            // The size has changed. Rebuild menu view from scratch.
            buildMenuView();
            return;
        }
        int previousSelectedId = mSelectedItemId;
        for (int i = 0; i < menuSize; i++) {
            mPresenter.setUpdateSuspended(true);
            MenuItem item = mMenu.getItem(i);
            if (item.isChecked()) {
                mSelectedItemId = item.getItemId();
                mSelectedItemPosition = i;
            }
            //根據MenuItem更新BottomNavigationItemView
            mButtons[i].initialize((MenuItemImpl) item, 0);
            mPresenter.setUpdateSuspended(false);
        }
        if (previousSelectedId != mSelectedItemId) {
            //通過TransitionManager執行動畫
            TransitionManager.beginDelayedTransition(this);
        }
    }複製程式碼

你可能會注意到,這裡也有presenter的掛起,但我可以負責任的告訴大家,這裡的掛起並有什麼作用,這口鍋26-alpha必須背!這完全就是版本迭代的時候忘記刪除這段程式碼了!不信大家可以去看25版本的原始碼,他肯定是忘記刪了!別問我花了多久才弄明白的!動畫切換部分使用的是support包中的TransitionManager,支援到4.0.3,不是那個5.0的TransitionManager,所以相容性上沒有問題。但是!BottomNavigationMenuView的全域性變數中有一個private final TransitionSet mSet;,而且還有這個

        mSet = new AutoTransition();
        mSet.setOrdering(TransitionSet.ORDERING_TOGETHER);
        mSet.setDuration(ACTIVE_ANIMATION_DURATION_MS);
        mSet.setInterpolator(new FastOutSlowInInterpolator());
        mSet.addTransition(new TextScale());複製程式碼

然後我就找啊,這個mSet在哪用的啊?我就想把那個寫原始碼的人叫過來,問問這個mSet在哪用的?恩?在哪?是不是在25版本里用的?心累。

BottomNavigationItemView

還剩下最後一個,BottomNavigationItemView,負責MenuItem的顯示工作,本身是個FrameLayout,通過佈局檔案載入子View:
sdk/extra/android/m2repository/com/android/support/design/26.0.0-alpha1/res/layout/design_bottom_navigation_item.xml

<merge xmlns:android="http://schemas.android.com/apk/res/android">
    <ImageView
        android:id="@+id/icon"
        android:layout_width="24dp"
        android:layout_height="24dp"
        android:layout_gravity="center_horizontal"
        android:layout_marginTop="@dimen/design_bottom_navigation_margin"
        android:layout_marginBottom="@dimen/design_bottom_navigation_margin"
        android:duplicateParentState="true" />
    <android.support.design.internal.BaselineLayout
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="bottom|center_horizontal"
        android:clipToPadding="false"
        android:paddingBottom="10dp"
        android:duplicateParentState="true">
        <TextView
            android:id="@+id/smallLabel"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:textSize="@dimen/design_bottom_navigation_text_size"
            android:singleLine="true"
            android:duplicateParentState="true" />
        <TextView
            android:id="@+id/largeLabel"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:visibility="invisible"
            android:textSize="@dimen/design_bottom_navigation_active_text_size"
            android:singleLine="true"
            android:duplicateParentState="true" />
    </android.support.design.internal.BaselineLayout>
</merge>複製程式碼

文字部分通過BaseLineLayout展示,這個Layout的作用是將子View對齊BaseLine排布在一起。建構函式沒什好說的,我們重點關注一下setChecked方法:

@Override
    public void setChecked(boolean checked) {
        //旋轉中心,用於scale
        mLargeLabel.setPivotX(mLargeLabel.getWidth() / 2);
        mLargeLabel.setPivotY(mLargeLabel.getBaseline());
        mSmallLabel.setPivotX(mSmallLabel.getWidth() / 2);
        mSmallLabel.setPivotY(mSmallLabel.getBaseline());
        if (mShiftingMode) {
            if (checked) {
                LayoutParams iconParams = (LayoutParams) mIcon.getLayoutParams();
                //被選中情況下將Gravity設定為TOP,因為未被選中下只是居中,所以TransitionManager會施加縱向的位移動畫
                iconParams.gravity = Gravity.CENTER_HORIZONTAL | Gravity.TOP;
                iconParams.topMargin = mDefaultMargin;
                //此方法會引起父佈局重新測量,寬度增加,從而觸發橫向的位移動畫
                mIcon.setLayoutParams(iconParams);
                //不管是checked還是unchecked,都是通過改變mLargeLabel的scale實現
                mLargeLabel.setVisibility(VISIBLE);
                mLargeLabel.setScaleX(1f);
                mLargeLabel.setScaleY(1f);
            } else {
                //...
            }
            mSmallLabel.setVisibility(INVISIBLE);
        } else {
            if (checked) {
                LayoutParams iconParams = (LayoutParams) mIcon.getLayoutParams();
                iconParams.gravity = Gravity.CENTER_HORIZONTAL | Gravity.TOP;
                iconParams.topMargin = mDefaultMargin + mShiftAmount;
                mIcon.setLayoutParams(iconParams);
                //通過mLargeLabel、mSmallLabel的輪番顯示來實現
                mLargeLabel.setVisibility(VISIBLE);
                mSmallLabel.setVisibility(INVISIBLE);

                mLargeLabel.setScaleX(1f);
                mLargeLabel.setScaleY(1f);
                //雖然mSmallLabel被隱藏了,但將其放大到mLargeLabel的大小以便設定為unchecked時可以獲得自然的過渡動畫
                mSmallLabel.setScaleX(mScaleUpFactor);
                mSmallLabel.setScaleY(mScaleUpFactor);
            } else {
                //...
            }
        }

        refreshDrawableState();
    }複製程式碼

此方法根據引數checked切換檢視狀態。通過註釋可以發現,由於TransitionManager的存在,BottomNavigationItemView並不需要處理動畫過渡,還是非常方便的。最後一句話refreshDrawableState();使得BottomNavigationItemView也不需要親自處理顏色切換,這才是正確的編碼姿勢。關於drawable狀態切換,大家可以參考洋神的這篇文章

當我們在談論初始化的時候我們在談論些什麼

回到文章開頭跳過的inflateMenu方法,我們從這裡入手,研究一下BottomNavigationView是如何初始化的。

    public void inflateMenu(int resId) {
        mPresenter.setUpdateSuspended(true);
        getMenuInflater().inflate(resId, mMenu);
        mPresenter.setUpdateSuspended(false);
        mPresenter.updateMenuView(true);
    }複製程式碼

在呼叫inflater的inflate方法之前,presenter就掛起了,這是因為inflate方法會出觸發圖更新,簡單的跟蹤一下:

    public void inflate(@MenuRes int menuRes, Menu menu) {
        XmlResourceParser parser = null;
        try {
            //...
            parseMenu(parser, attrs, menu);
        } catch (XmlPullParserException e) {
            //...
        }
    }複製程式碼

會呼叫parseMenu解析menu檔案:

    private void parseMenu(XmlPullParser parser, AttributeSet attrs, Menu menu)
            throws XmlPullParserException, IOException {
        MenuState menuState = new MenuState(menu);
        //。。。

        boolean reachedEndOfMenu = false;
        while (!reachedEndOfMenu) {
            switch (eventType) {
                case XmlPullParser.START_TAG:
                    //...
                    break;

                case XmlPullParser.END_TAG:
                    tagName = parser.getName();
                    //...
                    else if (tagName.equals(XML_ITEM)) {
                        if (!menuState.hasAddedItem()) {
                            //if...
                            else {
                                //看這裡!
                                registerMenu(menuState.addItem(), attrs);
                            }
                        }
                    } 
                    break;
            }
            eventType = parser.next();
        }
    }複製程式碼

此方法會將解析出來的資料放置在menu物件中,從而完成inflate操作。看一眼menuState.addItem()方法:

    public MenuItem addItem() {
        itemAdded = true;
        MenuItem item = menu.add(groupId, itemId, itemCategoryOrder, itemTitle);
        setItem(item);
        return item;
    }複製程式碼

menu.add()會輾轉呼叫到BottomNavigationMenu的addInternal方法,就是前面講到的限制item個數以及設定exclusive的地方。下面的一句setItem

        private void setItem(MenuItem item) {
            item.setChecked(itemChecked)
                .setXXX...
                .setXXX...
                ...
            //...
        }複製程式碼

就會呼叫到setchecked了:

    @Override
    public MenuItem setChecked(boolean checked) {
        if ((mFlags & EXCLUSIVE) != 0) {
            mMenu.setExclusiveItemChecked(this);
        } else {
            setCheckedInt(checked);
        }
        return this;
    }複製程式碼

因為我們設定過標誌位,所以執行mMenu.setExclusiveItemChecked(this)

    void setExclusiveItemChecked(MenuItem item) {
        final int group = item.getGroupId();

        final int N = mItems.size();
        stopDispatchingItemsChanged();
        for (int i = 0; i < N; i++) {
            MenuItemImpl curItem = mItems.get(i);
            if (curItem.getGroupId() == group) {
                if (!curItem.isExclusiveCheckable()) continue;
                if (!curItem.isCheckable()) continue;
                curItem.setCheckedInt(curItem == item);
            }
        }
        startDispatchingItemsChanged();
    }
`複製程式碼

注意這個for迴圈,對於menu中的每一個item,檢查其是否與引數item的引用一致,只有一致的,才會將checked設定為true,其他只能是false,所以,對於那些帶有Exclusive標記的item,只能使用item.setChecked(true)來選中它,別想著傳入false進行進行反向操作,因為這與傳入true的結果是一樣的。在for迴圈中,會呼叫到curItem.setCheckedInt(curItem == item),跟進之:

    void setCheckedInt(boolean checked) {
        final int oldFlags = mFlags;
        //根據checked設定標誌位,位與取反是清空,或操作是設定標誌位
        mFlags = (mFlags & ~CHECKED) | (checked ? CHECKED : 0);
        if (oldFlags != mFlags) {
            mMenu.onItemsChanged(false);
        }
    }複製程式碼

當checked發生變化就會呼叫mMenu.onItemsChanged(false);,然後就與之前提到的呼叫鏈一致了。於是在inflate操作之前,必須掛起presenter,否則將導致檢視多次更新。再回到inflate方法:

    public void inflateMenu(int resId) {
        mPresenter.setUpdateSuspended(true);
        getMenuInflater().inflate(resId, mMenu);
        mPresenter.setUpdateSuspended(false);
        mPresenter.updateMenuView(true);
    }複製程式碼

選單檔案載入完畢,資料被存放在mMenu物件中,之後就會呼叫mPresenter.updateMenuView(true);

    @Override
    public void updateMenuView(boolean cleared) {
        if (mUpdateSuspended) return;
        if (cleared) {
            mMenuView.buildMenuView();
        } else {
            mMenuView.updateMenuView();
        }
    }複製程式碼

然後是mMenuView.buildMenuView();

public void buildMenuView() {
        removeAllViews()
        //...
        mButtons = new BottomNavigationItemView[mMenu.size()];
        mShiftingMode = mMenu.size() > 3;
        for (int i = 0; i < mMenu.size(); i++) {
            //...
            BottomNavigationItemView child = getNewItem();
            mButtons[i] = child;
            //...
            child.initialize((MenuItemImpl) mMenu.getItem(i), 0);
            child.setItemPosition(i);
            child.setOnClickListener(mOnClickListener);
            addView(child);
        }
        mSelectedItemPosition = Math.min(mMenu.size() - 1, mSelectedItemPosition);
        mMenu.getItem(mSelectedItemPosition).setChecked(true);
    }複製程式碼

到這裡大家就很熟悉了,mShiftingMode也是在這裡賦值的,BottomNavigationMenuView的子View也是在這個地方建立的,他們都發生在BottomNavigationView的建構函式中,最後一句mMenu.getItem(mSelectedItemPosition).setChecked(true);將當前記錄的選項設定為選中狀態,因為是初始化,所以是0。後面的步驟已經講解過了:由於setChecked,
MenuItem#setChecked => MenuBuilder#setExclusiveItemChecked=>MenuItem#setCheckedInt => MenuBuilder#onItemsChanged => MenuBuilder#dispatchPresenterUpdate =>MenuPresenter#updateMenuView => MenuView#updateMenuView => BottomNavigationView#initialize
之後經過measure、layout、draw的操作,我們就可以看到這些小可愛了...

點選事件的傳遞及處理流程

不好意思,廢話太多,文章很長,最後一部分,分析一下點選事件的傳遞及處理流程。在BottomNavigationMenuView的buildMenuView方法中,為每一個BottomNavigationItemView設定了點選監聽,onclick方法如下:

        mOnClickListener = new OnClickListener() {
            @Override
            public void onClick(View v) {
                final BottomNavigationItemView itemView = (BottomNavigationItemView) v;
                MenuItem item = itemView.getItemData();
                if (!mMenu.performItemAction(item, mPresenter, 0)) {
                    item.setChecked(true);
                }
            }
        };複製程式碼

會首先呼叫mMenu.performItemAction

    public boolean performItemAction(MenuItem item, MenuPresenter preferredPresenter, int flags) {
        MenuItemImpl itemImpl = (MenuItemImpl) item;
        if (itemImpl == null || !itemImpl.isEnabled()) {
            return false;
        }
        boolean invoked = itemImpl.invoke();
        //...
        return invoked;
    }複製程式碼

會進入被點選的這個item的invoke方法:

    public boolean invoke() {
        //...
        if (mMenu.dispatchMenuItemSelected(mMenu.getRootMenu(), this)) {
          return true;
        }
        //...
        return false;
    }複製程式碼

然後又會回到MenuBuilder中去,呼叫他的dispatchMenuItemSelected方法:

    boolean dispatchMenuItemSelected(MenuBuilder menu, MenuItem item) {
        return mCallback != null && mCallback.onMenuItemSelected(menu, item);
    }複製程式碼

我們可以看到事件跑到callback中去了,那callback在哪呢?其實我們在BottomNavigationView的建構函式中設定過他:

        mMenu.setCallback(new MenuBuilder.Callback() {
            @Override
            public boolean onMenuItemSelected(MenuBuilder menu, MenuItem item) {
                if (mReselectedListener != null && item.getItemId() == getSelectedItemId()) {
                    mReselectedListener.onNavigationItemReselected(item);
                    return true; // item is already selected
                }
                return mSelectedListener != null
                        && !mSelectedListener.onNavigationItemSelected(item);
            }

            @Override
            public void onMenuModeChange(MenuBuilder menu) {}
        });複製程式碼

現在事件的處理權回到了BottomNavigationView中,他的處理方式就是讓我們自己處理,也就是傳遞給mReselectedListener或mSelectedListener,如果我們在外部的監聽中返回了true,則callback返回false,則MenuItem的invoke方法返回false,則MenuBuilder的performItemAction返回false,則BottomNavigationItemView的點選監聽中的條件判斷成立:

        if (!mMenu.performItemAction(item, mPresenter, 0)) {
            item.setChecked(true);
        }複製程式碼

於是會呼叫MenuItem的setChecked方法更新檢視,否則將不做處理。

寫在最後

安卓的原始碼總是有很多值得我們學習的地方,比如Transition的運用和Drawable狀態的處理。但這次的BottomNavigationView看得我很心累,可能是alpha版本的原因,總有一種施工現場的感覺...
Menu框架從API level 1 就已經被設計好,經歷了26個系統版本的變化,支撐著ActionBar、Toolbar、PopupMenu、NavigationView、BottomNavigationView等上層設計,基本上已經修煉成精,所以這次加入的suspend,也是無奈之舉
修修補補又一年吧 :)

相關文章