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

作者: 键盘上的麒麟臂 | 来源:发表于2017-10-19 15:49 被阅读263次

    一.滑动带

    什么是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<View> 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<View> getChildViewList(){
            List<View> 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自定义滑动带(横条指示器)

        本文链接:https://www.haomeiwen.com/subject/plzyuxtx.html