开源库源码学习---MagicIndicator

2,560 阅读9分钟

前言

最近在做一个需求,是关于底部导航栏的,实现效果如下:

动态变化toolbar.gif

其中icon是使用lottie动画实现,可以进行回溯,也就是在fragment切换时可以动画加载到一半且可以返回,这个使用lottie动画很容易实现。

还有就是文本的颜色,也是根据滑动进行渐变,且可以回溯。本来想把这个效果进行优化一点,做成一个组件,但是发现还是有一些细节需要考虑,为了少踩坑,准备研究一下之前用过的一个很有名的指示器框架:MagicIndicator。

代码开源库是:github.com/hackware199…

效果图如下

效果图.gif

这就是MagicIndicator中一些效果,功能很强大,我们就来看看它是如何实现的。

正文

先从简单入手,分析一下需要做些什么,因为源码代码实在太多了,必须要从问题入手,先看一下:

效果图解析.jpg

从这里我们就可以简单列出几个问题需要解决:

问题.png

带着问题,我们再来看一下源码,这样就会有思路。

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);
            }
        });
    }
}

所以看到代码,和我们预期的一模一样。类的大致关系:

大体架构.png

看到这里我不禁有个疑问,就是滑动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个容器.png

所以后面要把这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);
}

其中选中和未被选中很好理解,这里有个离开是什么作用呢,而且有百分比和方向之分,这里就是为了做文字的特效而需要的,用来做动画进度,这个很关键,下面来看个示例:

左右滑动特效.gif

会发现在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内容一样的,

指示器宽度1.jpg

宽度是TextView宽度的,

指示器宽度2.jpg 宽度是自定义很小的,

指示器3.jpg

所以有了上面的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即可,就不多说了,其中关于动画我们可以说道一下,其实动画在之前的文章里尤其是贝塞尔曲线中说的很仔细,这里就是利用非线性插值器来达到更好看的效果,

非线性动画.gif

这里的动画就不是默认的线性动画,是非线性的,

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小红点说过,有个网站可以调试动画就是:

inloop.github.io/interpolato…

,在这个网站,可以找到合适的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的基本素养。