一.滑动带
什么是Android滑动带,我们举个栗子
就是图中的黑色长条,最典型的就是用在和viewpager或者多个fragment相关的地方,因此也有人称这个东西为Indicator(指示器)。
那我为什么称它为滑动带呢?有个使用和典型场景,有个控件叫TabLayout,它经常和viewpager一起使用,TabLayout的内部会自带这个横条指示器,看看内部的定义。
它的官方给它命名为SlidingTabStrip,我翻译过来就是滑动带、滑动条。Tab是和TabLayout相关的命名,我可以再接下来都叫它SlidingStrip
二.自定义滑动带
1. 为什么要自定义SlidingStrip
既然系统的控件已经帮我封装好了,为什么还要重复造轮子。有时候可能某种特殊情况不适用TabLayout,需要自定义Tab或者其它一些SlidingStrip和Tab不连用的状态,那时候就只能自己写个SlidingStrip。
2.怎么自定义SlidingStrip
怎么去自定义,当然每个人都有每个人的做法,或者你脑洞能想出实现这个功能的方法,但这里既然官方都写了,我个人肯定是会按照官方的做法去做。至于官方怎么做的,我们只能看看源码,看TabLayout内部的SlidingTabStrip类
private class SlidingTabStrip extends LinearLayout {
private int mSelectedIndicatorHeight;
private final Paint mSelectedIndicatorPaint;
private int mSelectedPosition = -1;
private float mSelectionOffset;
private int mIndicatorLeft = -1;
private int mIndicatorRight = -1;
private ValueAnimatorCompat mIndicatorAnimator;
SlidingTabStrip(Context context) {
super(context);
setWillNotDraw(false);
mSelectedIndicatorPaint = new Paint();
}
void setSelectedIndicatorColor(int color) {
if (mSelectedIndicatorPaint.getColor() != color) {
mSelectedIndicatorPaint.setColor(color);
ViewCompat.postInvalidateOnAnimation(this);
}
}
void setSelectedIndicatorHeight(int height) {
if (mSelectedIndicatorHeight != height) {
mSelectedIndicatorHeight = height;
ViewCompat.postInvalidateOnAnimation(this);
}
}
boolean childrenNeedLayout() {
for (int i = 0, z = getChildCount(); i < z; i++) {
final View child = getChildAt(i);
if (child.getWidth() <= 0) {
return true;
}
}
return false;
}
void setIndicatorPositionFromTabPosition(int position, float positionOffset) {
if (mIndicatorAnimator != null && mIndicatorAnimator.isRunning()) {
mIndicatorAnimator.cancel();
}
mSelectedPosition = position;
mSelectionOffset = positionOffset;
updateIndicatorPosition();
}
float getIndicatorPosition() {
return mSelectedPosition + mSelectionOffset;
}
@Override
protected void onMeasure(final int widthMeasureSpec, final int heightMeasureSpec) {
......
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
......
}
private 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);
}
}
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) {
ValueAnimatorCompat animator = mIndicatorAnimator = ViewUtils.createAnimator();
animator.setInterpolator(AnimationUtils.FAST_OUT_SLOW_IN_INTERPOLATOR);
animator.setDuration(duration);
animator.setFloatValues(0, 1);
animator.setUpdateListener(new ValueAnimatorCompat.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimatorCompat animator) {
final float fraction = animator.getAnimatedFraction();
setIndicatorPosition(
AnimationUtils.lerp(startLeft, targetLeft, fraction),
AnimationUtils.lerp(startRight, targetRight, fraction));
}
});
animator.setListener(new ValueAnimatorCompat.AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(ValueAnimatorCompat animator) {
mSelectedPosition = position;
mSelectionOffset = 0f;
}
});
animator.start();
}
}
@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);
}
}
}
PS:我暂时把onMeasure和onLayout两个方法给隐藏内部了。
(1)可以从draw方法中看出这个视觉上的横条是用Paint画出来的。这样的话每当切换tab时,都会进行重绘,所以能在很多地方找到这个方法:ViewCompat.postInvalidateOnAnimation(this);
(2)可以从代码中看出是获取左边一点和右边一点画出,这两个点是根据getChildAt得到,分别为子view的左边和右边
重要方法
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);
}
获取当前的子view,左点设为子view的左边距viewgroup的距离,右点设为子view的右边距到viewgroup左边的距离。解释麻烦,我直接贴张view的坐标图
PS:补充一点,还有个方法是能获取view相对于屏幕左上角的宽高
int[] wandh = new int[2];
view.getLocationInWindow(wandh);
wandh[0]是宽,wandh[1]是高
继续说上面, if (mSelectionOffset > 0f && mSelectedPosition < getChildCount() - 1) 这个判断里面的代码我暂时不是很懂,反正mSelectionOffset表示的是viewpager的偏移量,只有在viewpager滑动的时候才会进这个判断里面,这个可以先不用管。
设置宽高之后最后重绘画布
private 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);
}
}
这样就是整个滑动条展示的整个过程,对于代码来说,可能一些地方一些算法不是很容易理解,我也没全看懂,但是流程却很容易看出:画出长方形 -> 如果有切换的话调用updateIndicatorPosition() ->调用setIndicatorPosition()重绘。
再来看看什么时候调用updateIndicatorPosition()这个方法设置左右点并进行重绘,可以在tablayout的源码中找到
然后找setScrollPosition在什么地方出现
这是viewpager的滑动监听,这里也可以看到传入偏移量positionOffset
selectTab是tab的点击切换事件,可以看出这里传的偏移量是0。
这样我们就可以知道整个过程是监听滑动或点击之后,更改左点和右点再重绘调用onDraw
剩下的源码我就不讲了,毕竟我自己也不是全部都理解,这逼不能装。
三.TabLayout基本原理
自然知道了原理,我们就能自己写个SlidingStrip,虽然SlidingStrip实现原理是和tablayout的一样,但是却有些不同。
tablayout中的SlidingTabStrip是包括tab在里面。
从这三个地方可以很方便的看出TabLayout是一个HorizontalScrollView,它的子view是SlidingStrip,SlidingStrip中包含tabview。
为什么要要说这些,因为我以前遇到一个坑,我以前想要获取到tablayout的tabview(子view),没有找到哪个方法是能拿到了,上网找了很多文章都是写得很扯淡,直到我看了源码,我才知道你可以这样拿到tabview(子View)
((LinearLayout)mTabLayout.getChildAt(0)).getChildAt(i)
四.我的滑动带
扯了这么多终于扯到重点了,我们自己写个简单的滑动带,以后有时间在慢慢去完善。
ps:我的做法和tablayout的不一样,我不打算在SlidingStrip中加tabview,我要把Tabview分离出去,滑动带只做滑动带内部应该做的逻辑,所以我的思路是还要写个适配器去连接tab和SlidingStrip。
1.SlidingStrip
public class NewLineIndicator extends View{
private ViewGroup viewgroup;
private List chindViewList = new ArrayList<>();
// 记录当前的标签
private int position = 0;
// 记录当前滑动条的起始和终止
private float mLeft = 0;
private float mRight = 0;
private Paint paint;
private NewLineIndicatorAdapter adapter;
public NewLineIndicator(Context context) {
super(context);
}
public NewLineIndicator(Context context, AttributeSet attrs) {
super(context, attrs);
}
public NewLineIndicator(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
private void initChildView(){
paint = new Paint();
paint.setColor(getResources().getColor(R.color.price_color));
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
mLeft = viewgroup.getLeft() + chindViewList.get(position).getLeft();
mRight = viewgroup.getLeft() + chindViewList.get(position).getRight();
Log.v("wori","mLeft"+chindViewList.get(position).getLeft()+" mRight"+chindViewList.get(position).getRight());
canvas.drawRect(mLeft, 0, mRight, getHeight(), paint);
}
public void setPosition(int position) {
this.position = position;
}
public void setAdapter(NewLineIndicatorAdapter adapter) {
this.adapter = adapter;
viewgroup = adapter.getTabLayout();
chindViewList = adapter.getChildViewList();
initChildView();
adapter.setIndicator(this);
adapter.initIndicator();
}
/**
* 当tab改变时
*/
public void tabChange(){
// 重绘
// invalidate();
ViewCompat.postInvalidateOnAnimation(this);
}
}
我这是继承view,写急了,讲道理应该是继承viewgroup才对,我之后有时间会改过来,记住,虽然当成view也能实现功能,但是按理来说应该是viewgroup而不是view,所以你写继承Layout或者什么的,只要是viewgroup就行。
2.适配器
public abstract class NewLineIndicatorAdapter {
private Context context;
private TabLayout mTabLayout;
private NewLineIndicator mIndicator;
public NewLineIndicatorAdapter(Context context,TabLayout mTabLayout){
this.context = context;
this.mTabLayout = mTabLayout;
}
public void setIndicator(NewLineIndicator mIndicator) {
this.mIndicator = mIndicator;
}
public void initIndicator(){
setTabLayoutChange();
}
/**
* 设置TabLayout点击哪个tab的监听
*/
private void setTabLayoutChange(){
mTabLayout.setOnTabSelectedListener(new TabLayout.OnTabSelectedListener() {
@Override
public void onTabSelected(TabLayout.Tab tab) {
mIndicator.setPosition(tab.getPosition());
mIndicator.tabChange();
}
@Override
public void onTabUnselected(TabLayout.Tab tab) {
}
@Override
public void onTabReselected(TabLayout.Tab tab) {
}
});
}
public TabLayout getTabLayout() {
return mTabLayout;
}
public List getChildViewList(){
List childViewList = new ArrayList<>();
for (int i = 0; i < ((LinearLayout)mTabLayout.getChildAt(0)).getChildCount(); i++) {
childViewList.add(((LinearLayout)mTabLayout.getChildAt(0)).getChildAt(i));
}
return childViewList;
}
}
为了之后和原生的TabLayout进行对比,我这里适配器就用了TabLayout的tab。
3.调用
(1) 适配器是抽象方法,先继承
public static class TestIndicatorAdapter extends NewLineIndicatorAdapter{
public TestIndicatorAdapter(Context context, TabLayout mTabLayout) {
super(context, mTabLayout);
}
}
(2)调用
adapter = new TestIndicatorAdapter(this,tab);
indicator.setAdapter(adapter);
代码都很简单,我觉得解释或源码后没必要再重复讲,但是我是写个小demo,所以没写完整,接口什么的我都没定义,直接就用抽象类了,赶时间没办法。
4.效果展示
可能看得不太清楚,下面的红条是我自定义的,上面的绿条是tablayout自带的。
按理来说我是完成了这个功能,但是我没有完善这个功能。
有的盆友会说人家自带的是有个滑动的效果,你这个闪现的效果太lowB了,我只能说那没办法,原生的加了动画,我是没辙了,动画这块我不敢装13
private ValueAnimatorCompat mIndicatorAnimator;
其实如果使用Tablayout当tab的话,在tabMode="scrollable"的时候会出问题。会发生这样的严重BUG。
滑动到后时对不上,而且一个屏幕能放下5个,滑动到第6个时滑动带会消失,这是因为第6个之后的子view的左点超出了屏幕,所以不是消失,而是滑出了屏幕。
那要怎么解决这个问题,其实直接不让tab滑动就行了,开个玩笑,解决问题怎么能这么随意呢,那就看看原生的是怎么去解决的。
既然tablayout是继承HorizontalScrollView,那我就先找找tablayout中有没有监听HorizontalScrollView滚动,发现没有,那估计就写在那个重要的监听刷新方法里面。
private void setScrollPosition(int position, float positionOffset, boolean updateSelectedText,
boolean updateIndicatorPosition) {
final int roundedPosition = Math.round(position + positionOffset);
if (roundedPosition < 0 || roundedPosition >= mTabStrip.getChildCount()) {
return;
}
// Set the indicator position, if enabled
if (updateIndicatorPosition) {
mTabStrip.setIndicatorPositionFromTabPosition(position, positionOffset);
}
// Now update the scroll position, canceling any running animation
if (mScrollAnimator != null && mScrollAnimator.isRunning()) {
mScrollAnimator.cancel();
}
scrollTo(calculateScrollXForTab(position, positionOffset), 0);
// Update the 'selected state' view as we scroll, if enabled
if (updateSelectedText) {
setSelectedTabView(roundedPosition);
}
}
第一块代码是四舍五入没联系,第二块代码就是刚才的调用重绘,第三块代码是停止动画也没联系,关键肯定在后面几行。
scrollTo(calculateScrollXForTab(position, positionOffset), 0);
// Update the 'selected state' view as we scroll, if enabled
if (updateSelectedText) {
setSelectedTabView(roundedPosition);
}
先看看第一行它让tablayout滚动到哪个横坐标
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;
return selectedChild.getLeft()
+ ((int) ((selectedWidth + nextWidth) * positionOffset * 0.5f))
+ (selectedChild.getWidth() / 2)
- (getWidth() / 2);
}
return 0;
}
ps:这种情况下我们都默认不考虑偏移量positionOffset
(1)mMode就是我们设置的scrollable就是0,所以进入if语句
(2)然后获取当前点击的子view的宽度和下一个子view的宽度
(3)最后返回的我也不懂是怎么得出这个公式的,反正就是把点击的子View移动到中间。
移动到中间之后调用setSelectedTabView
private void setSelectedTabView(int position) {
final int tabCount = mTabStrip.getChildCount();
if (position < tabCount && !mTabStrip.getChildAt(position).isSelected()) {
for (int i = 0; i < tabCount; i++) {
final View child = mTabStrip.getChildAt(i);
child.setSelected(i == position);
}
}
}
看到这我就蒙圈了,这个child.setSelected(i == position);我看不懂,好像这里只是更改状态,和重绘没什么关系。
我认真观察代码,发现我之前找错地方了,点击tab之后调用这个方法animateToTab
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 draw the new
// position now
setScrollPosition(newPosition, 0f, true);
return;
}
final int startScrollX = getScrollX();
final int targetScrollX = calculateScrollXForTab(newPosition, 0);
if (startScrollX != targetScrollX) {
if (mScrollAnimator == null) {
mScrollAnimator = ViewUtils.createAnimator();
mScrollAnimator.setInterpolator(AnimationUtils.FAST_OUT_SLOW_IN_INTERPOLATOR);
mScrollAnimator.setDuration(ANIMATION_DURATION);
mScrollAnimator.setUpdateListener(new ValueAnimatorCompat.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimatorCompat animator) {
scrollTo(animator.getAnimatedIntValue(), 0);
}
});
}
mScrollAnimator.setIntValues(startScrollX, targetScrollX);
mScrollAnimator.start();
}
// Now animate the indicator
mTabStrip.animateIndicatorToPosition(newPosition, ANIMATION_DURATION);
}
startScrollX 是当前的滑动距离,calculateScrollXForTab我在上面的代码贴了,是如果变化的情况下滑动之后的距离,
if (startScrollX != targetScrollX)是判断是否滑动,监听里面有写滑动到的位置scrollTo(animator.getAnimatedIntValue(), 0);
最主要的是mScrollAnicmator.setIntValues(startScrollX, targetScrollX);虽然我对mScrollAnicmator的操作都不理解,但是我觉得这个是一个记录的操作,然后newPosition是点击之后的position,最后调用animateIndicatorToPosition
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) {
ValueAnimatorCompat animator = mIndicatorAnimator = ViewUtils.createAnimator();
animator.setInterpolator(AnimationUtils.FAST_OUT_SLOW_IN_INTERPOLATOR);
animator.setDuration(duration);
animator.setFloatValues(0, 1);
animator.setUpdateListener(new ValueAnimatorCompat.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimatorCompat animator) {
final float fraction = animator.getAnimatedFraction();
setIndicatorPosition(
AnimationUtils.lerp(startLeft, targetLeft, fraction),
AnimationUtils.lerp(startRight, targetRight, fraction));
}
});
animator.setListener(new ValueAnimatorCompat.AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(ValueAnimatorCompat animator) {
mSelectedPosition = position;
mSelectionOffset = 0f;
}
});
animator.start();
}
}
从正面看是真看不懂,只能从结果去推, AnimationUtils.lerp(startLeft, targetLeft, fraction)就是设置的左点,AnimationUtils.lerp(startRight, targetRight, fraction));是右点,setUpdateListener是一个动画更新的监听,换句话说就是时时调用setIndicatorPosition重绘直到动画结束,那就看看lerp里面肯定有一个关键的点。
static int lerp(int startValue, int endValue, float fraction) {
return startValue + Math.round(fraction * (endValue - startValue));
}
这个fraction我不直到是什么,然后这24dp我不知道怎么用,我最多只能知道滑动后的左点和右点是这里设置的
至于怎么算得到的,我太菜,看不懂,希望有大神看到可以指导一下。那到这里这章就结束了,简单的自定义滑动带只能用于禁止滑动的tablayout,之后如果我研究出来源码内部怎么做的,我会再重新更新这篇文章。