Android自定义滑动带(横条指示器)

一.滑动带

什么是Android滑动带,我们举个栗子

image.png

就是图中的黑色长条,最典型的就是用在和viewpager或者多个fragment相关的地方,因此也有人称这个东西为Indicator(指示器)。
那我为什么称它为滑动带呢?有个使用和典型场景,有个控件叫TabLayout,它经常和viewpager一起使用,TabLayout的内部会自带这个横条指示器,看看内部的定义。

image.png

它的官方给它命名为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的坐标图

image.png

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的源码中找到

image.png

然后找setScrollPosition在什么地方出现

image.png

这是viewpager的滑动监听,这里也可以看到传入偏移量positionOffset

image.png
image.png

selectTab是tab的点击切换事件,可以看出这里传的偏移量是0。

这样我们就可以知道整个过程是监听滑动或点击之后,更改左点和右点再重绘调用onDraw

剩下的源码我就不讲了,毕竟我自己也不是全部都理解,这逼不能装。

三.TabLayout基本原理

自然知道了原理,我们就能自己写个SlidingStrip,虽然SlidingStrip实现原理是和tablayout的一样,但是却有些不同。

tablayout中的SlidingTabStrip是包括tab在里面。

image.png
image.png
image.png

从这三个地方可以很方便的看出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.效果展示
15083827033281508382690972.gif

可能看得不太清楚,下面的红条是我自定义的,上面的绿条是tablayout自带的。


按理来说我是完成了这个功能,但是我没有完善这个功能。

有的盆友会说人家自带的是有个滑动的效果,你这个闪现的效果太lowB了,我只能说那没办法,原生的加了动画,我是没辙了,动画这块我不敢装13

private ValueAnimatorCompat mIndicatorAnimator;

其实如果使用Tablayout当tab的话,在tabMode="scrollable"的时候会出问题。会发生这样的严重BUG。

15083836572711508383651840.gif

滑动到后时对不上,而且一个屏幕能放下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

image.png
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();
            }
        }
image.png

从正面看是真看不懂,只能从结果去推, 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我不知道怎么用,我最多只能知道滑动后的左点和右点是这里设置的

image.png

至于怎么算得到的,我太菜,看不懂,希望有大神看到可以指导一下。那到这里这章就结束了,简单的自定义滑动带只能用于禁止滑动的tablayout,之后如果我研究出来源码内部怎么做的,我会再重新更新这篇文章。

你可能感兴趣的:(Android自定义滑动带(横条指示器))