为什么我说它是最实用的 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 中一些方法的使用。