首先先打个广告,自己写了一个AndroidBottomNavigation,扩展官方BottomNavigationView的功能,而且实现起来更加简便。
Bottom-Navigation
是谷歌官方发布的android底部状态栏,它的动画效果非常的漂亮,看起来非常的让人赏心悦目。为了能够拥有相同的用户体验,google对它有着严格的设计标准,具体的要求和实例请看:官方文档。同时,谷歌还推出了BottomNavigationView
来实现这种设计。那下面就来看看BottomNavigationView
是如何实现的。
简单使用
通过BottomNavigationView
的官方文档,我们可以看到,BottomNavigationView
是在version 25.0.0
以后被添加进来的,所以在此之前的版本,要使用就需要添加的包:compile 'com.android.support:design:25.0.0'
。同时,官方还给出了简单的使用实例,这里就不在介绍了。
res/menu/my_navigation_items.xml:
这里我们看到,BottomNavigationView
的高度被限定在56dp,这个值是在官方的设计文档中明确要求的,因此当你使用大于56dp的高度时,就会有一部分空白区域流出来,同时也完全不建议使用过小的高度,这样,内部的图标或文字都可能会被裁剪掉部分。
实现原理
BottomNavigationView分析
通过阅读BottomNavigationView
源码,我们看到BottomNavigationView
直接通过继承FrameLayout实现。它里面最重要的有3个对象:MenuBuilder
,BottomNavigationMenuView
、BottomNavigationPresenter
。从他们的命名上,我们就可以知道,MenuBuilder
主要是创建一个menu,通过xml文件,创建menu后,再将其中的item的title、icon、id等信息传递给BottomNavigationMenuView
去创建最终我们看到的view,同时,也将view的点击事件通过menu的回调传回到BottomNavigationMenuView
;BottomNavigationPresenter
则主要是进行一些逻辑的操作,比如初始化BottomNavigationMenuView
,更新BottomNavigationMenuView
等;BottomNavigationMenuView
则是具体我们所看到的view,它通过MenuBuilder
来创建item,同时根据click来进行样式的变化。
除了这三个之外,BottomNavigationView
其他部分都是一些参数的设置和初始化,这边就不再介绍了。
BottomNavigationMenuView分析
通过上面我们可以看到,所有的一切都是围绕BottomNavigationMenuView
所展开,所以我们重点通过BottomNavigationMenuView
来了解整个流程。
初始化
在BottomNavigationView
的构造方法里,程序在创建完这3个对象后,首先对MenuBuilder
进行初始化:
public void inflateMenu(int resId) {
mPresenter.setUpdateSuspended(true);
getMenuInflater().inflate(resId, mMenu);//初始化menu
mPresenter.initForMenu(getContext(), mMenu);
mPresenter.setUpdateSuspended(false);
mPresenter.updateMenuView(true);
}
在初始化menu前,先对BottomNavigationPresenter
进行暂停,同样的事情还出现在BottomNavigationMenuView
初始化各个item和每次进行动画时。这样做可以避免在初始化和动画时同时在进行更新动画而冲突。
初始化MenuBuilder
后,再通过BottomNavigationPresenter
对BottomNavigationMenuView
进行初始化:
@Override
public void initForMenu(Context context, MenuBuilder menu) {
mMenuView.initialize(mMenu);
mMenu = menu;
}
同时进行界面创建:
@Override
public void updateMenuView(boolean cleared) {
if (mUpdateSuspended) return;
if (cleared) {
mMenuView.buildMenuView();
} else {
mMenuView.updateMenuView();
}
}
具体界面创建的方法:
public void buildMenuView() {
if (mButtons != null) {
for (BottomNavigationItemView item : mButtons) {
sItemPool.release(item);
}
}
removeAllViews();
mButtons = new BottomNavigationItemView[mMenu.size()];
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);
}
}
这里我们看到有一个池sItemPool
,当界面重构时,会把原来已有的BottomNavigationItemView
放到池中,再次创建新界面时又从池中取出,这样做可以减少对象的创建数量。同时,程序会根据menu的item数量创建BottomNavigationItemView
数组,而BottomNavigationItemView
就是显示的每一个菜单按钮。里面有3个控件:
LayoutInflater.from(context).inflate(R.layout.design_bottom_navigation_item, this, true);
setBackgroundResource(R.drawable.design_bottom_navigation_item_background);
mIcon = (ImageView) findViewById(R.id.icon);
mSmallLabel = (TextView) findViewById(R.id.smallLabel);
mLargeLabel = (TextView) findViewById(R.id.largeLabel);
这些就是一个item所显示的内容。
onMeasure和onLayout
在BottomNavigationMenuView
初始化完成之后,就要对里面的控件进行测量和排列。
在onMeasure方法中,做的主要是两件是:1是对里面每一个BottomNavigationItemView
都进行宽高的测量;2是设置整个BottomNavigationMenuView
的宽高。
第一步的测量还分两种情况,当item的数量大于3个时,mShiftingMode
=true。在这种情况下,选中的item和其他的items的宽度是不一样的,所以程序要先计算出选中的item的宽度,然后根据它计算其他items的宽度;第二种情况是当items的数量<=3个时,每个item的宽度是一样的,所以只需要根据总宽度/items的数量就可以计算出item的宽度。
第二步在测量整个view的宽度时,程序将先前的所有可见的items的宽度加起来作为整个BottomNavigationMenuView
的宽度(目前也没有发现有什么可能会使item不可见)。
onLayout
方法就比较简单,它根据之前计算好的每一个item的宽高,从左往右或从右往左放置每一个item的位置。
@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
final int count = getChildCount();
final int width = right - left;
final int height = bottom - top;
int used = 0;
for (int i = 0; i < count; i++) {
final View child = getChildAt(i);
if (child.getVisibility() == GONE) {
continue;
}
if (ViewCompat.getLayoutDirection(this) == ViewCompat.LAYOUT_DIRECTION_RTL) {
child.layout(width - used - child.getMeasuredWidth(), 0, width - used, height);
} else {
child.layout(used, 0, child.getMeasuredWidth() + used, height);
}
used += child.getMeasuredWidth();
}
}
点击动画和回调
在初始化BottomNavigationMenuView
时,每一个BottomNavigationItemView
都会添加onClickListener
:
mOnClickListener = new OnClickListener() {
@Override
public void onClick(View v) {
final BottomNavigationItemView itemView = (BottomNavigationItemView) v;
final int itemPosition = itemView.getItemPosition();
activateNewButton(itemPosition);
mMenu.performItemAction(itemView.getItemData(), mPresenter, 0);
}
};
关键的代码是后面两句,其中一句是执行点击的动画,最后一句是执行menu点击的回调。那我们分别来看一下。
private void activateNewButton(int newButton) {
if (mActiveButton == newButton) return;
mAnimationHelper.beginDelayedTransition(this);
mPresenter.setUpdateSuspended(true);
mButtons[mActiveButton].setChecked(false);
mButtons[newButton].setChecked(true);
mPresenter.setUpdateSuspended(false);
mActiveButton = newButton;
}
在这里我们看到,主要的操作就是将原来的BottomNavigationItemView
check设置为false,将点击的设置为true,那我们来看BottomNavigationItemView
的setCheck方法里又做了什么。
setCheck
方法也是整个BottomNavigationItemView
最核心的方法。
mItemData.setChecked(checked);
ViewCompat.setPivotX(mLargeLabel, mLargeLabel.getWidth() / 2);
ViewCompat.setPivotY(mLargeLabel, mLargeLabel.getBaseline());
ViewCompat.setPivotX(mSmallLabel, mSmallLabel.getWidth() / 2);
ViewCompat.setPivotY(mSmallLabel, mSmallLabel.getBaseline());
首先,它将设置menu的item是否为点击;设置两个文本的动画原点。
if (checked) {
LayoutParams iconParams = (LayoutParams) mIcon.getLayoutParams();
iconParams.gravity = Gravity.CENTER_HORIZONTAL | Gravity.TOP;
iconParams.topMargin = mDefaultMargin;
mIcon.setLayoutParams(iconParams);
mLargeLabel.setVisibility(VISIBLE);
ViewCompat.setScaleX(mLargeLabel, 1f);
ViewCompat.setScaleY(mLargeLabel, 1f);
} else {
LayoutParams iconParams = (LayoutParams) mIcon.getLayoutParams();
iconParams.gravity = Gravity.CENTER;
iconParams.topMargin = mDefaultMargin;
mIcon.setLayoutParams(iconParams);
mLargeLabel.setVisibility(INVISIBLE);
ViewCompat.setScaleX(mLargeLabel, 0.5f);
ViewCompat.setScaleY(mLargeLabel, 0.5f);
}
mSmallLabel.setVisibility(INVISIBLE);
在有移动的情况下,对选中和非选中都进行动画操作,同时,大文本显示,小文本隐藏。
if (checked) {
LayoutParams iconParams = (LayoutParams) mIcon.getLayoutParams();
iconParams.gravity = Gravity.CENTER_HORIZONTAL | Gravity.TOP;
iconParams.topMargin = mDefaultMargin + mShiftAmount;
mIcon.setLayoutParams(iconParams);
mLargeLabel.setVisibility(VISIBLE);
mSmallLabel.setVisibility(INVISIBLE);
ViewCompat.setScaleX(mLargeLabel, 1f);
ViewCompat.setScaleY(mLargeLabel, 1f);
ViewCompat.setScaleX(mSmallLabel, mScaleUpFactor);
ViewCompat.setScaleY(mSmallLabel, mScaleUpFactor);
} else {
LayoutParams iconParams = (LayoutParams) mIcon.getLayoutParams();
iconParams.gravity = Gravity.CENTER_HORIZONTAL | Gravity.TOP;
iconParams.topMargin = mDefaultMargin;
mIcon.setLayoutParams(iconParams);
mLargeLabel.setVisibility(INVISIBLE);
mSmallLabel.setVisibility(VISIBLE);
ViewCompat.setScaleX(mLargeLabel, mScaleDownFactor);
ViewCompat.setScaleY(mLargeLabel, mScaleDownFactor);
ViewCompat.setScaleX(mSmallLabel, 1f);
ViewCompat.setScaleY(mSmallLabel, 1f);
}
在不移动情况下,对icon的上距进行变化,同时选中时小文本变大,不选择时大文本变小文本。
在点击回调时,执行mMenu.performItemAction (itemView.getItemData(), mPresenter, 0);
代码,该代码会调用MenuItemImpl
invoke方法,并且最终调用callback回调。