自定义控件实战-打造一个简约而不简单的ViewPager指示器

来源:互联网 发布:电脑网络驱动下载 编辑:程序博客网 时间:2024/05/23 23:41

ViewPager指示器,顾名思义,它是用来指示ViewPager的一种功能效果。基于ViewPager强大的分页功能,ViewPager指示器衍生出很多各种各样的炫酷效果,比较著名的当属大神Jake Wharton 的 开源库ViewPagerIndicator。那么,我们作为android开发者不能仅仅只会使用别人的库,要学会自己动手实现,了解自定义控件的原理,这样才能应付产品经理无边无际,无法无天,无理取闹的各种奇葩需求。

那么正如标题所言,本文我们要实现一个简约而不简单的ViewPager指示器,我们的ViewPager指示器最终的效果是这样滴:

这里写图片描述

gif是用模拟器录制的,体验不好,别见怪。

那么从效果图来看,我们的指示器有哪些具体的功能呢?这里我列举了一下:

已经实现的功能:

  • 支持动态设置标签切换(回调触发)
  • 支持点击标签切换(回调触发)
  • 支持标签切换动画(可动态设置是否执行滚动动画)
  • 支持ViewPager联动

未实现的功能:

  • 不支持无限数量标签(建议2~5个之间)
  • 不支持标签滑动切换(ViewPager联动可支持)
  • 不支持超屏幕尺寸(标签均分屏幕宽度)
  • 不支持各种炫酷特效(实现不了)

差不多就这些了,如果还有我自己都没发现的,那就让各位看官自己找吧。功能描述已经结束,那么下面开始正式介绍实现方法了。

对于任何一个自定义控件,如果想要实现参数化配置,无非就是利用自定义属性或者利用Builder形式,动态配置参数。本例采用了自定义属性形式,不熟悉自定义属性的童鞋请 戳这里,为了实现上面给出的功能,我们的指示器定义了如下自定义属性,并且具体用法如下:

    <declare-styleable name="SimpleTabIndicator">        <attr name="sti_titleSize" format="dimension" /> <!-- 标题大小 -->        <attr name="sti_titleColor" format="color" />  <!-- 标题颜色 -->        <attr name="sti_tabHeight" format="dimension" /> <!-- 标签高度 -->        <attr name="sti_tabColor" format="color" /> <!-- 标签颜色 -->        <attr name="sti_tabTopPadding" format="dimension"/> <!-- 标签距离标题间距 -->        <attr name="sti_tabWidthPercent" format="float"/> <!-- 标签宽度百分比 -->        <attr name="sti_followPageScrolled" format="boolean"/> <!-- 是否与ViewPager联动 -->    </declare-styleable>
    <com.example.simpletabindicator.SimpleTabIndicator        android:id="@+id/tab_indicator"        android:layout_width="match_parent"        android:layout_height="wrap_content"        android:background="#ffcccc"        android:paddingBottom="10dp"        android:paddingTop="10dp"        app:sti_followPageScrolled="true"        app:sti_tabColor="#ff0000"        app:sti_tabHeight="20dp"        app:sti_tabTopPadding="15dp"        app:sti_tabWidthPercent="0.8"        app:sti_titleColor="#ff0000"        app:sti_titleSize="20dp" />

接着,我们源码里面解析这些属性:

        mTitleSize = (int) ta.getDimension(R.styleable.SimpleTabIndicator_sti_titleSize, density * 18f);        mTitleColor = ta.getColor(R.styleable.SimpleTabIndicator_sti_titleColor, Color.RED);        mTabHeight = (int) ta.getDimension(R.styleable.SimpleTabIndicator_sti_tabHeight, density * 3f);        mTabColor = ta.getColor(R.styleable.SimpleTabIndicator_sti_tabColor, Color.RED);        mTabTopPadding = (int) ta.getDimension(R.styleable.SimpleTabIndicator_sti_tabTopPadding, density * 12f);        mTabWidthPercent = ta.getFloat(R.styleable.SimpleTabIndicator_sti_tabWidthPercent, 1f);        mTabWidthPercent = mTabWidthPercent > 1.0f ? 1.0f : mTabWidthPercent;        mTabWidthPercent = mTabWidthPercent < 0.0f ? 0.5f : mTabWidthPercent;        mFollowPageScrolled = ta.getBoolean(R.styleable.SimpleTabIndicator_sti_followPageScrolled, false);

接下来,我们常规性的分析onMeasure和onDraw方法。

onMeasure

    @Override    protected void onMeasure(final int widthMeasureSpec, final int heightMeasureSpec) {        int widthSize = MeasureSpec.getSize(widthMeasureSpec);        int heightMode = MeasureSpec.getMode(heightMeasureSpec);        if (heightMode == MeasureSpec.AT_MOST) {            mTitlePaint.getTextBounds(TAG, 0, TAG.length(), titleRect);            int heightSize = getPaddingTop() + titleRect.height() + mTabTopPadding + mTabHeight + getPaddingBottom();            setMeasuredDimension(widthSize, heightSize);        } else {            super.onMeasure(widthMeasureSpec, heightMeasureSpec);        }    }

首先,不了解onMeasure的童鞋可直达 这里,如代码所示,我们解析了widthSize和heightMode,widthSize是控件最终的宽度,这里我当然期望是屏幕宽度,heightMode是指高度的设置模式,由上面的链接可以了解到,这里当布局文件里面设置的高度是wrap_content,我们的指示器需要动态计算所有绘制的内容的高度之和,从我们的属性列表可以看出,影响高度的属性有:

<attr name="sti_titleSize" format="dimension" /> <!-- 标题大小 --><attr name="sti_tabHeight" format="dimension" /> <!-- 标签高度 --><attr name="sti_tabTopPadding" format="dimension"/> <!-- 标签距离标题间距 -->

别忘了,我们的控件是支持上下padding的,应当加上paddingTop和paddingBottom,因此,我们最终的高度计算是这样子滴:

int heightSize = getPaddingTop() + titleRect.height() + mTabTopPadding + mTabHeight + getPaddingBottom();

其中 titleRect.height() 指的就是标题内容高度,至于怎么计算的,请 戳这里 ,至于不是wrap_content的高度模式,我就不解释了。

onDraw

    @Override    protected void onDraw(Canvas canvas) {        if (mTitles != null && mTitles.length > 1) {            final int count = mTitles.length; // 标题数量            final int averageWidth = getWidth() / count; // 屏幕平均宽度            final int paddingTop = getPaddingTop(); // 上边距            for (int i = 0; i < count; i++) {                String title = mTitles[i]; // 对应标题                mTitlePaint.getTextBounds(title, 0, title.length(), titleRect); // 获取标题内容宽高                // Paint.Align.LEFT//                final int x = (averageWidth - titleRect.width()) / 2 + i * averageWidth;                // Paint.Align.CENTER                final int x = averageWidth / 2 + i * averageWidth;                final int baseline = paddingTop + getTextBaseline(titleRect.height(), mTitlePaint);                canvas.drawText(title, x, baseline, mTitlePaint);            }            // 标签宽度 = 屏幕平均宽度 * 标签宽度百分比            final int tabWidth = (int) (averageWidth * mTabWidthPercent);            // 标签起始 x = 动态设置的标签起始坐标            int startX = mScrollStartX;            // 标签起始 y = 上边距            //           + 标题内容高度            //           + 标签距离标题间距            //           + 标签一半高度(画笔宽度一半)(为什么要加上标签一半高度?)            //            int startY = paddingTop                    + titleRect.height()                    + mTabTopPadding                    + mTabHeight / 2;            // 标签结束 x = 标签起始坐标 + 标签宽度            int endX = startX + tabWidth;            // 标签结束 y = 标签起始坐标 + 标签宽度            int endY = startY;            canvas.drawLine(startX, startY, endX, endY, mTabPaint);        }    }

onDraw方法一共干了两件事,绘制标题文本和绘制标签。先分析绘制标题:

final int count = mTitles.length; // 标题数量final int averageWidth = getWidth() / count; // 屏幕平均宽度final int paddingTop = getPaddingTop(); // 上边距for (int i = 0; i < count; i++) {    String title = mTitles[i]; // 对应标题    mTitlePaint.getTextBounds(title, 0, title.length(), titleRect); // 获取标题内容宽高    // Paint.Align.LEFT//    final int x = (averageWidth - titleRect.width()) / 2 + i * averageWidth;    // Paint.Align.CENTER    final int x = averageWidth / 2 + i * averageWidth;    final int baseline = paddingTop + getTextBaseline(titleRect.height(), mTitlePaint);    canvas.drawText(title, x, baseline, mTitlePaint);}

关于Paint.Align

绘制文本内容离不开canvas.drawText方法,而该方法绘制文本需要制定文本绘制位置的x,y坐标,同时绘制模式和Paint.Align相关,那么Paint.Align又什么呢?Paint.Align是画笔的文本绘制模式,一共有三种模式:

  • Paint.Align.LEFT
  • Paint.Align.CENTER
  • Paint.Align.RIGHT

这三种模式有什么区别呢?我们用一张图来说明:

这里写图片描述

如上上所示,当我们使用Paint.Align.CENTER 的时候,图中中间的基准线相对于文本内容来说是对齐文本内容中心的,所以我们使用canvas.drawText方法时,传递的横坐标x就是文本内容外面的中心位置,在这里应该是:

averageWidth / 2

当使用 Paint.Align.LEFT 模式时,基准线相对于文本内容中心位置,向右偏移了文本内容宽度一半的距离,所以这种模式下横坐标x应该要减去文本内宽度的一般,也就是:

(averageWidth - titleRect.width()) / 2

同样的,当使用 Paint.Align.RIGHT 模式时,基准线相对于文本内容中心位置,向左偏移了文本内容宽度一半的距离,所以这种模式下横坐标x应该要减去文本内宽度的一般,也就是:

(averageWidth + titleRect.width()) / 2

这种模式朋友们可以自己修改源码验证即可。

最终我们的文本内容横坐标x为:

final int x = averageWidth / 2 + i * averageWidth;

其中 averageWidth / 2,是因为我使用了Paint.Align.CENTER 绘制模式, + i * averageWidth 这个不用多多,自行理解。

关于baseline

那么横坐标x我们说完了,接着说纵坐标y吧,代码中我们计算y的代码是:

final int baseline = paddingTop + getTextBaseline(titleRect.height(), mTitlePaint);

这里的y就是baseline,baseline 由 paddingTop 和 getTextBaseline方法得到,除去可有可无的paddingTop不说,那么getTextBaseline方法是怎么计算得到baseline的呢?关于baseline的计算网上也有很多先关的介绍可以查询到,但是经过我一番搜寻,没有一篇能把baseline用巧妙的方式说清楚的,虽然那些公式都正确,最终得到的结果也正确,但是总是感觉差了那么一点描述,这里,我用自己的理解再描述一下baseline的计算方法,同样用一张图表示:

这里写图片描述

如图中所注,baseline为图中从上至下第三条线,这条线的y坐标位置,是无法直接计算到的,这里,上面代码中我也写了一个方法去计算baseline:

private int getTextBaseline(int rectHeight, Paint paint) {    Paint.FontMetricsInt fontMetrics = paint.getFontMetricsInt();    return rectHeight / 2 - (fontMetrics.top + fontMetrics.bottom) / 2;}

上面的代码中,参数 rectHeight为文本内容所在的矩形区域,在上面介绍Paint.Align的时候引用的图片里面 “云天河”三个字外面画了一个矩形框,没错,这个rect就是它,但是这个框是无形的看不见的,那么这个框的四条边怎么确定呢?其实是通过 :

mTitlePaint.getTextBounds(title, 0, title.length(), titleRect);

这一行代码返回一个Rect对象,我们知道rect有top和bottom属性,而同时从上图中也有top和bottom两条线,那么它们有什么关系吗?实际上,它们没关系。

rect指的是文本控件的绘制区域,而文本内容区域是在绘制区域内的,图中的top和bottom两条线之间的区域就是我们的文本内容区域,也就是说,图上top和bottom两条线是在rect内部的。同时,还有一个区别,rect.top 是相对于View原点(View左上角)而言的,rect.bottom是rect.top加上rect.height,所以rect.bottom 和 rect.top 的 差值 就是 rect的高度。

而对于图中top和bottom两条线(下文称作topline 和 bottomline)而言则不同了,图中baseline的纵坐标位置相当于原点的纵坐标位置,也就是说,topline是在原点位置的上面,是负值,bottomline在原点位置的下面是正值,但是topline和baseline之间的距离加上bottomline和baseline之间的距离之和就是文本内容区域的高度了

绘制区域是大于文本内容区域的。

那么,说了半天baseline到底怎么计算呢?呵呵,我也不知道。虽然我不知道baseline的位置怎么计算的,但是我知道一件确定的事就是:baseline是一条能让文本内容区域显示在绘制区域的中心的线,它是一个y坐标值,请注意我的描述,baseline我们不知道在哪,但是它能让我们的文本内容区域显示在绘制区域的中心,那还有别的线能达到这个效果吗?我告诉你,

假设现在文本内容已经居中了,我们知道了绘制区域的高度rect.height,知道了文本内容区域的高度(topline和bottomline之间的距离),那么文本内容区域的顶部距离绘制区域的顶部的距离不就是一个定值吗?那么这个定值怎么计算呢?如图:
这里写图片描述

草图,不能笑,千万不能笑,已经是最高水平了。

图中 “我们只要求出这个距离即可”代表的距离就是这个定值了,这个距离很好求了吧,代码如下:

private int getTextBaseline(int rectHeight, Paint paint) {    Paint.FontMetricsInt fontMetrics = paint.getFontMetricsInt();    return rectHeight / 2 - (fontMetrics.top + fontMetrics.bottom) / 2;}

其中FontMetrics能获取到topline和baseline,rectHeight是传进来的,所以,o了!

绘制文本内容的已经介绍完了,因为本篇博客希望能尽量把知识点放在一起说,所以说的可能比较细,篇幅会很长,不要见怪。那么接下来分析绘制标签的代码了。代码呢?代码呢?找到了,如下:

// 标签宽度 = 屏幕平均宽度 * 标签宽度百分比final int tabWidth = (int) (averageWidth * mTabWidthPercent);// 标签起始 x = 动态设置的标签起始坐标int startX = mScrollStartX;// 标签起始 y = 上边距//           + 标题内容高度//           + 标签距离标题间距//           + 标签一半高度(画笔宽度一半)(为什么要加上标签一半高度?)//nt startY = paddingTop           + titleRect.height()           + mTabTopPadding           + mTabHeight / 2;// 标签结束 x = 标签起始坐标 + 标签宽度int endX = startX + tabWidth;// 标签结束 y = 标签起始坐标 + 标签宽度int endY = startY;canvas.drawLine(startX, startY, endX, endY, mTabPaint);

绘制标签其实是绘制的线,使用的是canvas.drawLine方法,该方法需要传递起始坐标和终点坐标值。线的粗细是布局里面的配置的,上面的代码不用过多的解释,注释很清楚,唯一一点我想要分析的就是为什么要加上标签一半高度?,因为画笔在设置了线条宽度后,由于画笔是以画笔中心点绘制内容的,(类似上面的Align.CENTER,但是压根没关系,不用尝试验证了),所以,假设线条宽度是40dp,那么20dp的位置就类似一条基线,画笔会往基线上面绘制20dp,基线下面再绘制20dp,这样一条40dp宽度的线就出来了,但是假设我们的起点和终点的纵坐标都是0,理论上我们肯定以为线条是在绘制区域的y坐标为0处绘制往下绘制40dp宽度的线,但是实际上画笔把中心放到了y坐标为0的地方,所以会往上绘制了一半,这样我们实际看到的线条就只有一半宽度了,因此,我们必须让画笔往下挪动线条的一半宽度,这就是为什么要加上标签一半高度? 的解释了。我们绘制标签的起点横坐标是一个动态变化的值,也就是mScrollStartX,为什么是动态的,因为我们要不短的invalidate来重绘。不解释了。

说完了onDraw方法,我们理解了绘制标签是通过一个动态变化的mScrollStartX 来完成界面重绘,那么所有的操作基本都是围绕这个mScrollStartX 来实现的了。回想一下我们上面提到的已经实现了的功能,再次列举一下:

  • 支持动态设置标签切换(回调触发)
  • 支持点击标签切换(回调触发)
  • 支持标签切换动画(可动态设置是否执行滚动动画)
  • 支持ViewPager联动

我们标签的滚动涉及到两种,一种是带滚动动画或者不带滚动的切换效果,一种是和ViewPager联动效果。那么我们直接贴代码分析了,先分析普通的切换效果:

    /**     * 设置指定标签页     *     * @param tab      标签页     * @param scroll   是否需要滚动动画     * @param callback 标签切换时,是否回调切换状态     */    private void setCurrentTab(final int tab, final boolean scroll, final boolean callback) {        if (mTitles != null && tab >= 0 && tab < mTitles.length && tab != mCurrentTab) {            final int count = mTitles.length;            final int averageWidth = getWidth() / count;            final int tabWidth = (int) (averageWidth * mTabWidthPercent);            if (scroll) {                final int startX = (averageWidth - tabWidth) / 2 + mCurrentTab * averageWidth;                final int endX = startX + averageWidth * (tab - mCurrentTab);                smoothScrollTo(tab, startX, endX, callback);            } else {                mScrollStartX = (averageWidth - tabWidth) / 2 + tab * averageWidth;                invalidate();                if (mViewPager != null) {                    mViewPager.setCurrentItem(tab, false);                }                if (callback && onTabChangedListener != null) {                    onTabChangedListener.onTabChanged(tab);                }            }            mCurrentTab = tab;        }    }

如上,我们动态设置指定标签的方法教setCurrentTab,参数tab是指定切换到哪一个标签,参数scroll指定是否执行滚动动画,参数callback指定是否在标签切换后回调标签切换结果。

当scroll为true时

我们计算了startX和endX两个值。startX 的计算如下:

final int startX = (averageWidth - tabWidth) / 2 + mCurrentTab * averageWidth;

其中(averageWidth - tabWidth) / 2 是指标签左边空白的起始坐标值,因为标签可是设置百分比宽度,如果标签宽度不等于屏幕均分的宽度,那么左右会有空白的。后面加上 mCurrentTab * averageWidth ,因为标签起点位置可以是任意标题下的位置,所以起始点还要加上对应标签索引乘以屏幕均分宽度,没什么难度,不详细解释了。endX计算如下:

final int endX = startX + averageWidth * (tab - mCurrentTab);

标签从某一个索引切换到另一个索引之前,两个索引之间的距离就是滚动动画的执行距离,那么endX就是起始坐标加上滚动距离,没毛病。接着调用了:

smoothScrollTo(tab, startX, endX, callback);private void smoothScrollTo(final int tab, int startX, int endX, final boolean callback) {    mScrollAnimation = ValueAnimator.ofInt(startX, endX);    mScrollAnimation.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {        @Override        public void onAnimationUpdate(ValueAnimator animation) {            Integer value = (Integer) animation.getAnimatedValue();            mScrollStartX = value.intValue();            invalidate()    ;            }            });    mScrollAnimation.addListener(new AnimatorListenerAdapter() {        @Override        public void onAnimationEnd(Animator animation) {            if (mViewPager != null) {                mViewPager.setCurrentItem(tab, false);            }            if (callback && onTabChangedListener != null) {                onTabChangedListener.onTabChanged(tab);            }        }    });    mScrollAnimation.setDuration(200);    mScrollAnimation.start();}

smoothScrollTo方法拿到起点终点坐标后启动了一个执行时间为200ms的动画来动态改变mScrollStartX 的值,然后不停地invalidate达到标签滚动效果。动画执行后根据参数决定是否回调。

当scroll为false时,

scroll为false时,不执行滚动动画,那么就会看到标签瞬间切换到指定tab,因此,我们的mScrollStartX 计算如下:

mScrollStartX = (averageWidth - tabWidth) / 2 + tab * averageWidth;

起点位置直接等于要切换的标签索引tab对应标签所在的其实位置,(averageWidth - tabWidth) / 2 不用再解释了吧。。。接着执行invalidate重绘,根据参数决定是否回调。

好了,普通的切换效果分析完了,普通的切换效果只要确定好要切换tab的索引,就可以计算出起始坐标和终点坐标了,没什么难度。我们重点说说和ViewPager联动的滚动效果。

既然要和ViewPager联动,那么我们肯定要先绑定ViewPager,并且监听ViewPager的滚动效果,也就是接收ViewPager滚动回调OnPageChangeListener,代码如下:
“`
/**
* 绑定ViewPager
*
* @param viewPager 当viewpager为空时,指示器不会和ViewPager联动,ViewPager翻页时,指示器不会自动切换。
*/

public void setViewPager(final ViewPager viewPager, final String... titles) {    if (viewPager != null) {        viewPager.addOnPageChangeListener(new ViewPager.OnPageChangeListener() {            @Override            public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {                if (mFollowPageScrolled) {                    followPageScroll(position, positionOffset, positionOffsetPixels);                }            }            @Override            public void onPageSelected(int position) {                if (!mFollowPageScrolled) {                    setCurrentTab(position, true);                } else {                    mCurrentTab = position;                }            }            @Override            public void onPageScrollStateChanged(int state) {                if (state == ViewPager.SCROLL_STATE_IDLE) {                    mCurrentTab = viewPager.getCurrentItem();                }            }        });        mViewPager = viewPager;    } else {        mFollowPageScrolled = false;    }    if (titles == null || titles.length < 2) {        throw new IllegalArgumentException("titles must not be null or its length must not be less than 2.");    } else {        mTitles = titles;    }    setCurrentTab(0, false);}

“`
setViewPager方法是提供给调用者外部调用的,参数viewPager是要绑定的ViewPager,参数titles就是对应的多个标题,是个数组。

从代码看,如果viewPager不等于空,我们给viewPager设置了滚动回调,如果为空时,我们将mFollowPageScrolled 参数置为false,因为viewPager为空时,即使布局文件里面配置了希望和ViewPager联动,这里也会阻止的。接着是对于标题titles的判断,

if (titles == null || titles.length < 2) {    throw new IllegalArgumentException("titles must not be null or its length must not be less than 2.");        }

如果少于2个我们会抛出异常,毕竟少于两个的标题,还切换个屁。接着初始化调用一次setCurrentTab,并且默认scroll为false,绘制初始化的标签位置。

一个好的程序员,要懂得为调用者买单…,但是他们会遭到报应的(异常!)

那么,ViewPager联动是怎么实现的呢?如下:

@Overridepublic void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {    if (mFollowPageScrolled) {        followPageScroll(position, positionOffset, positionOffsetPixels);    }}

onPageScrolled方法中,我们在mFollowPageScrolled 为 true时,调用followPageScroll 和ViewPager联动,onPageScrolled 回调 positionpositionOffsetpositionOffsetPixels 三个参数:

positionOffset 是指手指滑动的距离相对于ViewPager宽度百分比,这个值在手指向左滑动,屏幕往右移动时的变化过程是 0~1,想反则是 1~0;

positionOffsetPixels 是指手指滑动的物理像素距离,不多说了。

我们重点说一说这个position参数,这个参数很特殊,当存在可以滑动的页面前提下(你不能在ViewPager已经切换到第0个item时继续往右滑动,也不能在ViewPager切换到最后一个item时继续往左滑动),当我们手指向左滑动,屏幕往右移动时,这个值一直等于ViewPager当前item的值,也就是viewPager.getCurrentItem返回的值;相反,当我们手指向右滑动,屏幕向左移动时,这个值会一直等于当前item索引减1。为此,我们通过几处log来描述:

onPageSelected回调方法中:@Overridepublic void onPageSelected(int position) {    if (!mFollowPageScrolled) {        setCurrentTab(position, true);    } else {        Log.d(TAG, "onPageSelected currentTab: " + mCurrentTab);        mCurrentTab = position;    }}                

followPageScroll方法:private void followPageScroll(int tab, float offsetPercent, int offsetPixels) {   if (offsetPercent == 0f || offsetPixels == 0) {       return;   }   final int tabCount = mTitles.length;   final int averageWidth = getWidth() / tabCount;   final int tabWidth = (int) (averageWidth * mTabWidthPercent);//   mScrollStartX = (int) ((averageWidth - tabWidth) / 2//                + mCurrentTab * averageWidth + offsetPercent * averageWidth)//                - (mCurrentTab - tab) * averageWidth;    if (tab == mCurrentTab) { // 往左滑动 或者 到达左边边界        Log.d(TAG, "向左滑动 tab: " + tab + ", currentTab: " + mCurrentTab + ", offsetPercent" + offsetPercent);        mScrollStartX = (int) ((averageWidth - tabWidth) / 2 + mCurrentTab * averageWidth + offsetPercent * averageWidth);    } else if (tab < mCurrentTab) { // 往右滑动  或者 到达右边边界        Log.d(TAG, "向右滑动 tab: " + tab + ", currentTab: " + mCurrentTab + ", offsetPercent" + offsetPercent);        mScrollStartX = (int) ((averageWidth - tabWidth) / 2 + mCurrentTab * averageWidth - (1 - offsetPercent) * averageWidth);    }    invalidate();}

Log如下:

当ViewPager从第0个切换第1个item时

这里写图片描述

从log中可以看出,tab值确实和ViewPager当前的item索引值一样,并且offsetPercent是递增的,但是我们惊讶的发现,ViewPager连贯的滚动中穿插着一条onPageSelected 的log,打印出当前item为1了,神奇的发现,同时,由于代码中我们设置了条件 tab < mCurrentTab 为向右滑动,所以当onPageSelected 回到后,我们的log开始变成了向右滑动了。那么试问,真实的向右滑动,tab会真的小于mCurrentTab吗? 继续看:

当ViewPager从第1个切换第0个item时

这里写图片描述

从log看,当ViewPager向右滑动切换到上一个item时,tab值直接变成了当前item减1,并且滚动过程中,也穿插了一条onPageSelected 的log,打印出0。而且由于代码中我们设置了条件 tab == mCurrentTab 为向左滑动,所以当onPageSelected 回到后,我们的log开始变成了向左滑动了。

情形一模一样哦,那么我们是不是可以这么猜测,只要发生了切换操作,那么ViewPager的当前item的索引值一定会在滚动过程中发生改变,或者说,ViewPager先于滚动结束计算出滚动结束后的索引下标并且回调。没毛病,从log看这已经是不争的事实了。

那么这个情况对于我们实现ViewPager联动,包括我们滑动抬手后的惯性滚动难道不会产生影响吗?答案是会的。至少我一开始在写demo的时候就被这样情况给弄混淆了,很是无语啊。那么我们怎么处理这个情况呢?首先,联动效果同样要不断地更新我们的mScrollStartX值,它在ViewPager切换过程中怎么变化的呢?如下:

if (tab == mCurrentTab) { // 往左滑动 或者 到达左边边界    mScrollStartX = (int) ((averageWidth - tabWidth) / 2 + mCurrentTab * averageWidth + offsetPercent * averageWidth);} else if (tab < mCurrentTab) { // 往右滑动  或者 到达右边边界    mScrollStartX = (int) ((averageWidth - tabWidth) / 2 + mCurrentTab * averageWidth - (1 - offsetPercent) * averageWidth);}

ViewPager滚动到下一个item时,mScrollStartX的计算如下:

value + mCurrentTab * averageWidth + offsetPercent * averageWidth);

其中value等于什么本文已经碰到过多次了,就不解释了。mScrollStartX 等于当前索引下的标签起始位置 mCurrentTab * averageWidth 加上 ViewPager回调的滑动距离百分比 offsetPercent 乘以 一个屏幕宽度平均值 averageWidth

提问:为什么是加上(百分比 乘以 一个屏幕宽度平均值)呢?答:因为从左往右滑动,起始值是0~1不断变大的。提问:为什么是(一个屏幕宽度平均值)呢?答:因为ViewPager只能一次滚动一页

ViewPager滚动到上一个item时,mScrollStartX的计算如下:

value + mCurrentTab * averageWidth - (1 - offsetPercent) * averageWidth);

value同样就不解释了。mScrollStartX 等于当前索引下的标签起始位置 mCurrentTab * averageWidth 减去 (1 - ViewPager回调的滑动距离百分比 offsetPercent) 乘以 一个屏幕宽度平均值 averageWidth

提问:为什么是减去(1 - 百分比 乘以 一个屏幕宽度平均值)呢?因为从右往左滑动,起始值是1~0不断变小的。提问:为什么减去的是(1 - 百分比)呢?由于这个百分比是从1~0递减的的。

但是前面说了,滚动过程中突然穿插了一条onPageSelected回调,并且ViewPager当前的item值已经变化了,我们从log可以看出,由于mCurrentTab值的变化,原本向右滑动,突然变成向左滑动了,但是滚动动画依然正确的执行着。为什么依然会正确的执行着滚动动画?

我们仔细分析以上代码,因为onPageSelected回调,无论开始是向左还是向右滚动,最终都会执行不同的条件了,那么这两个条件下的代码的区别在哪呢?

滚到到下一个

value + mCurrentTab * averageWidth + offsetPercent * averageWidth);

滚到到上一个

value + mCurrentTab * averageWidth - (1 - offsetPercent) * averageWidth);

惊奇的发现滚动到上一个的条件执行代码可以简化变成:

value + (mCurrentTab - 1) * averageWidth  + offsetPercent * averageWidth);

我曹,这么一简化发现它们的差别在于一个是

mCurrentTab * averageWidth

一个是

(mCurrentTab - 1) * averageWidth

也就是说,无论ViewPager怎么切换,只要保证在onPageSelected方法回调时,让mCurrentTab - 1 等于 mCurrentTab 就行了,这太简单了啊,刚好onPageSelected 回调的结果就是和当前的item索引相差1,所以,只要在onPageSelected 回调里面加上:

mCurrentTab = position;

就行了,事实上我们已经这么做了,那么ViewPager联动效果已经完成了。

最后剩下的就是点击标题标签,动态切换标签了,由于我们是自定义View绘制标题标签,所以,点击事件只能最直接的就是复写onTouchEvent了,所以,我们这么做:

    @Override    public boolean onTouchEvent(MotionEvent event) {        if (!isTabScrolling()) {            switch (event.getAction()) {                case MotionEvent.ACTION_UP:                    final int clickedX = (int) event.getX();                    final int clickedTab = calculateClickedTab(clickedX);                    setCurrentTab(clickedTab, true, true);                    break;            }        }        return true;    }    private int calculateClickedTab(int x) {        final int count = mTitles.length;        final int averageWidth = getWidth() / count;        int clickedTab = x / averageWidth;        return clickedTab;    }

根据触摸位置的x坐标和屏幕宽度平均值相除得到索引下标,最后调用setCurrentTab方法,搞定!

最后贴出我们自定义的ViewPager的使用方法:

布局文件:<com.example.simpletabindicator.SimpleTabIndicator    android:id="@+id/tab_indicator"    android:layout_width="match_parent"    android:layout_height="wrap_content"    android:background="#ffcccc"    android:paddingBottom="10dp"    android:paddingTop="10dp"    app:sti_followPageScrolled="true"    app:sti_tabColor="#ff0000"    app:sti_tabHeight="4dp"    app:sti_tabTopPadding="15dp"    app:sti_tabWidthPercent="0.8"    app:sti_titleColor="#ff0000"    app:sti_titleSize="20dp" />java代码使用:String[] titles = {"云", "天河", "云天河", "小云云"};indicator = (SimpleTabIndicator) findViewById(R.id.tab_indicator);viewPager = (ViewPager) findViewById(R.id.view_pager);indicator.setViewPager(viewPager, titles);viewPager.setAdapter(new MyAdapter(getSupportFragmentManager(), Arrays.asList(titles)));

好了,到此结束。

源码传送门

原创粉丝点击