天天看点

#BottomNavigationView使用及源码分析

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,也是无奈之举

修修补补又一年吧 :)