BottomNavigationView是design包提供的底部導航欄,樣子跟市面上常見的底欄差不多,但是點選的時候會帶有一點動畫效果,放張圖:
三個選項的底欄
BottomNavigationView是建構在系統的menu子產品之上的,是以可以通過配置menu檔案的方式使用它,但是,BottomNavigationView最多隻支援五個選項,當選項數量為四個或五個時,表現出的效果是與三個不同的,看圖:
四個選項的底欄
圖示在點選的時候會有漂移動畫,沒有被選中的菜單項是沒有标題文字的。如果你想讓他的表現的像是三個選項時那樣,可以參考 這篇文章來操作一下,其間用到了一次反射,是以大家自行斟酌。下面講解使用方式。
BottomNavigationView使用方式
首先保證design包被項目引入
之後建立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菜單的話:
傳入選項id即可。
好了下面進入正題。
BottomNavigationView源碼分析
BottomNavigationView基于Android的Menu架構建構,是以,視圖方面,主要角色為MenuView、ItemView兩個接口,對應的實作分别是BottomNavigationMenuView、BottomNavigationItemView,一個負責選項視圖,一個負責整體布局。資料及互動處理方面,主要角色為Menu(MenuBuilder)、MenuItem、MenuPresenter三個接口,實作類分别為BottomNavigationMenu、MenuItemImpl、BottomNavigationPresenter。他們之間的依賴關系見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, ));
}
a.recycle();
addView(mMenuView, params);
if (Build.VERSION.SDK_INT < ) {
//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() + > 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 - ;
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 = ; i < count; i++) {
mTempChildWidths[i] = (i == mSelectedItemPosition) ? activeWidth : inactiveWidth;
if (extra > ) {
mTempChildWidths[i]++;
extra--;
}
}
//非漂移模式
} else {
final int maxAvailable = width / (count == ? : count);
final int childWidth = Math.min(maxAvailable, mActiveItemMaxWidth);
int extra = width - childWidth * count;
for (int i = ; i < count; i++) {
mTempChildWidths[i] = childWidth;
if (extra > ) {
mTempChildWidths[i]++;
extra--;
}
}
}
//調用每一個子View的measure确立子布局寬高
int totalWidth = ;
for (int i = ; 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), ),
View.resolveSizeAndState(mItemHeight, heightSpec, ));
}複制代碼
大家可以看到,漂移模式跟非漂移模式的寬度測量方式是不同的,通過mShiftingMode控制。有沒有很疑惑mShiftingMode在什麼時機被指派的?坦率的講,mShiftingMode一定在onMeasure之前就被指派了,而且觸發的時機就是前面沒有詳細解釋的inflate方法。但按照我們正常的編碼思路,mShiftingMode完全可以在onMeasure方法中根據參數count來決定,而且有很高的安全性,但Google為什麼沒有這樣做?還記得開頭提到的修改樣式的那篇文章嗎,有沒有想過憑什麼簡簡單單的反射一個變量就能把樣式改了?為什麼那波操作看似驚心動魄卻又穩如狗?細思極恐了吧。扯遠了,下面我們來分析一下寬度計算的算法,以漂移模式為例,我把代碼摘出來:
//未選中的item的數量
final int inactiveCount = count - ;
//先根 據未選中的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 = ; i < count; i++) {
mTempChildWidths[i] = (i == mSelectedItemPosition) ? activeWidth : inactiveWidth;
if (extra > ) {
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() == ) {
mSelectedItemId = ;
mSelectedItemPosition = ;
mButtons = null;
return;
}
mButtons = new BottomNavigationItemView[mMenu.size()];
//在這裡設定ShiftingMode
mShiftingMode = mMenu.size() > ;
for (int i = ; 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), );
child.setItemPosition(i);
//添加點選監聽
child.setOnClickListener(mOnClickListener);
addView(child);
}
mSelectedItemPosition = Math.min(mMenu.size() - , 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 : );
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了,并沒有執行,否則可能會調用到
BottomNavigationMenuView
的
updateMenuView
方法,也就是那個一次性更新所有ItemView的方法,這是我們不願意看到的,畢竟
setCheckable()
出現在一個循環之中,我們完全有理由讓這個循環結束再統一更新他們。
再重新回過頭來看這個函數的命名,就顯得很有意思了,雖然不是真的操作程序,但presenter确實不工作了,等到
mUpdateSuspended
設定為true的時候再激活它。值得一說的是,有的時候挂起presenter不是為了性能,而是不挂起presenter代碼就會死循環。。比如我們在
mMenuView
的
updateMenuView
方法中調用
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 = ; 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, );
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() / );
mLargeLabel.setPivotY(mLargeLabel.getBaseline());
mSmallLabel.setPivotX(mSmallLabel.getWidth() / );
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();
mLargeLabel.setScaleY();
} 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();
mLargeLabel.setScaleY();
//雖然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) != ) {
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 = ; 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 : );
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() > ;
for (int i = ; i < mMenu.size(); i++) {
//...
BottomNavigationItemView child = getNewItem();
mButtons[i] = child;
//...
child.initialize((MenuItemImpl) mMenu.getItem(i), );
child.setItemPosition(i);
child.setOnClickListener(mOnClickListener);
addView(child);
}
mSelectedItemPosition = Math.min(mMenu.size() - , 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, )) {
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, )) {
item.setChecked(true);
}複制代碼
于是會調用MenuItem的setChecked方法更新視圖,否則将不做處理。
寫在最後
安卓的源碼總是有很多值得我們學習的地方,比如Transition的運用和Drawable狀态的處理。但這次的BottomNavigationView看得我很心累,可能是alpha版本的原因,總有一種施工現場的感覺...
Menu架構從API level 1 就已經被設計好,經曆了26個系統版本的變化,支撐着ActionBar、Toolbar、PopupMenu、NavigationView、BottomNavigationView等上層設計,基本上已經修煉成精,是以這次加入的suspend,也是無奈之舉
修修補補又一年吧 :)