底部导航-BottomNavigationView 的使用及源码分析

目前市面上很多 APP 都有底部导航的功能,实现底部导航的方式也有很多种,如:

  • ① 、使用原生控件 :TabHostLinearLayout /RelativeLayoutRadioButton 等;
  • ② 、使用 Design 库中的 TabLayoutBottomNavigationView 实现;
  • ③、使用第三方库 (FlycoTabLayout/…) 实现;
  • ④、自定义控件实现;

总之,根据自己的实际需求可以选择不同的实现方案。这里主要介绍 BottomNavigationView 的实现方式,以及 SDK28 前后的差异。

一、实现底部导航

首先添加 Design 库的依赖:

implementation "com.android.support:design:27.1.1"

然后,在 menu 目录下定义 tab 的菜单,例如 res/menu/menu_navigation_tab.xml

<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
    <item
        android:id="@+id/navi_home"
        android:icon="@drawable/ic_home_black_24dp"
        android:title="首页"/>

    <item
        android:id="@+id/navi_order"
        android:icon="@drawable/ic_view_list_black_24dp"
        android:title="订单"/>

    <item
        android:id="@+id/navi_cart"
        android:icon="@drawable/ic_local_grocery_store_black_24dp"
        android:title="购物车"/>

    <item
        android:id="@+id/navi_mine"
        android:icon="@drawable/ic_person_black_24dp"
        android:title="我的"/>
</menu>

接着添加 BottomNavigationView 到布局中,如下主要代码:

<android.support.design.widget.BottomNavigationView
    android:id="@+id/navigation_view"
    android:layout_width="match_parent"
    android:layout_height="?attr/actionBarSize"
    android:background="@android:color/white"
    app:itemIconTint="@color/color_navigation_item"
    app:itemTextColor="@color/color_navigation_item"
    app:layout_constraintBottom_toBottomOf="parent"
    app:layout_constraintLeft_toLeftOf="parent"
    app:layout_constraintRight_toRightOf="parent"
    app:layout_constraintTop_toBottomOf="@id/viewpager"
    app:menu="@menu/menu_navigation">
</android.support.design.widget.BottomNavigationView>

注意 :menu 指定tab 显示菜单;itemIconTintitemTextColor 对应为指定 tab 的 icon 和文本颜色(通过定义 color ,指定不同选中状态的颜色)。

最后在 对应的 activity 中对 BottomNavigationView 添加 item 选中监听,配合 ViewPager 实现界面切换:

上述实现较为简单,预览效果如下左图所示:

4个tab: 图一 3个tab :

注意:如上图所示,当 tab 操作三个,选中的 item 就会有偏移效果,并且只有选中的 item 显示文本。

二、tab 偏移问题

对于上面所出现的3个以上tab 会出现偏移问题,通过查看源码,BottomNavigationView 的菜单是由 BottomNavigationMenuView 控制的,如下构造方法中对 BottomNavigationMenuView 的初始化及相关属性设置的部分代码 :

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

        // Create the menu
        mMenu = new BottomNavigationMenu(context);

        mMenuView = new BottomNavigationMenuView(context);
        FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(
                ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
        params.gravity = Gravity.CENTER;
        mMenuView.setLayoutParams(params);

        mPresenter.setBottomNavigationMenuView(mMenuView);
        mPresenter.setId(MENU_PRESENTER_ID);
        mMenuView.setPresenter(mPresenter);
        mMenu.addMenuPresenter(mPresenter);
        mPresenter.initForMenu(getContext(), mMenu);

        // Custom attributes
        TintTypedArray a = TintTypedArray.obtainStyledAttributes(context, attrs,
                R.styleable.BottomNavigationView, defStyleAttr,
                R.style.Widget_Design_BottomNavigationView);

        if (a.hasValue(R.styleable.BottomNavigationView_itemIconTint)) {
            mMenuView.setIconTintList(
                    a.getColorStateList(R.styleable.BottomNavigationView_itemIconTint));
        } else {
            mMenuView.setIconTintList(
                    createDefaultColorStateList(android.R.attr.textColorSecondary));
        }
        if (a.hasValue(R.styleable.BottomNavigationView_itemTextColor)) {
            mMenuView.setItemTextColor(
                    a.getColorStateList(R.styleable.BottomNavigationView_itemTextColor));
        } else {
            mMenuView.setItemTextColor(
                    createDefaultColorStateList(android.R.attr.textColorSecondary));
        }
        if (a.hasValue(R.styleable.BottomNavigationView_elevation)) {
            ViewCompat.setElevation(this, a.getDimensionPixelSize(
                    R.styleable.BottomNavigationView_elevation, 0));
        }

        int itemBackground = a.getResourceId(R.styleable.BottomNavigationView_itemBackground, 0);
        mMenuView.setItemBackgroundRes(itemBackground);

        if (a.hasValue(R.styleable.BottomNavigationView_menu)) {
            inflateMenu(a.getResourceId(R.styleable.BottomNavigationView_menu, 0));
        }
        a.recycle();
        addView(mMenuView, params);
        // ...
}

BottomNavigationMenuView 中的 item 是一个包含图片文本的自定义控件– BottomNavigationItemView 类型的数组保存的。在 BottomNavigationItemView 中对于不同的模式和选中状态,设置 item 的 icon 和 label 的显示,如下部分代码:

// 偏移模式,默认 false
 private boolean mShiftingMode;

public BottomNavigationItemView(Context context, AttributeSet attrs, int defStyleAttr) {
    // ...
    int inactiveLabelSize =
                res.getDimensionPixelSize(R.dimen.design_bottom_navigation_text_size);// 12sp
    int activeLabelSize = res.getDimensionPixelSize(
                R.dimen.design_bottom_navigation_active_text_size);//14sp
    mShiftAmount = inactiveLabelSize - activeLabelSize;// inactiveLabelSize < activeLabelSize 为 false
    mShiftAmount = inactiveLabelSize - activeLabelSize;
    // label 选中缩放比例
    mScaleUpFactor = 1f * activeLabelSize / inactiveLabelSize;
    // label 隐藏缩放比例
    mScaleDownFactor = 1f * inactiveLabelSize / activeLabelSize;
    // ...
}

// 设置偏移模式
public void setShiftingMode(boolean enabled) {
    mShiftingMode = enabled;
}
// 设置 title
@Override
public void setTitle(CharSequence title) {
    mSmallLabel.setText(title);
    mLargeLabel.setText(title);
}
// ...
@Override
public void setChecked(boolean checked) {
 if (mShiftingMode) {
        if (checked) {
            LayoutParams iconParams = (LayoutParams) mIcon.getLayoutParams();
            iconParams.gravity = Gravity.CENTER_HORIZONTAL | Gravity.TOP;
            iconParams.topMargin = mDefaultMargin;
            mIcon.setLayoutParams(iconParams);
            // 选中状态,显示文本,设置缩放
            mLargeLabel.setVisibility(VISIBLE);
            mLargeLabel.setScaleX(1f);
            mLargeLabel.setScaleY(1f);
        } else {
            LayoutParams iconParams = (LayoutParams) mIcon.getLayoutParams();
            iconParams.gravity = Gravity.CENTER;
            iconParams.topMargin = mDefaultMargin;
            mIcon.setLayoutParams(iconParams);
            // 未选中状态,隐藏文本,设置缩放一半动画
            mLargeLabel.setVisibility(INVISIBLE);
            mLargeLabel.setScaleX(0.5f);
            mLargeLabel.setScaleY(0.5f);
        }
        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);
            // 选中,选中 label 显示,未选中 label 隐藏
            mLargeLabel.setVisibility(VISIBLE);
            mSmallLabel.setVisibility(INVISIBLE);

            mLargeLabel.setScaleX(1f);
            mLargeLabel.setScaleY(1f);
            mSmallLabel.setScaleX(mScaleUpFactor);
            mSmallLabel.setScaleY(mScaleUpFactor);
        } else {
            LayoutParams iconParams = (LayoutParams) mIcon.getLayoutParams();
            iconParams.gravity = Gravity.CENTER_HORIZONTAL | Gravity.TOP;
            iconParams.topMargin = mDefaultMargin;
            mIcon.setLayoutParams(iconParams);
            // 未选中,选中 label 隐藏,未选中 label 显示
            mLargeLabel.setVisibility(INVISIBLE);
            mSmallLabel.setVisibility(VISIBLE);

            mLargeLabel.setScaleX(mScaleDownFactor);
            mLargeLabel.setScaleY(mScaleDownFactor);
            mSmallLabel.setScaleX(1f);
            mSmallLabel.setScaleY(1f);
        }
    }
    // ...
}

mLargeLabel 和 mSmallLabel 均表示 菜单文本,只是前者是选中状态,后者是未选中状态展示;对两个 label 设置相应的缩放比例,实现切换 tab 时的动画视差效果。同时,在 mShiftingModefalse 下可以保证 label 的一直显示以及 item 的间距均衡。

通过上面的分析,知道了 tab 设置文本显示的根源,但是其 显示与否还与 mShiftingMode 相关,毕竟 tab 数量在3前后不同。

接着回头查看 BottomNavigationMenuView 源码,mShiftingMode 的设置 如下:

// 偏移模式
private boolean mShiftingMode = true;
// 菜单 item
private BottomNavigationItemView[] mButtons;

private final Pools.Pool<BottomNavigationItemView> mItemPool = new Pools.SynchronizedPool<>(5);

private MenuBuilder mMenu;

mButtons = new BottomNavigationItemView[mMenu.size()];
// 以3为分界线,来设置不同的偏移模式
mShiftingMode = mMenu.size() > 3;
for (int i = 0; i < mMenu.size(); i++) {
    mPresenter.setUpdateSuspended(true);
    mMenu.getItem(i).setCheckable(true);
    mPresenter.setUpdateSuspended(false);
    BottomNavigationItemView child = getNewItem();
    mButtons[i] = child;
    child.setIconTintList(mItemIconTint);
    child.setTextColor(mItemTextColor);
    child.setItemBackground(mItemBackgroundRes);
    // 设置偏移
    child.setShiftingMode(mShiftingMode);
    child.initialize((MenuItemImpl) mMenu.getItem(i), 0);
    child.setItemPosition(i);
    child.setOnClickListener(mOnClickListener);
    addView(child);
}

// 获取 item ,mItemPool 的最大值为5 ,限制了最多5个 tab
private BottomNavigationItemView getNewItem() {
    BottomNavigationItemView item = mItemPool.acquire();
    if (item == null) {
        item = new BottomNavigationItemView(getContext());
    }
    return item;
}

通过上面的代码,彻底知道了 tab 个数在3前后 菜单的偏移模式不同,所以可以修改 mShiftingMode 属性来保证 tab 操作3个后的显示模式,但是,BottomNavigationMenuView 并未开放相关方法,因此可以通过反射修改 mShiftingMode 的值,以及遍历菜单修改相关属性,如下:

object NavigationViewHelper {
    @SuppressLint("RestrictedApi")
    fun disableShiftingMode(view: BottomNavigationView) {
        val menuView = view.getChildAt(0) as BottomNavigationMenuView
        if (menuView.childCount > 3) {
            try {
                val shiftingMode = menuView::class.java.getDeclaredField("mShiftingMode")
                shiftingMode.apply {
                    isAccessible = true
                    setBoolean(menuView, false)
                    isAccessible = false
                }
                menuView.forEachChild {
                    (it as BottomNavigationItemView).apply {
                        setShiftingMode(false)
                        // reset check state to update it
                        setChecked(itemData.isChecked)
                    }
                }
            } catch (e: NoSuchFieldException) {
                Log.e("tx", "NavigationViewHelper: Unable to get shiftMode field", e)
            } catch (e: IllegalAccessException) {
                Log.e("tx", "NavigationViewHelper: Unable to change value of shiftMode", e)
            }
        }
    }

}

使用上面的工具类可以使 tab 个数在5个内的文本显示、间距相同。

三、SDK28 以后相关 API 的变更

最近将项目的 compile sdksupport 相关库升级到28以后,上述的工具类失效,编译无法通过,发现源码中 ,移除了 mShiftingMode 属性和 setShiftingMode 方法。
这里以 28.0.0-rc01support 库为例进行分析 (如需使用其他版本,请查看 google maven repo )。

主要的变动有:

  • 对于 BottomNavigationItemView,原始的 mShiftingMode 换成了 isShifting ,并且添加 labelVisibilityMode 属性。
  • 对于 BottomNavigationMenuView ,移除了 boolean 类型的 mShiftingMode 属性,取而代之的是 int 类型的 labelVisibilityMode
  • 对于 BottomNavigationView ,添加字自定义属性 labelVisibilityMode;

这里从 BottomNavigationView 源码看起,在 28 以后,新增的自定属性 labelVisibilityMode 取值为:

<declare-styleable name="BottomNavigationView"><attr name="menu"/><attr name="labelVisibilityMode">
  <!-- 自动 ,和 menu 的 item 数目有关 -->
  <enum name="auto" value="-1"/>
  <!-- 选中状态显示 label -->
  <enum name="selected" value="0"/>
  <!-- 全部显示 label -->
  <enum name="labeled" value="1"/>
  <!-- 全部不显示 label -->
  <enum name="unlabeled" value="2"/>

其中,默认为 -1,如下获取自定义属性和设置 menu 的 labelVisibilityMode 的源码:

 private final BottomNavigationMenuView menuView;

// 获取自定义属性值,默认 -1
this.setLabelVisibilityMode(a.getInteger(styleable.BottomNavigationView_labelVisibilityMode, -1));

// 设置 menu 的 labelVisibilityMode
public void setLabelVisibilityMode(int labelVisibilityMode) {
    if (this.menuView.getLabelVisibilityMode() != labelVisibilityMode) {
        this.menuView.setLabelVisibilityMode(labelVisibilityMode);
        this.presenter.updateMenuView(false);
    }
}

接着,查看 BottomNavigationMenuView 的相关源码:

boolean shifting = this.isShifting(this.labelVisibilityMode, this.menu.getVisibleItems().size());
for(int i = 0; i < menuSize; ++i) {
    this.presenter.setUpdateSuspended(true);
    this.buttons[i].setLabelVisibilityMode(this.labelVisibilityMode);
    // 设置偏移
    this.buttons[i].setShifting(shifting);
    this.buttons[i].initialize((MenuItemImpl)this.menu.getItem(i), 0);
    this.presenter.setUpdateSuspended(false);
}

// 判断是否需要偏移
private boolean isShifting(int labelVisibilityMode, int childCount) {
    return labelVisibilityMode == -1 ? childCount > 3 : labelVisibilityMode == 0;
}

说明:主要是 isShifting 方法中处理:当 labelVisibilityMode 为默认值 -1 时,菜单数目大于 3 个,会偏移,否则不会偏移;当 labelVisibilityMode 值为 0 时,会偏移;其他取值不会偏移 。

最后来看看 BottomNavigationItemView 中 item 偏移与 label 显示与否的相关设置:

// ...
switch(this.labelVisibilityMode) {
    case -1:
        if (this.isShifting) {
            if (checked) {
                this.setViewLayoutParams(this.icon, this.defaultMargin, 49);
                this.setViewValues(this.largeLabel, 1.0F, 1.0F, 0);
            } else {
                this.setViewLayoutParams(this.icon, this.defaultMargin, 17);
                this.setViewValues(this.largeLabel, 0.5F, 0.5F, 4);
            }

            this.smallLabel.setVisibility(4);
        } else if (checked) {
            this.setViewLayoutParams(this.icon, (int)((float)this.defaultMargin + this.shiftAmount), 49);
            this.setViewValues(this.largeLabel, 1.0F, 1.0F, 0);
            this.setViewValues(this.smallLabel, this.scaleUpFactor, this.scaleUpFactor, 4);
        } else {
            this.setViewLayoutParams(this.icon, this.defaultMargin, 49);
            this.setViewValues(this.largeLabel, this.scaleDownFactor, this.scaleDownFactor, 4);
            this.setViewValues(this.smallLabel, 1.0F, 1.0F, 0);
        }
        break;
    case 0:
        if (checked) {
            this.setViewLayoutParams(this.icon, this.defaultMargin, 49);
            this.setViewValues(this.largeLabel, 1.0F, 1.0F, 0);
        } else {
            this.setViewLayoutParams(this.icon, this.defaultMargin, 17);
            this.setViewValues(this.largeLabel, 0.5F, 0.5F, 4);
        }

        this.smallLabel.setVisibility(4);
        break;
    case 1:
        if (checked) {
            this.setViewLayoutParams(this.icon, (int)((float)this.defaultMargin + this.shiftAmount), 49);
            this.setViewValues(this.largeLabel, 1.0F, 1.0F, 0);
            this.setViewValues(this.smallLabel, this.scaleUpFactor, this.scaleUpFactor, 4);
        } else {
            this.setViewLayoutParams(this.icon, this.defaultMargin, 49);
            this.setViewValues(this.largeLabel, this.scaleDownFactor, this.scaleDownFactor, 4);
            this.setViewValues(this.smallLabel, 1.0F, 1.0F, 0);
        }
        break;
    case 2:
        this.setViewLayoutParams(this.icon, this.defaultMargin, 17);
        this.largeLabel.setVisibility(8);
        this.smallLabel.setVisibility(8);
    }

    this.refreshDrawableState();
    this.setSelected(checked);
}

// ...
private void setViewLayoutParams(@NonNull View view, int topMargin, int gravity) {
    LayoutParams viewParams = (LayoutParams)view.getLayoutParams();
    viewParams.topMargin = topMargin;
    viewParams.gravity = gravity;
    view.setLayoutParams(viewParams);
}

private void setViewValues(@NonNull View view, float scaleX, float scaleY, int visibility) {
    view.setScaleX(scaleX);
    view.setScaleY(scaleY);
    view.setVisibility(visibility);
}
// ...

由于 labelVisibilityMode 的多种取值(-1-2,设置其他值会导致切换时 label 无动画),上面出现了多种情况,如下简要说明:

注意:(1)、由于没有源码,AS 自动生成的,代码中有些是具体的值。(2)、对于 View 的显示状态:VISIBLE 为 0、INVISIBLE 为 4、GONE 为 8 ;通过查看 27.1.1 中 BottomNavigationItemView 对应的源码,设置 gravity 参数分别为 : Gravity.CENTER , Gravity.CENTER_HORIZONTAL | Gravity.TOP , 值为 1749 (通过相应的 |<< 运算而得,可在 Gravity 源码中查看)。上述源码中调用 setViewLayoutParams 时传入的 17/49 的值应该与之前版本一致。

四、总结

对于 28以前,可以使用上面的工具类,来保证操作3个 tab 的显示不偏移;而 28 以后直接设置 app:labelVisibilityMode="labeled" 即可。此外,对于项目中的依赖库的升级,需要了解其 api 的变更,然后作出相应的更换。另外,如果项目中自定义了 CoordinatorLayout.Behavior,在 28 以后,也需要注意,对于 layoutDependsOnonDependentViewChanged方法参数不能为空,有 @NonNull 注解。



   转载规则(或关注公众号:imtianx)


《底部导航-BottomNavigationView 的使用及源码分析》imtianx 采用 知识共享署名-非商业性使用 4.0 国际许可协议 进行许可。
 上一篇
OpenGrok 源码阅读环境搭建 OpenGrok 源码阅读环境搭建
开发过程中,往往需要阅读源码,挑选一个合适的源码阅读方式十分重要。这里简记使用 [OpenGrok](https://oracle.github.io/opengrok/)搭建源码阅读环境
下一篇 
Docker的使用及镜像编写 Docker的使用及镜像编写
随着容器技术的普及,越来越多的事情可以在 Docker 中完成。这里简要记录 docker 的常用命令以及镜像的构建与运用。结合gitlab-ci来构建 android apk。 一、docker 安装1.Mac 安装在 mac 上,通常使
  目录