Android TabLayout
前言
很久很久没写过源码解析了,不是自己没有看了,只是没有记录了,却发现不记录的话,似懂非懂,时间久了就忘得差不多了,用到了还是得再学一遍,忍住提笔一篇 TabLayout 源码学习。
Hello World
依赖
添加 support design 包
implementation 'com.android.support:design:27.1.1'
xml
添加一个 TabLayout 就可以了
<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout 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"
tools:context=".TabLayoutActivity">
<android.support.design.widget.TabLayout
android:id="@+id/tab_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"/>
</android.support.constraint.ConstraintLayout>
MainActivity.java
import android.support.design.widget.TabLayout;
import android.support.v7.app.AppCompatActivity;
import android.os.Bundle;
public class TabLayoutActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_tab_layout);
TabLayout mTabLayout = findViewById(R.id.tab_layout);
// 添加 tab item
mTabLayout.addTab(mTabLayout.newTab().setText("TAB1"));
mTabLayout.addTab(mTabLayout.newTab().setText("TAB2"));
mTabLayout.addTab(mTabLayout.newTab().setText("TAB3"));
mTabLayout.addTab(mTabLayout.newTab().setText("TAB4"));
}
}
效果
Screenshot_1550732046.png源码学习
其实,实现这样一个布局并不难,让我们来看看里面所有的内容
前世今生
继承自 HorizontalScrollView 因为他支持滚动
public class TabLayout extends HorizontalScrollView
TabLayout 支持两种模式,一种是固定的,一种是可滚动的(tab 太多,一屏显示不下,可使用这种模式,否则默认为平分)
/**
* Scrollable tabs display a subset of tabs at any given moment, and can contain longer tab
* labels and a larger number of tabs. They are best used for browsing contexts in touch
* interfaces when users don’t need to directly compare the tab labels.
*
* @see #setTabMode(int)
* @see #getTabMode()
*/
public static final int MODE_SCROLLABLE = 0;
/**
* Fixed tabs display all tabs concurrently and are best used with content that benefits from
* quick pivots between tabs. The maximum number of tabs is limited by the view’s width.
* Fixed tabs have equal width, based on the widest tab label.
*
* @see #setTabMode(int)
* @see #getTabMode()
*/
public static final int MODE_FIXED = 1;
/**
* @hide
*/
@RestrictTo(LIBRARY_GROUP)
@IntDef(value = {MODE_SCROLLABLE, MODE_FIXED})
@Retention(RetentionPolicy.SOURCE)
public @interface Mode {}
Tab 的位置有两种,一种是居中,一种是平分
/**
* Gravity used to fill the {@link TabLayout} as much as possible. This option only takes effect
* when used with {@link #MODE_FIXED}.
*
* @see #setTabGravity(int)
* @see #getTabGravity()
*/
public static final int GRAVITY_FILL = 0;
/**
* Gravity used to lay out the tabs in the center of the {@link TabLayout}.
*
* @see #setTabGravity(int)
* @see #getTabGravity()
*/
public static final int GRAVITY_CENTER = 1;
/**
* @hide
*/
@RestrictTo(LIBRARY_GROUP)
@IntDef(flag = true, value = {GRAVITY_FILL, GRAVITY_CENTER})
@Retention(RetentionPolicy.SOURCE)
public @interface TabGravity {}
居中模式
创建 Tab
使用代码创建 Tab
public Tab newTab() {
Tab tab = sTabPool.acquire();
if (tab == null) {
tab = new Tab();
}
tab.mParent = this;
tab.mView = createTabView(tab);
return tab;
}
Tab 还使用了 Pool,还是挺细心的
private static final Pools.Pool<Tab> sTabPool = new Pools.SynchronizedPool<>(16);
可滑动的指示条形图
自定义 ViewGroup
private class SlidingTabStrip extends LinearLayout
onMeasure
如果设置了 MODE_FIXED 和 GRAVITY_CENTER 则需要重新测量,目的就是让居中,每个 ITEM 的宽度都是一样的,而且等于最大的一个,如果一屏放得下则需要重新设置每个 ITEM 的大小,并且重新测量。如果发不下,那么侧设置GRAVITY_FILL
@Override
protected void onMeasure(final int widthMeasureSpec, final int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
if (MeasureSpec.getMode(widthMeasureSpec) != MeasureSpec.EXACTLY) {
// HorizontalScrollView will first measure use with UNSPECIFIED, and then with
// EXACTLY. Ignore the first call since anything we do will be overwritten anyway
return;
}
// 重新测量
if (mMode == MODE_FIXED && mTabGravity == GRAVITY_CENTER) {
final int count = getChildCount();
// First we'll find the widest tab
int largestTabWidth = 0;
for (int i = 0, z = count; i < z; i++) {
View child = getChildAt(i);
if (child.getVisibility() == VISIBLE) {
largestTabWidth = Math.max(largestTabWidth, child.getMeasuredWidth());
}
}
if (largestTabWidth <= 0) {
// If we don't have a largest child yet, skip until the next measure pass
return;
}
// 间隔
final int gutter = dpToPx(FIXED_WRAP_GUTTER_MIN);
boolean remeasure = false;
// 一屏放得下
if (largestTabWidth * count <= getMeasuredWidth() - gutter * 2) {
// If the tabs fit within our width minus gutters, we will set all tabs to have
// the same width
for (int i = 0; i < count; i++) {
final LinearLayout.LayoutParams lp =
(LayoutParams) getChildAt(i).getLayoutParams();
if (lp.width != largestTabWidth || lp.weight != 0) {
lp.width = largestTabWidth;
lp.weight = 0;
remeasure = true;
}
}
} else {
// If the tabs will wrap to be larger than the width minus gutters, we need
// to switch to GRAVITY_FILL
mTabGravity = GRAVITY_FILL;
updateTabViews(false);
remeasure = true;
}
if (remeasure) {
// Now re-measure after our changes
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
}
}
}
如何实现动画?
通过移动 IndicatorView
onLayout
mIndicatorAnimator 是动画辅助类,在 onLayout 中,非空而且正在运行则看取消,然后调用 animateIndicatorToPosition,动画调用,否则直接设置位置,不支持动画
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
super.onLayout(changed, l, t, r, b);
if (mIndicatorAnimator != null && mIndicatorAnimator.isRunning()) {
// If we're currently running an animation, lets cancel it and start a
// new animation with the remaining duration
mIndicatorAnimator.cancel();
final long duration = mIndicatorAnimator.getDuration();
animateIndicatorToPosition(mSelectedPosition,
Math.round((1f - mIndicatorAnimator.getAnimatedFraction()) * duration));
} else {
// If we've been layed out, update the indicator position
updateIndicatorPosition();
}
}
updateIndicatorPosition 首先获取选中的 View,然后看 mSelectionOffset 是否大于零,说明发生滚动,则需要重新计算新位置
private void updateIndicatorPosition() {
final View selectedTitle = getChildAt(mSelectedPosition);
int left, right;
if (selectedTitle != null && selectedTitle.getWidth() > 0) {
left = selectedTitle.getLeft();
right = selectedTitle.getRight();
if (mSelectionOffset > 0f && mSelectedPosition < getChildCount() - 1) {
// Draw the selection partway between the tabs
View nextTitle = getChildAt(mSelectedPosition + 1);
left = (int) (mSelectionOffset * nextTitle.getLeft() +
(1.0f - mSelectionOffset) * left);
right = (int) (mSelectionOffset * nextTitle.getRight() +
(1.0f - mSelectionOffset) * right);
}
} else {
left = right = -1;
}
setIndicatorPosition(left, right);
}
// mIndicatorLeft 和 mIndicatorRight 控制了线的起始位置
void setIndicatorPosition(int left, int right) {
if (left != mIndicatorLeft || right != mIndicatorRight) {
// If the indicator's left/right has changed, invalidate
mIndicatorLeft = left;
mIndicatorRight = right;
ViewCompat.postInvalidateOnAnimation(this);
}
}
移动动画,移动间隔大的话,并不会从当前位置直接移动,而是跳跃一段距离再移动,通过 startLeft 和 startRight 控制,并且使用 ValueAnimator 来实现动画
这个不错,同意了 fraction 0-1 ,通过函数计算进度
setIndicatorPosition(
AnimationUtils.lerp(startLeft, targetLeft, fraction),
AnimationUtils.lerp(startRight, targetRight, fraction));
void animateIndicatorToPosition(final int position, int duration) {
if (mIndicatorAnimator != null && mIndicatorAnimator.isRunning()) {
mIndicatorAnimator.cancel();
}
final boolean isRtl = ViewCompat.getLayoutDirection(this)
== ViewCompat.LAYOUT_DIRECTION_RTL;
final View targetView = getChildAt(position);
if (targetView == null) {
// If we don't have a view, just update the position now and return
updateIndicatorPosition();
return;
}
final int targetLeft = targetView.getLeft();
final int targetRight = targetView.getRight();
final int startLeft;
final int startRight;
if (Math.abs(position - mSelectedPosition) <= 1) {
// If the views are adjacent, we'll animate from edge-to-edge
startLeft = mIndicatorLeft;
startRight = mIndicatorRight;
} else {
// Else, we'll just grow from the nearest edge
final int offset = dpToPx(MOTION_NON_ADJACENT_OFFSET);
if (position < mSelectedPosition) {
// We're going end-to-start
if (isRtl) {
startLeft = startRight = targetLeft - offset;
} else {
startLeft = startRight = targetRight + offset;
}
} else {
// We're going start-to-end
if (isRtl) {
startLeft = startRight = targetRight + offset;
} else {
startLeft = startRight = targetLeft - offset;
}
}
}
// 开始移动位置
if (startLeft != targetLeft || startRight != targetRight) {
ValueAnimator animator = mIndicatorAnimator = new ValueAnimator();
animator.setInterpolator(AnimationUtils.FAST_OUT_SLOW_IN_INTERPOLATOR);
animator.setDuration(duration);
animator.setFloatValues(0, 1);
animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animator) {
final float fraction = animator.getAnimatedFraction();
setIndicatorPosition(
AnimationUtils.lerp(startLeft, targetLeft, fraction),
AnimationUtils.lerp(startRight, targetRight, fraction));
}
});
animator.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animator) {
mSelectedPosition = position;
mSelectionOffset = 0f;
}
});
animator.start();
}
}
onDraw 很简单
@Override
public void draw(Canvas canvas) {
super.draw(canvas);
// Thick colored underline below the current selection
if (mIndicatorLeft >= 0 && mIndicatorRight > mIndicatorLeft) {
canvas.drawRect(mIndicatorLeft, getHeight() - mSelectedIndicatorHeight,
mIndicatorRight, getHeight(), mSelectedIndicatorPaint);
}
}
TabView
接下来再看看上面的内容
class TabView extends LinearLayout {
private Tab mTab;
private TextView mTextView;
private ImageView mIconView;
private View mCustomView;
private TextView mCustomTextView;
private ImageView mCustomIconView;
private int mDefaultMaxLines = 2;
}
如何实现监听的,对每个 TabView 设置点击事件,重写了 performClick,其中调用 mTab.select,mTab 拥有 TabLayout 的引用,
@Override
public boolean performClick() {
final boolean handled = super.performClick();
if (mTab != null) {
if (!handled) {
playSoundEffect(SoundEffectConstants.CLICK);
}
mTab.select();
return true;
} else {
return handled;
}
}
/**
* Select this tab. Only valid if the tab has been added to the action bar.
*/
public void select() {
if (mParent == null) {
throw new IllegalArgumentException("Tab not attached to a TabLayout");
}
mParent.selectTab(this);
}
tabLayout 回调事件,
void selectTab(Tab tab) {
selectTab(tab, true);
}
void selectTab(final Tab tab, boolean updateIndicator) {
final Tab currentTab = mSelectedTab;
if (currentTab == tab) {
if (currentTab != null) {
dispatchTabReselected(tab);
animateToTab(tab.getPosition());
}
} else {
final int newPosition = tab != null ? tab.getPosition() : Tab.INVALID_POSITION;
if (updateIndicator) {
if ((currentTab == null || currentTab.getPosition() == Tab.INVALID_POSITION
&& newPosition != Tab.INVALID_POSITION) {
// If we don't currently have a tab, just draw the indicator
setScrollPosition(newPosition, 0f, true);
} else {
animateToTab(newPosition);
}
if (newPosition != Tab.INVALID_POSITION) {
setSelectedTabView(newPosition);
}
}
if (currentTab != null) {
dispatchTabUnselected(currentTab);
}
mSelectedTab = tab;
if (tab != null) {
dispatchTabSelected(tab);
}
}
}
移动 Tab,动画
private void animateToTab(int newPosition) {
if (newPosition == Tab.INVALID_POSITION) {
return;
}
if (getWindowToken() == null || !ViewCompat.isLaidOut(this)
|| mTabStrip.childrenNeedLayout()) {
// If we don't have a window token, or we haven't been laid out yet just dra
// position now
setScrollPosition(newPosition, 0f, true);
return;
}
final int startScrollX = getScrollX();
final int targetScrollX = calculateScrollXForTab(newPosition, 0);
if (startScrollX != targetScrollX) {
ensureScrollAnimator();
mScrollAnimator.setIntValues(startScrollX, targetScrollX);
mScrollAnimator.start();
}
// Now animate the indicator
mTabStrip.animateIndicatorToPosition(newPosition, ANIMATION_DURATION);
}
计算移动的距离,让选中的 tab 位于中间位置,由于 Android ScrollView 默认不会滚动超出边界,所以如果到达边界也不会继续滚动了
private int calculateScrollXForTab(int position, float positionOffset) {
if (mMode == MODE_SCROLLABLE) {
final View selectedChild = mTabStrip.getChildAt(position);
final View nextChild = position + 1 < mTabStrip.getChildCount()
? mTabStrip.getChildAt(position + 1)
: null;
final int selectedWidth = selectedChild != null ? selectedChild.getWidth() : 0;
final int nextWidth = nextChild != null ? nextChild.getWidth() : 0;
// base scroll amount: places center of tab in center of parent
int scrollBase = selectedChild.getLeft() + (selectedWidth / 2) - (getWidth() / 2);
// offset amount: fraction of the distance between centers of tabs
int scrollOffset = (int) ((selectedWidth + nextWidth) * 0.5f * positionOffset);
return (ViewCompat.getLayoutDirection(this) == ViewCompat.LAYOUT_DIRECTION_LTR)
? scrollBase + scrollOffset
: scrollBase - scrollOffset;
}
return 0;
}
ViewPager
其实很简单,就是给 ViewPager 添加一个 OnPageChangeListener 就行了,代码也很简单,在 onPageScrolled 中改变 指示条 的位置,在 onPageSelected 中改变 选中状态
public static class TabLayoutOnPageChangeListener implements ViewPager.OnPageChangeListener {
private final WeakReference<TabLayout> mTabLayoutRef;
private int mPreviousScrollState;
private int mScrollState;
public TabLayoutOnPageChangeListener(TabLayout tabLayout) {
mTabLayoutRef = new WeakReference<>(tabLayout);
}
@Override
public void onPageScrollStateChanged(final int state) {
mPreviousScrollState = mScrollState;
mScrollState = state;
}
@Override
public void onPageScrolled(final int position, final float positionOffset,
final int positionOffsetPixels) {
final TabLayout tabLayout = mTabLayoutRef.get();
if (tabLayout != null) {
// Only update the text selection if we're not settling, or we are settling after
// being dragged
final boolean updateText = mScrollState != SCROLL_STATE_SETTLING ||
mPreviousScrollState == SCROLL_STATE_DRAGGING;
// Update the indicator if we're not settling after being idle. This is caused
// from a setCurrentItem() call and will be handled by an animation from
// onPageSelected() instead.
final boolean updateIndicator = !(mScrollState == SCROLL_STATE_SETTLING
&& mPreviousScrollState == SCROLL_STATE_IDLE);
tabLayout.setScrollPosition(position, positionOffset, updateText, updateIndicator);
}
}
@Override
public void onPageSelected(final int position) {
final TabLayout tabLayout = mTabLayoutRef.get();
if (tabLayout != null && tabLayout.getSelectedTabPosition() != position
&& position < tabLayout.getTabCount()) {
// Select the tab, only updating the indicator if we're not being dragged/settled
// (since onPageScrolled will handle that).
final boolean updateIndicator = mScrollState == SCROLL_STATE_IDLE
|| (mScrollState == SCROLL_STATE_SETTLING
&& mPreviousScrollState == SCROLL_STATE_IDLE);
tabLayout.selectTab(tabLayout.getTabAt(position), updateIndicator);
}
}
void reset() {
mPreviousScrollState = mScrollState = SCROLL_STATE_IDLE;
}
}
小结
基本上看完了,但对于一些细节,滚动边界问题还没有深刻的理解,只知道大概的逻辑