前言
最近在做一个需求,是关于底部导航栏的,实现效果如下:
其中icon是使用lottie动画实现,可以进行回溯,也就是在fragment切换时可以动画加载到一半且可以返回,这个使用lottie动画很容易实现。
还有就是文本的颜色,也是根据滑动进行渐变,且可以回溯。本来想把这个效果进行优化一点,做成一个组件,但是发现还是有一些细节需要考虑,为了少踩坑,准备研究一下之前用过的一个很有名的指示器框架:MagicIndicator。
代码开源库是:github.com/hackware199…
效果图如下
这就是MagicIndicator中一些效果,功能很强大,我们就来看看它是如何实现的。
正文
先从简单入手,分析一下需要做些什么,因为源码代码实在太多了,必须要从问题入手,先看一下:
从这里我们就可以简单列出几个问题需要解决:
带着问题,我们再来看一下源码,这样就会有思路。
MagicIndicator
看xml布局里:
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/navigator_margin_top"
android:background="#455a64"
android:orientation="vertical">
<net.lucode.hackware.magicindicator.MagicIndicator
android:id="@+id/magic_indicator1"
android:layout_width="wrap_content"
android:layout_height="@dimen/common_navigator_height"
android:layout_gravity="center_horizontal" />
</LinearLayout>
复制代码
可以发现上面效果图中的3个tab在这里是一个自定义View,这样设计好处是为了可以放置无限多个tab,既然如此设计,那肯定要有个适配器,来提供tab的文字、个数以及指示器的样子, 所以这里在Java代码中是:
MagicIndicator magicIndicator = (MagicIndicator) findViewById(R.id.magic_indicator1);
//new出一个导航实例
CommonNavigator commonNavigator = new CommonNavigator(this);
//导航实例的适配器
commonNavigator.setAdapter(new CommonNavigatorAdapter() {
//需要知道有多少个tab项
@Override
public int getCount() {
return mDataList == null ? 0 : mDataList.size();
}
//需要知道每个标题View是什么样式的
@Override
public IPagerTitleView getTitleView(Context context, final int index) {
...省略
return simplePagerTitleView;
}
//需要知道指示器是什么样子的
@Override
public IPagerIndicator getIndicator(Context context) {
LinePagerIndicator indicator = new LinePagerIndicator(context);
indicator.setColors(Color.parseColor("#40c4ff"));
return indicator;
}
});
//导航器设置适配器
magicIndicator.setNavigator(commonNavigator);
复制代码
其实这里的逻辑和普通设置适配器是一样的,接下来就是把这个MagicIndicator和ViewPager给结合起来:
ViewPagerHelper.bind(magicIndicator, mViewPager);
复制代码
这里就一行代码即可,使用ViewPagerHelper辅助类来完成。
从上面代码我们不禁可以看出,很多逻辑是在导航器CommonNavigator中,我们来看一下MagicIndicator的代码:
//自定义View,继承值FrameLayout
public class MagicIndicator extends FrameLayout {
//导航器实例
private IPagerNavigator mNavigator;
public MagicIndicator(Context context) {
super(context);
}
public MagicIndicator(Context context, AttributeSet attrs) {
super(context, attrs);
}
//ViewPager滑动回调
public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {
if (mNavigator != null) {
mNavigator.onPageScrolled(position, positionOffset, positionOffsetPixels);
}
}
//ViewPager选中回调,
public void onPageSelected(int position) {
if (mNavigator != null) {
mNavigator.onPageSelected(position);
}
}
//ViewPager滑动状态回调
public void onPageScrollStateChanged(int state) {
if (mNavigator != null) {
mNavigator.onPageScrollStateChanged(state);
}
}
public IPagerNavigator getNavigator() {
return mNavigator;
}
//设置导航器
public void setNavigator(IPagerNavigator navigator) {
if (mNavigator == navigator) {
return;
}
if (mNavigator != null) {
mNavigator.onDetachFromMagicIndicator();
}
mNavigator = navigator;
removeAllViews();
//这里会发现导航器其实就是View,设置导航器时add View到FrameLayout
if (mNavigator instanceof View) {
LayoutParams lp = new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT);
addView((View) mNavigator, lp);
mNavigator.onAttachToMagicIndicator();
}
}
}
复制代码
哦,看到这里大家应该就明白了,这里的MagicIndicator只是一个桥梁,具体的View实现在IPagerNavigator中,而通过MagicIndicator把ViewPager的滑动状态传递给IPagerNavigator。
所以很有必要看一下ViewPagerHelper类:
//把ViewPager的滑动、选择状态传递给MagicIndicator中
public class ViewPagerHelper {
public static void bind(final MagicIndicator magicIndicator, ViewPager viewPager) {
viewPager.addOnPageChangeListener(new ViewPager.OnPageChangeListener() {
@Override
public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {
magicIndicator.onPageScrolled(position, positionOffset, positionOffsetPixels);
}
@Override
public void onPageSelected(int position) {
magicIndicator.onPageSelected(position);
}
@Override
public void onPageScrollStateChanged(int state) {
magicIndicator.onPageScrollStateChanged(state);
}
});
}
}
复制代码
所以看到代码,和我们预期的一模一样。类的大致关系:
看到这里我不禁有个疑问,就是滑动ViewPager时状态传递给了MagicIndicator,这时tabView可以做出对应变化,但是点击TabView时,ViewPager如何切换呢,这个在哪做的呢,其实是在获取标题View中做的,看一下上面说的IPagerNavigator的适配器中getTitleView方法:
@Override
public IPagerTitleView getTitleView(Context context, final int index) {
SimplePagerTitleView simplePagerTitleView = new ColorTransitionPagerTitleView(context);
simplePagerTitleView.setText(mDataList.get(index));
simplePagerTitleView.setNormalColor(Color.parseColor("#88ffffff"));
simplePagerTitleView.setSelectedColor(Color.WHITE);
//这里直接设置点击事件来切换ViewPager
simplePagerTitleView.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
mViewPager.setCurrentItem(index);
}
});
return simplePagerTitleView;
}
复制代码
ok,第一个问题已经解决,就是点击tab时切换viewPager是在getTitleView中手动处理。
既然知道了大体架构,那就主要看一下IPagerNavigator实现类即可,下面是CommonNavigator,也是源码中使用最多最简单的一个IPagerNavigator。
CommonNavigator
这个就是上面效果图中的导航器,正常来说就是一个MagicIndicator对应一个导航器Navigator,因为很简单,这个Navigator也是是一个View,所以这里的主要逻辑就集中在了这里,如何添加标题View以及指示器View,都在这里实现。
public class CommonNavigator extends FrameLayout implements IPagerNavigator
, NavigatorHelper.OnNavigatorScrollListener
复制代码
这里实现了2个接口,我们来看一下。
IPagerNavigator
这个就是主要导航器接口了,
public interface IPagerNavigator {
// ViewPager的3个回调
void onPageScrolled(int position, float positionOffset, int positionOffsetPixels);
void onPageSelected(int position);
void onPageScrollStateChanged(int state);
//
/**
* 当IPagerNavigator被添加到MagicIndicator时调用
*/
void onAttachToMagicIndicator();
/**
* 当IPagerNavigator从MagicIndicator上移除时调用
*/
void onDetachFromMagicIndicator();
/**
* ViewPager内容改变时需要先调用此方法,自定义的IPagerNavigator应当遵守此约定
*/
void notifyDataSetChanged();
}
复制代码
其中主要就是前面也说过了,通过MagicIndicator把ViewPager的状态传递到导航器中,所以ViewPager的3个回调是必须的,还有就是添加、删除、更新导航器的回调。
NavigatorHelper.OnNavigatorScrollListener
这是啥玩意呢,看一下代码:
public interface OnNavigatorScrollListener {
void onEnter(int index, int totalCount, float enterPercent, boolean leftToRight);
void onLeave(int index, int totalCount, float leavePercent, boolean leftToRight);
void onSelected(int index, int totalCount);
void onDeselected(int index, int totalCount);
}
复制代码
有点离谱,这里是把ViewPager的3个回调,给转成了这4个回调,为什么要这么做呢,原因很简单,原来ViewPager的几个回调方法不是很好适配使用,所以改成这4个方法,具体原因我们后面分析。
添加view
到现在我们兵分2路,首先来看一下如何把View添加到导航器Navigator中,然后再看如何和ViewPager做联动。
看一下CommonNavigator中的init代码:
private void init() {
//先移除所有的view
removeAllViews();
View root;
//判断是否是自适应模式
if (mAdjustMode) {
root = LayoutInflater.from(getContext()).inflate(R.layout.pager_navigator_layout_no_scroll, this);
} else {
root = LayoutInflater.from(getContext()).inflate(R.layout.pager_navigator_layout, this);
}
//这个就是标题容器
mTitleContainer = (LinearLayout) root.findViewById(R.id.title_container);
mTitleContainer.setPadding(mLeftPadding, 0, mRightPadding, 0);
//指示器容器
mIndicatorContainer = (LinearLayout) root.findViewById(R.id.indicator_container);
if (mIndicatorOnTop) {
mIndicatorContainer.getParent().bringChildToFront(mIndicatorContainer);
}
//进行初始化
initTitlesAndIndicator();
}
复制代码
看一下这里的rootView是个什么样子:
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:id="@+id/indicator_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal" />
<LinearLayout
android:id="@+id/title_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal" />
</FrameLayout>
复制代码
很意外,这里就2个容器View,一个是标题的容器,一个是指示器的容器,
到这里第二个疑问我们也知道了,整个布局是由2个布局容器形成的,标题和指示器分开。
所以后面要把这2个容器做的和一个View一样联动就很关键,主要代码就是如何往这2个容器中添加View:
private void initTitlesAndIndicator() {
//这里的NavigatorHelper就是一个辅助类,保存一些信息
for (int i = 0, j = mNavigatorHelper.getTotalCount(); i < j; i++) {
IPagerTitleView v = mAdapter.getTitleView(getContext(), i);
//这里的代码就是拿到titleView然后挨个添加到线性布局中,如果自适应布局可以设置weight
if (v instanceof View) {
View view = (View) v;
LinearLayout.LayoutParams lp;
if (mAdjustMode) {
lp = new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT);
lp.weight = mAdapter.getTitleWeight(getContext(), i);
} else {
lp = new LinearLayout.LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.MATCH_PARENT);
}
mTitleContainer.addView(view, lp);
}
}
if (mAdapter != null) {
//指示器就不一样了,因为只有一个指示器,所以就直接添加到指示器容器中即可
mIndicator = mAdapter.getIndicator(getContext());
if (mIndicator instanceof View) {
LayoutParams lp = new LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT);
mIndicatorContainer.addView((View) mIndicator, lp);
}
}
}
复制代码
既然添加View如此简单,那复杂的逻辑肯定封装在了具体TitleView中,下面来分析一波TitleView。
IPagerTitleView
这个就是所有titleView所继承的接口,这里就很关键,看一下接口:
public interface IPagerTitleView {
/**
* 被选中
*/
void onSelected(int index, int totalCount);
/**
* 未被选中
*/
void onDeselected(int index, int totalCount);
/**
* 离开
*
* @param leavePercent 离开的百分比, 0.0f - 1.0f
* @param leftToRight 从左至右离开
*/
void onLeave(int index, int totalCount, float leavePercent, boolean leftToRight);
/**
* 进入
*
* @param enterPercent 进入的百分比, 0.0f - 1.0f
* @param leftToRight 从左至右离开
*/
void onEnter(int index, int totalCount, float enterPercent, boolean leftToRight);
}
复制代码
其中选中和未被选中很好理解,这里有个离开是什么作用呢,而且有百分比和方向之分,这里就是为了做文字的特效而需要的,用来做动画进度,这个很关键,下面来看个示例:
会发现在ViewPager左右滑动时,第一排的文字会变大和缩小,同时有颜色渐变,这里先不讨论如何知道滑动进度,这里就只要明白我知道了滑动进度即Percent和哪个是进入和离开就可以实现这个动画,那方向呢 就是设计第二排的效果实现。
第二排中间那个TextView,当都是leave即离开状态时,往左和往右是不一样的,其中字体颜色变化一个从左边一个从右边,所以还需要知道方向。
到这里我们对文本标题为啥要实现这几个接口就大概知道了,然后看一下最普通的一个文本实现,首先是文字颜色变化,这个其实我之前的文章中已经说过很多次了:
public class ColorTransitionPagerTitleView extends SimplePagerTitleView {
public ColorTransitionPagerTitleView(Context context) {
super(context);
}
@Override
public void onLeave(int index, int totalCount, float leavePercent, boolean leftToRight) {
int color = ArgbEvaluatorHolder.eval(leavePercent, mSelectedColor, mNormalColor);
setTextColor(color);
}
@Override
public void onEnter(int index, int totalCount, float enterPercent, boolean leftToRight) {
int color = ArgbEvaluatorHolder.eval(enterPercent, mNormalColor, mSelectedColor);
setTextColor(color);
}
@Override
public void onSelected(int index, int totalCount) {
}
@Override
public void onDeselected(int index, int totalCount) {
}
}
复制代码
就是根据及进度计算2个颜色的差值,然后就是大小变化了:
public class ScaleTransitionPagerTitleView extends ColorTransitionPagerTitleView {
private float mMinScale = 0.75f;
public ScaleTransitionPagerTitleView(Context context) {
super(context);
}
@Override
public void onEnter(int index, int totalCount, float enterPercent, boolean leftToRight) {
super.onEnter(index, totalCount, enterPercent, leftToRight); // 实现颜色渐变
setScaleX(mMinScale + (1.0f - mMinScale) * enterPercent);
setScaleY(mMinScale + (1.0f - mMinScale) * enterPercent);
}
@Override
public void onLeave(int index, int totalCount, float leavePercent, boolean leftToRight) {
super.onLeave(index, totalCount, leavePercent, leftToRight); // 实现颜色渐变
setScaleX(1.0f + (mMinScale - 1.0f) * leavePercent);
setScaleY(1.0f + (mMinScale - 1.0f) * leavePercent);
}
public float getMinScale() {
return mMinScale;
}
public void setMinScale(float minScale) {
mMinScale = minScale;
}
}
复制代码
这2种最简单的动画就不做过多叙述了,知道其中原理即可,关于复杂点的那个文字颜色左右变化,后面单独再说。
到这里,我们已经知道了标题如何排列,以及标题如何根据viewPager的切换而变化了,那接着看一下指示器。
IPagerIndicator
对于指示器会有点复杂,其实原因很简单,标题是多个view挨个加到容器中,但是指示器就一个view,它要做到能随着ViewPager滑动,并且滑动还有动画,所以要考虑的东西会多一点,在这里我们先不讨论ViewPager滑动回调,后面再细说,先看一下指示器的接口:
public interface IPagerIndicator {
void onPageScrolled(int position, float positionOffset, int positionOffsetPixels);
void onPageSelected(int position);
void onPageScrollStateChanged(int state);
void onPositionDataProvide(List<PositionData> dataList);
}
复制代码
其中前3个方法都好理解,也就是ViewPager切换的回调,第4个方法记录着标题位置,方便指示器来移动,看一下PositionData这个类:
public class PositionData {
//TextView的上、下、左、右4个点坐标
public int mLeft;
public int mTop;
public int mRight;
public int mBottom;
//TextView的内容坐标,也就是去除padding
public int mContentLeft;
public int mContentTop;
public int mContentRight;
public int mContentBottom;
//TextView的整体宽度,因为有的指示器宽度是整个TextView宽度
public int width() {
return mRight - mLeft;
}
public int height() {
return mBottom - mTop;
}
//内容宽度
public int contentWidth() {
return mContentRight - mContentLeft;
}
public int contentHeight() {
return mContentBottom - mContentTop;
}
//TextView的中心点位置,因为指示器要移动到这里
public int horizontalCenter() {
return mLeft + width() / 2;
}
public int verticalCenter() {
return mTop + height() / 2;
}
}
复制代码
这里为什么要区分这些东西呢,原因很简单,指示器的宽度是可以定义的,比如宽度和TextView内容一样的,
宽度是TextView宽度的,
宽度是自定义很小的,
所以有了上面的PositionData数据宽度问题就好解决了,接下来看一下如何移动指示器。
LinePagerIndicator
从名字来看,这个就是线指示器,指示器是一条线,根据前面的思路我们大概能猜出指示器是如何实现的,也就是根据ViewPager滑动的情况来控制指示器这个View的大小和位置即可。
代码如下:
//滑动回调
@Override
public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {
if (mPositionDataList == null || mPositionDataList.isEmpty()) {
return;
}
...略
// 计算锚点位置
PositionData current = FragmentContainerHelper.getImitativePositionData(mPositionDataList, position);
PositionData next = FragmentContainerHelper.getImitativePositionData(mPositionDataList, position + 1);
//各种模式不同得到不同的指示器宽度
float leftX;
float nextLeftX;
float rightX;
float nextRightX;
if (mMode == MODE_MATCH_EDGE) {
leftX = current.mLeft + mXOffset;
nextLeftX = next.mLeft + mXOffset;
rightX = current.mRight - mXOffset;
nextRightX = next.mRight - mXOffset;
} else if (mMode == MODE_WRAP_CONTENT) {
leftX = current.mContentLeft + mXOffset;
nextLeftX = next.mContentLeft + mXOffset;
rightX = current.mContentRight - mXOffset;
nextRightX = next.mContentRight - mXOffset;
} else { // MODE_EXACTLY
leftX = current.mLeft + (current.width() - mLineWidth) / 2;
nextLeftX = next.mLeft + (next.width() - mLineWidth) / 2;
rightX = current.mLeft + (current.width() + mLineWidth) / 2;
nextRightX = next.mLeft + (next.width() + mLineWidth) / 2;
}
//线条指示器的4个顶点属性
//加上动画,可以产生更好看的效果
mLineRect.left = leftX + (nextLeftX - leftX) * mStartInterpolator.getInterpolation(positionOffset);
mLineRect.right = rightX + (nextRightX - rightX) * mEndInterpolator.getInterpolation(positionOffset);
mLineRect.top = getHeight() - mLineHeight - mYOffset;
mLineRect.bottom = getHeight() - mYOffset;
invalidate();
}
复制代码
然后在onDraw()中重绘这个rect即可,就不多说了,其中关于动画我们可以说道一下,其实动画在之前的文章里尤其是贝塞尔曲线中说的很仔细,这里就是利用非线性插值器来达到更好看的效果,
这里的动画就不是默认的线性动画,是非线性的,
public IPagerIndicator getIndicator(Context context) {
LinePagerIndicator indicator = new LinePagerIndicator(context);
//加速动画
indicator.setStartInterpolator(new AccelerateInterpolator());
//减速动画
indicator.setEndInterpolator(new DecelerateInterpolator(1.6f));
indicator.setYOffset(UIUtil.dip2px(context, 39));
indicator.setLineHeight(UIUtil.dip2px(context, 1));
indicator.setColors(Color.parseColor("#f57c00"));
return indicator;
}
复制代码
这里减速动画有个值是1.6,在之前贝塞尔曲线的QQ小红点说过,有个网站可以调试动画就是:
,在这个网站,可以找到合适的factor,来绘制出更漂亮的动画。
NavigatorHelper
在前面我们说过,我们在处理标题时,需要把ViewPager的回调转成特定的几个,好判断方向和进度,这个逻辑代码就是在NavigatorHelper中实现的,在说这个之前,我们来看一下ViewPager的3个回调,这个回调值有个很巧妙的地方:
viewPager.addOnPageChangeListener(new ViewPager.OnPageChangeListener() {
@Override
public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {
Log.i("zyh", "onPageScrolled: position = " + position);
Log.i("zyh", "onPageScrolled: positionOffset = " + positionOffset);
Log.i("zyh", "onPageScrolled: positionOffsetPixels = " + positionOffsetPixels);
magicIndicator.onPageScrolled(position, positionOffset, positionOffsetPixels);
}
@Override
public void onPageSelected(int position) {
magicIndicator.onPageSelected(position);
Log.i("zyh", "onPageSelected: position = " + position);
}
@Override
public void onPageScrollStateChanged(int state) {
magicIndicator.onPageScrollStateChanged(state);
Log.i("zyh", "onPageScrollStateChanged: state = " + state);
}
});
复制代码
比如下面我从位置0滑动到1,这里的onPageScrolled的回调中的值变化是:
position: 0 -> 0 -> 0 .... -> 1
positionOffset: 0 -> 1
但是我从位置1滑动0,这里的值变化是
position: 1 -> 0 -> 0 .... -> 0
positionOffset: 1 -> 0
所以我们会发现这个position始终是左边那个tab的位置,记住这一点,很关键。
然后就是根据具体逻辑把上面3个回调转成下面4个回调:
public interface OnNavigatorScrollListener {
void onEnter(int index, int totalCount, float enterPercent, boolean leftToRight);
void onLeave(int index, int totalCount, float leavePercent, boolean leftToRight);
void onSelected(int index, int totalCount);
void onDeselected(int index, int totalCount);
}
复制代码
具体代码就不细说了,大家可以去源码看。
结尾
这篇文章只是介绍了其中的基本原理,很多稍微复杂点的动画特效后面有用到再说,其实了解了原理后,其他的动画实现起来也就不麻烦了。
总结以下几点在我们平时使用中常用的地方:
-
ViewPager的滑动回调方法,里面是position位置永远是左边的页面。
-
ViewPager的3个回调方法在使用中需要转换,改成更为方便使用的4个回调,同时记录方向。
-
标题和指示器进行分离处理,分别放入2个容器,通过回调进度进行联动。
-
动画要会熟练使用,这个也是作为Android UI boy的基本素养。