前言

	Tab与ViewPager结合起来使用在我们日常的开发中很常见,本文也是在这种场景下展开分析的。一般的,实现起来有3种方式。
	第一种,在ViewPager设置的监听器OnPageChangeListener的onPageSelected触发后,直接切换Tab或者播放Tween Animation(补间动画)来切换。这种实现方式,在用户体验要求不高的情况下是可取的。
	第二种,使用design包下面的TabLayout(完整路径:android.support.design.widget.TabLayout)来实现,这是谷歌集成的开源控件,并且经过一系列的优化。它支持自定义Tab,但是要自定义指示器好像不是那么方便。总体来说,Tab切换的体验还是不错的,Tab指示器会随着ViewPager的切换而滑动,封装的方法调用起来也很方便。关于它的使用非常简单,我就不把它写在文中了,没接触过的读者可以自己去学习。
	第三种,自定义TabLayout来实现,这是本文的主要内容。思路很简单,直接继承一个HorizontalScrollView,这就是TabLayout。然后,再自定义一个TabStripLayout,它继承自LinearLayout,是一个水平的线性布局。两者按照HorizontalScrollView与LinearLayout结合起来使用的方式,就实现了跟design包下面的TabLayout几乎一样的功能。并且,借助我的这个思路来拓展,指示器可以自定义,Tab也可以自定义了,固定水平宽度的情况也可以很容易就拓展实现。
 先来看看效果,github地址在文末附上。
  

实现思路

 要实现TabLayout,思路非常重要,我看过很多童鞋去实现这个控件,大都不尽人意,其实都是思路不对(思路对的童鞋可以无视下面这段话)。
	一般人首先能想到HorizontalScrollView来实现,可接下来就稀里糊涂的下手了,然后就自己懵逼了。举几个例子,大部分人对指示器的选择就导致他几乎不能实现,他们会选择另外一个View来充当指示器,到最后指示器并不能很愉快地跟着LinearLayout滑动。然后,在选中某个Tab之后,调用smoothScrollTo(x, y)来使Tab滑动到控件中间,这样做出来的控件在整个过程中一抖一抖的,完全不符合沉浸式设计。一部分人到此就打止了,因为是选中Tab之后才滑动嘛,跟用动画来滑动区别不大。当然,也有部分人可以有正确的思路,但是实现出来的效果总是差那么一丢丢。
	下面,我来说说一种比较可取的思路,按照这个思路实现出来的TabLayout才比较令人满意。我仍然采用自定义HorizontalScrollView来实现,命名为TabLayout,它的直接子控件LinearLayout也要采用自定义的,命名为TabStripLayout。从名字上可以看出来,TabLayout用来控制Tab的滑动,TabStripLayout用来控制指示器的滑动。指示器在哪里呢?其实指示器不需要使用第三个View来充当,只需要在TabStripLayout的某个位置绘制一段长度为一个Tab长度的图画即可。当TabLayout与ViewPager一起使用的时候,我们完全采用ViewPager带有滑动偏移量的监听方法来控制TabLayout及指示器的滑动。有了这个控件结构的思路,接下来重点落在了如何计算Tab滑动位置的问题上,请看下面这张图。
	
	从图中我们可以知道,我们每次滑动Tab都是尽量把它移动到TabLayout的中间,这个移动的距离我们不能一步到位,得分步骤分析。首先,我们选中了某个Tab作为参考,这时候我们求出这个Tab距离TabStripLayout最左端的距离left,如果TabLayout将这个Tab向左移left,那么此时这个Tab将会位于TabLayout的最左端。这是不够的,因为我们想把这个Tab移动到中间,这样向左移动得太多了。我们设TabLayout宽度为parentWidth,这个值是可以获取的到的,既然向左移动left会过分移动,那么我们少移动parentWidth/2,这时候Tab的左边缘与TabLayout的中间分隔线重合。这时候表现为向左少移得太多了,这个简单,我只需要再向左多移动半个Tab的宽度,记为width/2。所以,我向左移动的距离为left - parentWidth / 2 + width / 2,这就是每次Tab应该滑动的位置计算公式。再补充一点,left的计算不单单只是Tab左边距离TabStripLayout最左边的距离,因为这会是一个固定值,我们的移动是考虑了偏移量的,偏移量在移动过程中是不断变化的,真正的left应该是Tab.getLeft() + offset才对,我们把它算做每次移动的left。

关键代码解析

	这个部分是从代码的实现层面来解说TabLayout,总分为2个大块,一个是TabStripLayout的实现,另一个是TabLayout本身的实现。TabLayout我分5个步骤来实现,分别是外部参数的传入,控件的初始化,添加Tab子控件,Tab子控件被选中的实现,及对外开放接口的回调。

TabStripLayout的实现

	TabStripLayout的实现是非常简单的,设置好画笔和绘制区域,然后绘制图案即可,当然还包括绘制区域的控制,这是指示器移动的关键。 

	/**
	 * Set the paint.
	 * 
	 * @param paint
	 */
	public void setPaint(Paint paint) {
		mPaint = paint;
	}

	/**
	 * Set the RectF.
	 * 
	 * @param rectF
	 */
	public void setRectF(RectF rectF) {
		mRectF = rectF;
		postInvalidate();
	}

	@Override
	protected void onDraw(Canvas canvas) {
		super.onDraw(canvas);
		if (mPaint != null && mRectF != null) {
			drawStrip(canvas, mPaint, mRectF);
		}
	}

	/**
	 * Draw strip.
	 * 
	 * @param canvas
	 * @param paint
	 * @param rectF
	 */
	protected void drawStrip(Canvas canvas, Paint paint, RectF rectF) {
		canvas.drawRect(rectF, paint);
	}

	/**
	 * Update strip position.
	 * 
	 * @param left
	 */
	public void updateHorizontalStrip(float left) {
		if (mRectF != null) {
			float width = mRectF.width();
			mRectF.left = left;
			mRectF.right = mRectF.left + width;
			postInvalidateOnAnimation();
		}
	}

	开篇提到了指示器的拓展,如果我们要拓展自己的指示器该怎么办呢?从代码中很容易知道,我们只需要设置不同的画笔,设置不同的绘制区域,重写drawStrip方法,在这个方法绘制自己想要的图案就可以了。至于指示器位置的更新,这个方法对于所有指示器样式通常都是一样的,无非是改变指示器区域左边的位置而已。

TabLayout的实现

	TabLayout的实现分为以下几个方面,下面是详细情况。

1、外部参数的传入

	外部参数值提供了3个,分别是Tab正常状态和选中状态的颜色,Tab的宽。

	/**
	 * Set tab normal color.
	 * 
	 * @param normalColor
	 */
	public void setNormalColor(int normalColor) {
		mNormalColor = normalColor;
	}

	/**
	 * Set tab selected color.
	 * 
	 * @param selectedColor
	 */
	public void setSelectedColor(int selectedColor) {
		mSelectedColor = selectedColor;
	}

	/**
	 * Set tab width.
	 * 
	 * @param tabWidth
	 * @param isPx
	 *            Whether using pixel for unit.
	 */
	public void setTabWidth(int tabWidth, boolean isPx) {
		if (isPx) {
			mTabWidth = tabWidth;
		} else {
			mTabWidth = getDipSize(tabWidth);
		}
	}

2、控件的初始化

	控件初始化的主要工作是完成TabLayout与TabStripLayout相关联,实际上就是把TabStripLayout添加到TabLayout里面。然后,对指示器画笔做了初始化,对指示器宽度也做了初始化设置,并且选中第一个Tab。
	private void init(Context context) {
		TabStripLayout tabStrip = new TabStripLayout(context);
		// Initialise paint.
		Paint paint = new Paint();
		paint.setAntiAlias(true);
		paint.setDither(true);
		paint.setStyle(Paint.Style.FILL);
		paint.setColor(mSelectedColor);
		tabStrip.setPaint(paint);
		// Add tab strip view.
		addTabStrip(tabStrip);
	}

	private void addTabStrip(TabStripLayout tabStrip) {
		if (getChildCount() > 0) {
			removeAllViews();
		}
		int width = LayoutParams.WRAP_CONTENT;
		int height = LayoutParams.MATCH_PARENT;
		addView(tabStrip, width, height);
		mTabStripLayout = tabStrip;
	}

	/**
	 * Initialise strip since tabs added.
	 */
	public void initStrip() {
		if (mTabStripLayout.getChildCount() <= 0) {
			mTabStripLayout.setRectF(new RectF());
			return;
		}
		// Select the first tab.
		mLastSelectedTab = (TextView) mTabStripLayout.getChildAt(0);
		mLastSelectedTab.setTextColor(mSelectedColor);
		post(new Runnable() {
			@Override
			public void run() {
				int w = mTabWidth;
				int h = getHeight();
				float left = 0;
				float top = h - getDipSize(2);
				float right = w;
				float bottom = h;
				RectF rectF = new RectF(left, top, right, bottom);
				// Initialise the first strip.
				mTabStripLayout.setRectF(rectF);
			}
		});
	}

3、添加Tab子控件

	这一步是向TabLayout添加子控件,实际上是添加到TabStripLayout控件中。添加的参数是索引及对应的数据,Tab是默认的TextView。如果要拓展成其他控件,直接把TextView换成自己想要的控件即可。
	/**
	 * Add tab to strip
	 * 
	 * @param index
	 *            The index of tab.
	 * @param title
	 *            The title of tab.
	 */
	public void addTab(int index, String title) {
		int textSize = 15;
		TextView tab = new TextView(getContext());
		tab.setText(title);
		tab.setTextSize(textSize);
		tab.setTextColor(mNormalColor);
		tab.setGravity(Gravity.CENTER);
		tab.setTypeface(Typeface.DEFAULT_BOLD);
		tab.setOnClickListener(this);
		tab.setFocusable(true);
		tab.setTag(index);
		tab.setLayoutParams(createTabParams());
		// Add tab to strip.
		mTabStripLayout.addView(tab);
	}

4、Tab子控件被选中

	TabLayout的每个Tab被选中有3个方法来控制,一个是在添加Tab时设置的监听,但是它不负责Tab滑动,因为这会引起抖动。第二个是selectTab(int position)方法,这个方法是直接选中某个Tab,内部实际上就只是执行了对应Tab设置的监听方法。第三个方法是selectTab(int position, float positionOffset)方法,这个方法负责移动Tab和指示器,不负责选中Tab。

	@Override
	public void onClick(View view) {
		if (mLastSelectedTab == view) {
			return;
		}
		// Set selected.
		mLastSelectedTab.setTextColor(mNormalColor);
		TextView currentTab = (TextView) view;
		currentTab.setTextColor(mSelectedColor);
		mLastSelectedTab = currentTab;
		// Call back.
		int position = (int) (view.getTag());
		if (mOnSelectedCallBack != null) {
			mOnSelectedCallBack.selected(position);
		}
	}

	/**
	 * Select tab by position.
	 * 
	 * @param position
	 *            The index of selected tab.
	 */
	public void selectTab(int position) {
		View view = mTabStripLayout.getChildAt(position);
		view.performClick();
	}

	/**
	 * Select tab by position and position offset.
	 * 
	 * @param position
	 *            The index of selected tab.
	 * @param positionOffset
	 *            The position offset.
	 */
	public void selectTab(int position, float positionOffset) {
		int lastPosition = mTabStripLayout.indexOfChild(mLastSelectedTab);
		if (lastPosition < 0) {
			return;
		}
		int nextPosition = -1;
		float nextP = 0;// Percent.
		float offset = 0;
		if (lastPosition == position) {// Move right.
			nextPosition = lastPosition + 1;
			nextP = positionOffset;
			offset = mLastSelectedTab.getWidth() * nextP;
		} else if (lastPosition > position) {// Move left.
			nextPosition = lastPosition - 1;
			nextP = 1f - positionOffset;
			offset = -mLastSelectedTab.getWidth() * nextP;
		}
		View nextTab = mTabStripLayout.getChildAt(nextPosition);
		if (nextTab != null) {
			float left = mLastSelectedTab.getLeft() + offset;
			int width = nextTab.getMeasuredWidth();
			int parentWidth = getWidth();
			float newPosition = left - parentWidth / 2 + width / 2;
			// Update position.
			scrollTo((int) newPosition, 0);
			mTabStripLayout.updateHorizontalStrip(left);
		}
	}

	有孩童会问,我只分析了TabLayout与ViewPager应用的场景,那要是没有与ViewPager一起用,而是单纯的切换Fragment或者View,那怎么办?这个简单,我有一种思路,只需要用属性动画的估值器和插值算法来控制带偏移量的移动,当动画计算完成后,我们再选中Tab。当然,这样子做又会跟播放动画一样,在选中之后滑动了,没办法,谁叫你设计成机械切换的,本文讲的只是恰巧利用了ViewPager滑动的特性。

5、对外开放的接口回调

	TabLayout对外开放了一个OnSelectedCallBack的接口,它用于在选中某个Tab之后能够提醒调用方选中了哪个序号的Tab,方便做出回应。
	// Out to user interface.
	public interface OnSelectedCallBack {
		public void selected(int position);
	}

	/**
	 * Set outside callback.
	 * 
	 * @param callBack
	 */
	public void setOnSelectedCallBack(OnSelectedCallBack callBack) {
		mOnSelectedCallBack = callBack;
	}

使用示例

	写完一个控件,我最关心的是怎么用,看完上面的介绍估计大家心里都明白了,无非是拿TabLayout与ViewPager结合起来使用。先看布局文件,就是这么简单。
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@color/content_bg"
    android:orientation="vertical" >

    <com.tablayout.view.TabLayout
        android:id="@+id/tabLayout"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:background="#193357" />

    <android.support.v4.view.ViewPager
        android:id="@+id/viewPager"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />

</LinearLayout>
	然后,看Java代码的实现。先获取到TabLayout与ViewPager控件,再分别初始化,最后设置下监听就可以了。
	private void findView() {
		mTabLayout = (TabLayout) findViewById(R.id.tabLayout);
		mViewPager = (ViewPager) findViewById(R.id.viewPager);
	}

	private void initView() {
		CustomPagerAdapter adapter = new CustomPagerAdapter(
				getSupportFragmentManager());
		mViewPager.setAdapter(adapter);
		for (int i = 0; i < adapter.getCount(); i++) {
			String title = adapter.getPageTitle(i).toString();
			mTabLayout.addTab(i, title);
		}
		mTabLayout.initStrip();
	}

	private void setListener() {
		mViewPager.setOnPageChangeListener(new OnPageChangeListener() {
			@Override
			public void onPageSelected(int position) {
				mTabLayout.selectTab(position);
			}

			@Override
			public void onPageScrolled(int position, float positionOffset,
					int positionOffsetPixels) {
				mTabLayout.selectTab(position, positionOffset);
			}

			@Override
			public void onPageScrollStateChanged(int state) {
			}
		});
		mTabLayout.setOnSelectedCallBack(new OnSelectedCallBack() {
			@Override
			public void selected(int position) {
				mViewPager.setCurrentItem(position);
			}
		});
	}

结语

	先给github地址:https://github.com/ljj19920425/TabLayout	我觉得TabLayout自定义实现关键在指示器的选择和带偏移量滑动上。指示器我们选择绘制的方式,这很会使得实现很灵活,拓展新性强,因为我们可以绘制自己想要的任何图案。指示器的滑动,是根据ViewPager的带偏移量的方法来实现的,这会有向用户展示一个滑动过度的过程,跟着手势移动。
	有时候觉得,虽然谷歌给我们提供了某些控件,但是我们自己实现出来也蛮有意思的。借此,我也把我的思路分享出来,给不会的童鞋参考。
Logo

瓜分20万奖金 获得内推名额 丰厚实物奖励 易参与易上手

更多推荐