前言
使用TabIndicator+ViewPager布局多个不同类别的页面,使它们展示在同一个地方,用户通过手指滑动切换关注的内容,这是很多应用都会使用的一种高效布局。在页面切换过程中ViewPager会做页面切换,TabIndicator通常会在不同Tab底部移动一条横线实现Tab之间的切换效果,这种看似简单的动画真正实现起来却相当费事,现在就来尝试手动实现一下。
实现效果
实现接口
如果仔细观察TabIndicator底部的线条运动会发现线条的位置和ViewPager的切换过程是同步的,比如切换页面只展示的一半那么横线会在两个Tab之间展示。ViewPager提供了一个监听接口来回调切换页面的百分比情况,接口如下:
public interface OnPageChangeListener {
// 该接口会在用户手动会动ViewPager或者调用ViewPager.scrollXXX类方法的时候回调,
// 第一个参数代表要展示的页面索引,第二个参数表示切换的当前比例,
// 最后一个是切换已经偏移的像素值
void onPageScrolled(int position, float positionOffset, int positionOffsetPixels);
// 当新页面被选中展示时回调,动画可能完成可能没完成
void onPageSelected(int position);
// 当ViewPager切换的时滚动状态发生变化会被回调
// ViewPager#SCROLL_STATE_IDLE 滑动停止
// ViewPager#SCROLL_STATE_DRAGGING 用户正在拖动
// ViewPager#SCROLL_STATE_SETTLING ViewPager在惯性滑动中
void onPageScrollStateChanged(int state);
}
通过ViewPager提供的接口查看,onPageScrolled回调的positionOffset正式我们需要的切换比例值,用这个比例值可以计算当前横线应该在两个Tab之间的位置。现在为ViewPager添加回调接口,并且添加3个页面元素,向右依次点击滑动页面,打印一下position和positionOffset的值:
onPageScrolled() position = 0, positionOffset = 0.0
onPageScrolled() position = 0, positionOffset = 0.073148146
onPageScrolled() position = 0, positionOffset = 0.40925926
onPageScrolled() position = 0, positionOffset = 0.65092593
onPageScrolled() position = 0, positionOffset = 0.78518516
onPageScrolled() position = 0, positionOffset = 0.8842593
onPageScrolled() position = 0, positionOffset = 0.9314815
onPageScrolled() position = 0, positionOffset = 0.9685185
onPageScrolled() position = 0, positionOffset = 0.98703706
onPageScrolled() position = 0, positionOffset = 0.9953704
onPageScrolled() position = 0, positionOffset = 0.9990741
onPageScrolled() position = 1, positionOffset = 0.0
onPageScrolled() position = 1, positionOffset = 0.20555556
onPageScrolled() position = 1, positionOffset = 0.48703706
onPageScrolled() position = 1, positionOffset = 0.6925926
onPageScrolled() position = 1, positionOffset = 0.82592595
onPageScrolled() position = 1, positionOffset = 0.9046296
onPageScrolled() position = 1, positionOffset = 0.95370376
onPageScrolled() position = 1, positionOffset = 0.98796296
onPageScrolled() position = 1, positionOffset = 0.9962963
onPageScrolled() position = 1, positionOffset = 0.9990741
onPageScrolled() position = 2, positionOffset = 0.0
观察数据可以发现除了positionOffset = 0.0和1.0的特殊位置外,其他位置都是切换的中间状态,positionOffset逐渐增大,并且position是当前页的索引。再从最后一页向前一次滑动页面观察打印结果:
onPageScrolled() position = 1, positionOffset = 0.912037
onPageScrolled() position = 1, positionOffset = 0.8370371
onPageScrolled() position = 1, positionOffset = 0.7787037
onPageScrolled() position = 1, positionOffset = 0.7574074
onPageScrolled() position = 1, positionOffset = 0.72870374
onPageScrolled() position = 1, positionOffset = 0.5842593
onPageScrolled() position = 1, positionOffset = 0.47685182
onPageScrolled() position = 1, positionOffset = 0.375
onPageScrolled() position = 1, positionOffset = 0.29537034
onPageScrolled() position = 1, positionOffset = 0.22592592
onPageScrolled() position = 1, positionOffset = 0.17129624
onPageScrolled() position = 1, positionOffset = 0.1268518
onPageScrolled() position = 1, positionOffset = 0.094444394
onPageScrolled() position = 1, positionOffset = 0.06759262
onPageScrolled() position = 1, positionOffset = 0.047222257
onPageScrolled() position = 1, positionOffset = 0.032407403
onPageScrolled() position = 1, positionOffset = 0.021296263
onPageScrolled() position = 1, positionOffset = 0.013888836
onPageScrolled() position = 1, positionOffset = 0.008333325
onPageScrolled() position = 1, positionOffset = 0.004629612
onPageScrolled() position = 1, positionOffset = 0.0027778149
onPageScrolled() position = 1, positionOffset = 9.2589855E-4
onPageScrolled() position = 1, positionOffset = 0.0
onPageScrolled() position = 0, positionOffset = 0.9546296
onPageScrolled() position = 0, positionOffset = 0.8518519
onPageScrolled() position = 0, positionOffset = 0.74444443
onPageScrolled() position = 0, positionOffset = 0.5833333
onPageScrolled() position = 0, positionOffset = 0.4037037
onPageScrolled() position = 0, positionOffset = 0.2851852
onPageScrolled() position = 0, positionOffset = 0.19074073
onPageScrolled() position = 0, positionOffset = 0.12037037
onPageScrolled() position = 0, positionOffset = 0.072222225
onPageScrolled() position = 0, positionOffset = 0.042592593
onPageScrolled() position = 0, positionOffset = 0.022222223
onPageScrolled() position = 0, positionOffset = 0.011111111
onPageScrolled() position = 0, positionOffset = 0.0046296297
onPageScrolled() position = 0, positionOffset = 0.0018518518
onPageScrolled() position = 0, positionOffset = 9.259259E-4
onPageScrolled() position = 0, positionOffset = 0.0
观察打印结果可以发现positionOffset的值是逐渐的减小,position的值是切换的目标页索引。打印完成之后再打印一下直接从第一个点击跳转到第三个,打印结果如下:
onPageScrolled() position = 0, positionOffset = 0.033333335
onPageScrolled() position = 0, positionOffset = 0.50555557
onPageScrolled() position = 0, positionOffset = 0.8833333
onPageScrolled() position = 1, positionOffset = 0.19629633
onPageScrolled() position = 1, positionOffset = 0.4351852
onPageScrolled() position = 1, positionOffset = 0.61296296
onPageScrolled() position = 1, positionOffset = 0.73703706
onPageScrolled() position = 1, positionOffset = 0.86481476
onPageScrolled() position = 1, positionOffset = 0.91851854
onPageScrolled() position = 1, positionOffset = 0.95370376
onPageScrolled() position = 1, positionOffset = 0.9759259
onPageScrolled() position = 1, positionOffset = 0.98796296
onPageScrolled() position = 1, positionOffset = 0.9944445
onPageScrolled() position = 1, positionOffset = 0.9981482
onPageScrolled() position = 1, positionOffset = 0.9990741
onPageScrolled() position = 2, positionOffset = 0.0
观察结果可以发现从第一个到第三个position是逐渐增大的,并没有出现直接从第一个跳到第三个的情况,positionOffset在相邻页之间是逐渐增大的。
通过上面打印的结果我们知道:
- 向左滑动时postionOffset逐渐增大,position代表当前展示页索引
- 向右滑动时positionOffset逐渐减小,position代表目标展示页索引
- 多个页面之间的跳转其实是第一个先跳到第二个,第二个再跳到第三个,最后在跳到目标页。
了解了ViewPager的页面切换原理之后,再观察底部线条的运动会发现它是跨越多个Tab的,所以最终实现在包含Tab视图的ViewGroup中,ViewGroup会在dispatchDraw里请求它的子控件绘制自己,这个动画就可以在这里做。这条线的宽高都是确定的,要画它只需要确定这条线经过的一个点,通常都是用线端的中心点作为定位点。
实现过程
首先定义TabIndicator继承自LinearLayou布局,然后初始化画笔工具,当然需要为它指定需要联动的ViewPager对象,并且初始化它的布局。
public void setViewPager(final ViewPager viewPager) {
this.viewPager = viewPager;
if (viewPager.getAdapter() != null) {
this.pagerAdapter = viewPager.getAdapter();
} else {
throw new IllegalArgumentException("ViewPager has no pagerAdapter");
}
if (pagerAdapter.getCount() == 0) {
setVisibility(View.GONE);
return;
}
setVisibility(View.VISIBLE);
int count = pagerAdapter.getCount();
int width = getTabWidth(count);
// 根据ViewPager的adapter获取标题,并且将标题TextView加入TabIndicator布局
for (int i = 0; i < count; i++) {
TextView textView = new TextView(getContext());
textView.setGravity(Gravity.CENTER);
textView.setTextColor(getResources().getColor(R.color.colorAccent));
textView.setTextSize(15f);
textView.setText(pagerAdapter.getPageTitle(i));
final int index = i;
textView.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
viewPager.setCurrentItem(index, true);
}
});
LinearLayout.LayoutParams params = new LayoutParams(width, ViewGroup.LayoutParams.WRAP_CONTENT);
params.gravity = Gravity.CENTER;
addView(textView, params);
}
// 初始化Tab的宽度为屏幕的四分之一
tabWidth = width;
// 底部线条的中心点在Tab宽度一半的位置,也就是说选中的是第一个Tab
curPos = width / 2;
selected = 0;
postInvalidate();
}
// Tab宽度大于3个就屏幕四分之一,否则就2、3个平分屏幕宽度
private int getTabWidth(int count) {
if (count >= 4) {
return CommonUtils.getScreenWidth() / 4;
} else {
return CommonUtils.getScreenWidth() / count;
}
}
接下来为自定义控件增加onMeasure测量宽高的功能,如果和添加画线功能,画线就是在curPos为中心点绘制。
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int height = MeasureSpec.getSize(heightMeasureSpec);
int mode = MeasureSpec.getMode(heightMeasureSpec);
// 如果设置的高度不是精确值,那么直接使用45dp
int realHeight = CommonUtils.dp2px(45);
if (mode == MeasureSpec.EXACTLY) {
realHeight = height;
}
int count = getChildCount();
for (int i = 0; i < count; i++) {
getChildAt(i).getLayoutParams().height = realHeight;
}
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
setMeasuredDimension(getMeasuredWidth(), realHeight);
}
@Override
protected void dispatchDraw(Canvas canvas) {
super.dispatchDraw(canvas);
drawBottomLine(canvas);
}
// 以curPos为中心点画一条线
private void drawBottomLine(Canvas canvas) {
canvas.save();
canvas.drawRect(curPos - tabWidth / 2,
getMeasuredHeight() - CommonUtils.dp2px(3),
curPos + tabWidth / 2, getMeasuredHeight(), paint);
canvas.restore();
}
前面的过程都非常简单,只是普通的自定义控件画图和添加子控件操作,下面开始真正的随着ViewPager的切换动态计算curPos的位置。为了动态跟踪ViewPager切换数值,需要在前面的回调里将数据传递给TabIndicator:
viewPager.addOnPageChangeListener(new ViewPager.OnPageChangeListener() {
@Override
public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {
Log.d(TAG, "onPageScrolled() position = " + position + ", positionOffset = " + positionOffset);
arrowTabIndicator.setPositionAndOffset(position, positionOffset);
}
@Override
public void onPageSelected(int position) {
}
@Override
public void onPageScrollStateChanged(int state) {
if (state == ViewPager.SCROLL_STATE_IDLE) {
arrowTabIndicator.reset();
}
}
});
获取了postion和positionOffset之后首先要确定ViewPager向那个方向移动,可以先采集最开始的两个positionOffset进行比较,如果逐渐增大就是向右移动,那么目标页索引就是position + 1;如果逐渐减小就是向左移动,目标页索引就是position。由于跨多个Tab跳转也是逐个跳转的,只需要实现当前页到前一个或者后一个页面的滑动就可以了。那么滚动什么时候停止呢,当然就在onPageScrollStateChanged的state为SCROLL_STATE_IDLE的时候就代表滚动停止了。这里为了区分这些步骤采用了简单的状态机实现计算curPos。
// 初始状态,ViewPager没有滑动时TabIndicator就处于这种状态
private static final int STATE_INIT = 0;
// 决策ViewPager滚动的方向状态
private static final int STATE_DECIDE_DIRECT = 1;
// 正在滚动画底部线条状态
private static final int STATE_DRAW_SCROLL_ARROW = 2;
private ViewPager viewPager;
private PagerAdapter pagerAdapter;
// ViewPager静止时选中的Tab索引
private int selected = -1;
// 初始状态
private int state = STATE_INIT;
定义好这些状态和属性值之后,开始通过回调采集到的数据进行处理。
public void setPositionAndOffset(int position, float offset) {
// 如果ViewPager没有初始化,或者传入的是特殊值,也就是滚动基本完成,不做处理
if (selected < 0 || offset < 0.00000001 || offset > 0.9999999999) {
return;
}
switch (state) {
// 初始状态,记录下第一个offset值和position,并且将状态改成决定滚动方向状态
case STATE_INIT:
lastOffset = offset;
lastPosition = position;
state = STATE_DECIDE_DIRECT;
Log.d(TAG, "setPositionAndOffset(): init state = " + state
+ ", lastPosition = " + lastPosition + ", lastOffset = " + lastOffset);
break;
case STATE_DECIDE_DIRECT:
// 使用第二个值与第一个值比较,确定滚动方向
if (lastPosition == position) {
leftToRight = (offset - lastOffset) > 0;
state = STATE_DRAW_SCROLL_ARROW;
} else { // 为了防止一个页面只采集到一个滚动数据
lastOffset = offset;
lastPosition = position;
}
Log.d(TAG, "setPositionAndOffset(): init state = " + state
+ ", lastPosition = " + lastPosition + ", lastOffset = " + lastOffset);
break;
case STATE_DRAW_SCROLL_ARROW:
// 确定方向后,开始根据positionOffset去顶curPos位置
if (leftToRight) {
int target = position + 1;
Log.d(TAG, "setPositionAndOffset(): init state = " + state
+ ", leftToRight = " + leftToRight + ", target = " + target + ", selected = " + selected);
if (target >= pagerAdapter.getCount() || target == selected) {
return;
}
curPos = (int) (((target + offset) * tabWidth) - tabWidth / 2);
} else {
int target = position;
Log.d(TAG, "setPositionAndOffset(): init state = " + state
+ ", leftToRight = " + leftToRight + ", target = " + target + ", selected = " + selected);
curPos = (int) (((target + 1 + offset) * tabWidth) - tabWidth / 2);
}
// 重回当前的底部线条,一次只会画一个位置的线,但是这个方法会在滚动过程中不
// 停调用直到滚动结束,这样就看到一条中心点不断变化的线。
invalidate();
default:
break;
}
}
// 在停止滑动后重置状态和选中Tab索引
public void reset() {
state = STATE_INIT;
selected = viewPager.getCurrentItem();
}
以上就是TabIndicator的简单实现,至于更多的Tab滑动支持暂时还没有添加,后面学习滑动的时候在来完善,查看详细代码实现请点击查看源码。