为什么我说它是最实用的 ViewPager 指示器控件呢?它有以下几个特点:

1、通过自定义 View 来实现,代码简单易懂

2、使用起来非常方便

3、通用性高,大部分涉及到 ViewPager 指示器的地方都能使用此控件

4、实现了两种指示器效果(具体请看效果图)

一、先来看效果图

传统版指示器的效果图:

流行版指示器的效果

二、分析

如果单纯的要实现此功能,相信,大家都能实现,而我也不会拿出来这里讲了,这里我是要把它打造成一个控件,通俗一点讲就是,在以后可以直接拿来用,而不需要修改代码。

控件,那就离不开自定义 View,我在前面也讲了一篇关于自定义 View 的文章 Android自定义View,你必须知道的几点 ,虽然讲的很浅,但我觉得还是非常有用处的,有兴趣的可以阅读一下,对理解这篇文章很有帮助。额,跑题了! 回顾下那两张效果图,整个 View 需要的资源其实只有两张图片;唯一的难点,就是对图片绘制的位置如何计算;既然是实现通用型易用的控件,那就不能再 ViewPager 的 OnPagerChangerListener 中来改变指示器的状态,所以这个时候,就得把 ViewPager 传入到这个控件中,到这里,分析的差不多了;

三、编码实现功能

像白饭要一口一口的吃,这里就得先创建一个类,然后让他继承之 View,前期步骤跟我的上一篇 blog 很像,就不累赘了,直接上代码

public class IndicatorView extends View implements ViewPager.OnPageChangeListener{

    //指示器图标,这里是一个 drawable,包含两种状态,
    //选中和飞选中状态
    private Drawable mIndicator;

    //指示器图标的大小,根据图标的宽和高来确定,选取较大者
    private int mIndicatorSize ;

    //整个指示器控件的宽度
    private int mWidth ;

    /*图标加空格在家 padding 的宽度*/
    private int mContextWidth ;

    //指示器图标的个数,就是当前ViwPager 的 item 个数
    private int mCount ;
    /*每个指示器之间的间隔大小*/
    private int mMargin ;
    /*当前 view 的 item,主要作用,是用于判断当前指示器的选中情况*/
    private int mSelectItem ;
    /*指示器根据ViewPager 滑动的偏移量*/
    private float mOffset ;
    /*指示器是否实时刷新*/
    private boolean mSmooth ;
    /*因为ViewPager 的 pageChangeListener 被占用了,所以需要定义一个
    * 以便其他调用
    * */
    private ViewPager.OnPageChangeListener mPageChangeListener ;

    public IndicatorView(Context context) {
        this(context, null);
    }

    public IndicatorView(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public IndicatorView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        //通过 TypedArray 获取自定义属性
        TypedArray typedArray = getResources().obtainAttributes(attrs, R.styleable.IndicatorView);
        //获取自定义属性的个数
        int N = typedArray.getIndexCount();
        for (int i = 0; i < N; i++) {
            int attr = typedArray.getIndex(i);
            switch (attr) {
                case R.styleable.IndicatorView_indicator_icon:
                    //通过自定义属性拿到指示器
                    mIndicator = typedArray.getDrawable(attr);
                    break;
                case R.styleable.IndicatorView_indicator_margin:
                    float defaultMargin = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP,5,getResources().getDisplayMetrics());
                    mMargin = (int) typedArray.getDimension(attr , defaultMargin);
                    break ;
                case R.styleable.IndicatorView_indicator_smooth:
                    mSmooth = typedArray.getBoolean(attr,false) ;
                    break;
            }
        }
        //使用完成之后记得回收
        typedArray.recycle();
        initIndicator() ;
    }

    private void initIndicator() {
        //获取指示器的大小值。一般情况下是正方形的,也是时,你的美工手抖了一下,切出一个长方形来了,
        //不用怕,这里做了处理不会变形的
        mIndicatorSize = Math.max(mIndicator.getIntrinsicWidth(),mIndicator.getIntrinsicHeight()) ;
        /*设置指示器的边框*/
        mIndicator.setBounds(0,0,mIndicator.getIntrinsicWidth(),mIndicator.getIntrinsicWidth());
    }
}

这里需要注意一点的就是 Drawable mIndicator这个成员变量,它是在 drawable 文件夹下定义的一个 drawable 文件,包含了选中和为选中两张图片。

接着是测量工作

/**
     * 测量View 的大小,这个方法我前面的 blog 讲了很多了,
     * @param widthMeasureSpec
     * @param heightMeasureSpec
     */
    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        setMeasuredDimension(measureWidth(widthMeasureSpec),measureHeight(heightMeasureSpec));
    }

    /**
     * 测量宽度,计算当前View 的宽度
     * @param widthMeasureSpec
     * @return
     */
    private int measureWidth(int widthMeasureSpec){
        int mode = MeasureSpec.getMode(widthMeasureSpec) ;
        int size = MeasureSpec.getSize(widthMeasureSpec) ;
        int width ;
        int desired = getPaddingLeft() + getPaddingRight() + mIndicatorSize*mCount + mMargin*(mCount -1) ;
        mContextWidth = desired ;
        if(mode == MeasureSpec.EXACTLY){
            width = Math.max(desired, size)  ;
        }else {
            if(mode == MeasureSpec.AT_MOST){
                width = Math.min(desired,size) ;
            }else {
                width = desired ;
            }
        }
        mWidth = width ;
        return width ;
    }

    private int measureHeight(int heightMeasureSpec){
        int mode = MeasureSpec.getMode(heightMeasureSpec) ;
        int size = MeasureSpec.getSize(heightMeasureSpec) ;
        int height ;
        if(mode == MeasureSpec.EXACTLY){
            height = size ;
        }else {
            int desired = getPaddingTop() + getPaddingBottom() + mIndicatorSize ;
            if(mode == MeasureSpec.AT_MOST){
                height = Math.min(desired,size) ;
            }else {
                height = desired ;
            }
        }

        return height ;
    }

测量完了,就到了绘制 View 的阶段了。这里重点看看 onDraw()方法,先说一下,大致流程:

首先,绘制所有为选中的指示器,这里是绘制 Drawable,所以需要用到 Canvas中的某些方法来平移画布,让其顺序的绘制所有的 Drawable,这里特别注意的一点就是 Canvas.restore() 方法,这个方法是在绘制完成之后,想要回到原来的位置和状态调用,但它必须配合Canvas.save()来配套使用。Canvas.save()就是记录当前画布的状态,所以这里,我觉得这个方法的名字应该换成 record()是不是更符合我们的理解呢?这里纯属个人见解,理解了就好,如何命名不妨碍我们的工作,下面是 onDraw()的代码,注释很详细

/**
     * 绘制指示器
     * @param canvas
     */
    @Override
    protected void onDraw(Canvas canvas) {

        /*
        * 首先得保存画布的当前状态,如果位置行这个方法
        * 等一下的 restore()将会失效,canvas 不知道恢复到什么状态
        * 所以这个 save、restore 都是成对出现的,这样就很好理解了。
        * */
        canvas.save() ;
        /*
        * 这里开始就是计算需要绘制的位置,
        * 如果不好理解,请按照我说的做,拿起
        * 附近的纸和笔,在纸上绘制一下,然后
        * 你就一目了然了,
        *
        * */
        int left = mWidth/2 - mContextWidth/2 +getPaddingLeft() ;
        canvas.translate(left,getPaddingTop());
        for(int i = 0 ; i < mCount ; i++){
            /*
            * 这里也需要解释一下,
            * 因为我们额 drawable 是一个selector 文件
            * 所以我们需要设置他的状态,也就是 state
            * 来获取相应的图片。
            * 这里是获取未选中的图片
            * */
            mIndicator.setState(EMPTY_STATE_SET) ;
            /*绘制 drawable*/
            mIndicator.draw(canvas);
            /*每绘制一个指示器,向右移动一次*/
            canvas.translate(mIndicatorSize+mMargin,0);
        }
        /*
        * 恢复画布的所有设置,也不是所有的啦,
        * 根据 google 说法,就是matrix/clip
        * 只能恢复到最后调用 save 方法的位置。
        * */
        canvas.restore();
        /*这里又开始计算绘制的位置了*/
        float leftDraw = (mIndicatorSize+mMargin)*(mSelectItem + mOffset);
        /*
        * 计算完了,又来了,平移,为什么要平移两次呢?
        * 也是为了好理解。
        * */
        canvas.translate(left,getPaddingTop());
        canvas.translate(leftDraw,0);
        /*
        * 把Drawable 的状态设为已选中状态
        * 这样获取到的Drawable 就是已选中
        * 的那张图片。
        * */
        mIndicator.setState(SELECTED_STATE_SET) ;
        /*这里又开始绘图了*/
        mIndicator.draw(canvas);

    }

现在我们的控件其实就差一步没有实现了,就是在何时何地更新 View,一开始就分析了,这个 View 是需要传入 ViewPager 的,传入 ViewPager 的目的是什么,其实有三个:

1、获取 ViewPager 的 item 的个数,从而来确定指示器的个数; 2、获取当前 ViewPager 选中的 item,也是确定指示器选中的 item; 3、获取 OnPagerChangeListener,来控制 View 什么时候需要刷新;

/**
     * 此ViewPager 一定是先设置了Adapter,
     * 并且Adapter 需要所有数据,后续还不能
     * 修改数据
     * @param viewPager
     */
    public void setViewPager(ViewPager viewPager){
        if(viewPager == null){
            return;
        }
        PagerAdapter pagerAdapter = viewPager.getAdapter() ;
        if(pagerAdapter == null){
            throw new RuntimeException("请看使用说明");
        }
        mCount = pagerAdapter.getCount() ;
        viewPager.setOnPageChangeListener(this);
        mSelectItem = viewPager.getCurrentItem() ;

        invalidate();
    }

    public void setOnPageChangeListener(ViewPager.OnPageChangeListener mPageChangeListener) {
        this.mPageChangeListener = mPageChangeListener;
    }

    @Override
    public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {
        Log.v("zgy","========"+position+",===offset" + positionOffset) ;
        if (mSmooth){
            mSelectItem = position ;
            mOffset = positionOffset ;
            invalidate();
        }
        if(mPageChangeListener != null){
            mPageChangeListener.onPageScrolled(position,positionOffset,positionOffsetPixels);
        }
    }

    @Override
    public void onPageSelected(int position) {

        mSelectItem = position ;
        invalidate();

        if(mPageChangeListener != null){
            mPageChangeListener.onPageSelected(position);
        }
    }

    @Override
    public void onPageScrollStateChanged(int state) {

        if(mPageChangeListener != null){
            mPageChangeListener.onPageScrollStateChanged(state);
        }
    }

这个位置也有个点需要提一下,就是当 mSmooth 为 true 的时候,这个时候是需要实时刷新的,所以需要在onPageScrolled(int position, float positionOffset, int positionOffsetPixels)调用 invalidate(),并把偏移量保存起来,用于计算绘制指示器的位置。

好了,以上就是指示器控件的实现全过程

既然是一个控件,接下来看看在 xml 是如何引用的

<com.gyzhong.viewpagerindicator.IndicatorView
        android:id="@+id/id_indicator"
        android:layout_centerHorizontal="true"
        android:layout_alignParentBottom="true"
        android:layout_marginBottom="20dp"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:padding="5dp"
        zgy:indicator_icon="@drawable/indicator_selector"
        zgy:indicator_margin="5dp"/>

再来看看代码中的引用

mIndicatorView = (IndicatorView) findViewById(R.id.id_indicator) ;
mIndicatorView.setViewPager(mViewPager);

代码简洁明了。

四、总结

整体来说,不是很难,代码量很少,主要用到的知识点,1、自定义属性,2、如何测量 View,2、Cavans 中一些方法的使用。