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