仿网易新闻标签选择器(可拖动)-TabMoveLayout

仿网易新闻标签栏-TabMoveLayout

网易新闻标签栏的实现效果我一直想实现试试,最近发现支付宝的应用栏也变成了这样,最近花了点时间终于实现,初步实现效果如下,后面有时间还会继续完善

实现功能

1.长按抖动
2.标签可随意拖动,其他标签随之变换位置
3.拖动变换子View顺序

后续想实现

1.仿照ListView+Adapter,利用adapter模式分离,实现自定义View的拖拽(现在只能为TextView)
2.实现自定义TextView,随文字长度变换字体大小
3.详细完善一些细节
4.设计完成后通过JitPack发布

难点:

1.熟悉自定义ViewGroup过程,onMeasure、onLayout
2.ViewGroup事件处理
3.多种拖动情况考虑(位置移动计算)
4.ViewGroup中子View的变更替换添加

实现思路:

1.自定义ViewGroup,实现标签栏的排列,这里我以4列为例(onMeasure,onLayout)

2.实现触摸标签的拖动,通过onTouch事件,在DOWN:获取触摸的x,y坐标,找到被触摸的View,在MOVE:通过view.layout()方法动态改变View的位置

3.其他标签的位置变换,主要通过TranslateAnimation,在MOVE:找到拖动过程中经过的View,并执行相应的Animation
(这里重点要考虑清楚所有拖动可能的情况)

4.拖动结束后,随之变换ViewGroup中view的实际位置,通过removeViewAt和addView进行添加和删除,中间遇到一点问题(博客)已分析。

关键代码:

1.自定义ViewGroup

这里主要是onMeasure和onLayout方法。这里我要说一下我的布局方式

 /**
     * 标签个数 4
     * |Magin|View|Magin|Magin|View|Magin|Magin|View|Magin|Magin|View|Magin|
     * 总宽度:4*(标签宽度+2*margin)  按照比例 (总份数):4*(ITEM_WIDTH+2*MARGIN_WIDTH)
     * 则一个比例占的宽度为:组件总宽度/总份数
     * 一个标签的宽度为:组件宽度/总份数 * ITEM_WIDTH(宽度占的比例)
     * 一个标签的MARGIN为:组件宽度/总份数 * MARGIN_WIDTH(MARGIN占的比例)
     * 行高=(ITEN_HEIGHT+2*MARGIN_HEIGHT)*mItemScale
     * 一个组件占的宽度=(ITEM_WIDTH + 2*MARGIN_WIDTH)*mItemScale
     */

可能看起来比较复杂,其实理解起来就是:
一个标签所占的宽度=标签的宽度+2*marginwidth
一个标签所占的高度=标签的高度+2*marginheight
这里都是用的权值计算的
一个比例占的长度为=总宽度/总份数
假如屏幕宽度为1000px,标签的宽度占10份,marginwidth占2份,标签的高度占5份,marginheight占1份
一个比例所占的长度(以一行4个标签为例) = 1000/((10+2*2)*4)
一个标签所占的宽度 = (10+2*2)*一个比例所占的长度
一个标签所占的高度 = (5+2*1)*一个比例所占的长度

onMeasure方法

protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        int sizeWidth = MeasureSpec.getSize(widthMeasureSpec);
        int sizeHeight = MeasureSpec.getSize(heightMeasureSpec);
        int modeWidth = MeasureSpec.getMode(widthMeasureSpec);
        int modeHeight = MeasureSpec.getMode(heightMeasureSpec);
        int width;
        int height;
        int childCount = getChildCount();
        if (modeWidth == MeasureSpec.EXACTLY) {
            width = sizeWidth;
        } else {
            width = Math.min(sizeWidth, getScreenWidth(mContext));
        }

        if (modeHeight == MeasureSpec.EXACTLY) {
            height = sizeHeight;
        } else {
            int rowNum = childCount / ITEM_NUM;
            if (childCount % ITEM_NUM != 0) {
                height = (int) Math.min(sizeHeight, (rowNum + 1) * (ITEM_HEIGHT + 2 * MARGIN_HEIGHT) * mItemScale);
            } else {
                height = (int) Math.min(sizeHeight, rowNum * (ITEM_HEIGHT + 2 * MARGIN_HEIGHT) * mItemScale);
            }
        }

        measureChildren(
                MeasureSpec.makeMeasureSpec((int) (mItemScale * ITEM_WIDTH), MeasureSpec.EXACTLY),
                MeasureSpec.makeMeasureSpec((int) (mItemScale * ITEM_HEIGHT), MeasureSpec.EXACTLY));
        setMeasuredDimension(width, height);
    }

这里也是自定义View常见的一个点,注意MeasureSpace的三种模式EXACITY,AT_MOST,UNSPECIFIED,三种模式的对应关系可以简单理解为:

EXACITY -> MATCH_PARENT或者具体值
AT_MOST -> WARP_CONTENT
UNSPECIFIED是未指定尺寸,这种情况不多,一般都是父控件是AdapterView,通过measure方法传入的模式。

所以这里我处理方式为
宽度:当EXACITY时:width = widthsize,当其他模式时,width=sizewidth和屏幕宽度的较小值(这里注意sizeWidth的值为父组件传给自己的宽度值,所以如果当前组件处于第一层级,sizeWidth=屏幕宽度)
高度:当EXACITY时:height = heightsize,当其他模式时,计算行数,height=行数*一行的高度(height+2*marginheight)
再执行measureChildren

onLayout方法

protected void onLayout(boolean changed, int l, int t, int r, int b) {
        int childCount = getChildCount();
        int left;
        int top;
        int right;
        int bottom;
        for (int i = 0; i < childCount; i++) {
            int row = i / ITEM_NUM;
            int column = i % ITEM_NUM;
            View child = getChildAt(i);
            left = (int) ((MARGIN_WIDTH + column * (ITEM_WIDTH + 2 * MARGIN_WIDTH)) * mItemScale);
            top = (int) ((MARGIN_HEIGHT + row * (ITEM_HEIGHT + 2 * MARGIN_HEIGHT)) * mItemScale);
            right = (int) (left + ITEM_WIDTH * mItemScale);
            bottom = (int) (top + ITEM_HEIGHT * mItemScale);
            child.layout(left, top, right, bottom);
        }

    }

所以onlayout也就比较好理解了,利用for循环遍历child,计算每个child所在的行和列,再通过child.layout()布局。

2.onTouch事件

public boolean onTouchEvent(MotionEvent event) {
        float x = event.getX();
        float y = event.getY();
        if(isMove){
            switch (event.getAction()) {
                case MotionEvent.ACTION_DOWN:
                    mBeginX = x;
                    mBeginY = y;
                    mTouchIndex = findChildIndex(x, y);
                    mOldIndex = mTouchIndex;
                    if (mTouchIndex != -1) {
                        mTouchChildView = getChildAt(mTouchIndex);
                        mTouchChildView.clearAnimation();
                        //mTouchChildView.bringToFront();
                    }

                    break;
                case MotionEvent.ACTION_MOVE:
                    if (mTouchIndex != -1 && mTouchChildView != null) {
                        moveTouchView(x, y);
                        //拖动过程中的View的index
                        int resultIndex = findChildIndex(x, y);
                        if (resultIndex != -1 && (resultIndex != mOldIndex)
                                && ((Math.abs(x - mBeginX) > mItemScale * 2 * MARGIN_WIDTH)
                                || (Math.abs(y - mBeginY) > mItemScale * 2 * MARGIN_HEIGHT))
                                ) {
                            beginAnimation(Math.min(mOldIndex, resultIndex)
                                    , Math.max(mOldIndex, resultIndex)
                                    , mOldIndex < resultIndex);
                            mOldIndex = resultIndex;
                            mOnHover = true;
                        }
                    }

                    break;
                case MotionEvent.ACTION_UP:
                    setTouchIndex(x, y);
                    mOnHover = false;
                    mTouchIndex = -1;
                    mTouchChildView = null;
                    return  true;
            }
        }
        return super.onTouchEvent(event);
    }

这个方法算是这个效果的主要方法了,详细分析一下吧。首先看DOWN事件

case MotionEvent.ACTION_DOWN:
                    mBeginX = x;
                    mBeginY = y;
                    mTouchIndex = findChildIndex(x, y);
                    mOldIndex = mTouchIndex;
                    if (mTouchIndex != -1) {
                        mTouchChildView = getChildAt(mTouchIndex);
                        mTouchChildView.clearAnimation();
                        //mTouchChildView.bringToFront();
                    }

                    break;

可以看到,首先我先记录了触摸位置的x,y坐标,通过findChildIndex方法确定触摸位置的child的index。

/**
     * 通过触摸位置确定触摸位置的View
     */
    private int findChildIndex(float x, float y) {
        int row = (int) (y / ((ITEM_HEIGHT + 2 * MARGIN_HEIGHT) * mItemScale));
        int column = (int) (x / ((ITEM_WIDTH + 2 * MARGIN_WIDTH) * mItemScale));
        int index = row * ITEM_NUM + column;
        if (index > getChildCount() - 1) {
            return -1;
        }
        return index;
    }

因为最初分析的时候已经说到了
一行的高度 = 组件的高度+2*marginheight
一列的宽度 = 组件的宽度+2*marginwidth
所以当我们得到触摸位置的x,y,就可以通过y/行高得到行数,x/列宽
当触摸位置没有child时返回-1。

得到触摸坐标后,获得通过getChildAt()获得触摸坐标的child,通过clearAnimation停止抖动。

MOVE事件:

case MotionEvent.ACTION_MOVE:
                    if (mTouchIndex != -1 && mTouchChildView != null) {
                        moveTouchView(x, y);
                        //拖动过程中的View的index
                        int resultIndex = findChildIndex(x, y);
                        if (resultIndex != -1 && (resultIndex != mOldIndex)
                                && ((Math.abs(x - mBeginX) > mItemScale * 2 * MARGIN_WIDTH)
                                || (Math.abs(y - mBeginY) > mItemScale * 2 * MARGIN_HEIGHT))
                                ) {
                            beginAnimation(Math.min(mOldIndex, resultIndex)
                                    , Math.max(mOldIndex, resultIndex)
                                    , mOldIndex < resultIndex);
                            mOldIndex = resultIndex;
                            mOnHover = true;
                        }
                    }

                    break;

首先根据move过程中的x,y,通过moveTouchView移动拖动的view随手指移动。

    private void moveTouchView(float x, float y) {
        int left = (int) (x - mTouchChildView.getWidth() / 2);
        int top = (int) (y - mTouchChildView.getHeight() / 2);
        mTouchChildView.layout(left, top
                , (left + mTouchChildView.getWidth())
                , (top + mTouchChildView.getHeight()));
        mTouchChildView.invalidate();
    }

这里有个细节,在移动的时候,将触摸的位置移动到大概child的中心位置,这样看起来正常一下,也就是我对x和y分别减去了child宽高的一半,不然会使得手指触摸的位置一直在child的左上角(坐标原点),看起来很变扭。最后通过layout和invalidate方法重绘child。

移动其他view

这个应该算是这个组件最难实现的地方,我在这上面花了最长的时间。
1)首先什么时候执行位移动画,反过来想就是什么时候不执行位移动画
这里分了四种情况:
(1)拖动的位置没有标签,也就是图上的从标签9往右拖
(2)拖动的位置和上一次位置相同(也就是没动)
(3)移动的位置不到一行的高度(也就是没有脱离当前标签的区域)
(4)移动的位置不到一列的宽度(也就是没有脱离当前标签的区域)

2)执行位移动画,下面会分析

3)mOldIndex = resultIndex这里是为了保存上一次移动的坐标位置

4)mOnHover=true,记录拖动不放的情况(和拖动就释放的情况有区分)

/**
     * 移动动画
     *
     * @param forward 拖动组件与经过的index的前后顺序 touchindex < resultindex
     *                true-拖动的组件在经过的index前
     *                false-拖动的组件在经过的index后
     */
    private void beginAnimation(int startIndex, int endIndex, final boolean forward) {
        TranslateAnimation animation;
        ViewHolder holder;
        List animList = new ArrayList<>();
        int startI = forward ? startIndex + 1 : startIndex;
        int endI = forward ? endIndex + 1 : endIndex;//for循环用的是<,取不到最后一个
        if (mOnHover) {//拖动没有释放情况
            if (mTouchIndex > startIndex) {
                if (mTouchIndex < endIndex) {
                    startI = startIndex;
                    endI = endIndex + 1;
                } else {
                    startI = startIndex;
                    endI = endIndex;
                }
            } else {
                startI = startIndex + 1;
                endI = endIndex + 1;
            }
        }

        //X轴的单位移动距离
        final float moveX = (ITEM_WIDTH + 2 * MARGIN_WIDTH) * mItemScale;
        //y轴的单位移动距离
        final float moveY = (ITEM_HEIGHT + 2 * MARGIN_HEIGHT) * mItemScale;
        //x轴移动方向
        final int directX = forward ? -1 : 1;
        final int directY = forward ? 1 : -1;
        boolean isMoveY = false;
        for (int i = startI; i < endI; i++) {
            if (i == mTouchIndex) {
                continue;
            }
            final View child = getChildAt(i);
            holder = (ViewHolder) child.getTag();
            child.clearAnimation();
            if (i % ITEM_NUM == (ITEM_NUM - 1) && !forward
                    && holder.row == i / ITEM_NUM && holder.column == i % ITEM_NUM) {
                //下移
                holder.row++;
                isMoveY = true;
                animation = new TranslateAnimation(0, directY * (ITEM_NUM - 1) * moveX, 0, directX * moveY);
            } else if (i % ITEM_NUM == 0 && forward
                    && holder.row == i / ITEM_NUM && holder.column == i % ITEM_NUM) {
                //上移
                holder.row--;
                isMoveY = true;
                animation = new TranslateAnimation(0, directY * (ITEM_NUM - 1) * moveX, 0, directX * moveY);
            } else if (mOnHover && holder.row < i / ITEM_NUM) {
                //onHover 下移
                holder.row++;
                isMoveY = true;
                animation = new TranslateAnimation(0, -(ITEM_NUM - 1) * moveX, 0, moveY);
            } else if (mOnHover && holder.row > i / ITEM_NUM) {
                //onHover 上移
                holder.row--;
                isMoveY = true;
                animation = new TranslateAnimation(0, (ITEM_NUM - 1) * moveX, 0, -moveY);
            } else {//y轴不动,仅x轴移动
                holder.column += directX;
                isMoveY = false;
                animation = new TranslateAnimation(0, directX * moveX, 0, 0);
            }
            animation.setDuration(mDuration);
            animation.setFillAfter(true);
            final boolean finalIsMoveY = isMoveY;
            animation.setAnimationListener(new Animation.AnimationListener() {
                @Override
                public void onAnimationStart(Animation animation) {

                }

                @Override
                public void onAnimationEnd(Animation animation) {
                    child.clearAnimation();
                    if (finalIsMoveY) {
                        child.offsetLeftAndRight((int) (directY * (ITEM_NUM - 1) * moveX));
                        child.offsetTopAndBottom((int) (directX * moveY));
                    } else {
                        child.offsetLeftAndRight((int) (directX * moveX));
                    }
                }

                @Override
                public void onAnimationRepeat(Animation animation) {

                }
            });
            child.setAnimation(animation);
            animList.add(animation);
        }
        for (TranslateAnimation anim : animList) {
            anim.startNow();
        }


    }

位移动画,这段代码怎么解释哪…我写的时候是发现一个bug改一种情况,最后实现了这段代码。
仿网易新闻标签选择器(可拖动)-TabMoveLayout_第1张图片
1)这里首先确定开始位移的view的坐标和结束位移的坐标
这里分为两种情况:
case1:手指拖动后抬起(down->move->up);
case2:手指来回拖动不放(down->move->move)

case1:是常见情况,这里我们就可以按照forward再分为两种情况
case1.1:标签0->标签1(forward =true);
case1.2:标签5->标签1(forward=false)

case1.1:
标签0移动到标签1,标签0随手指移动,所以需要执行位移动画的只有标签1,所以startI = 1,endI = 2(for循环<,所以取不到最后一个),而startindex = 0,endindex = 1;
所以forward = true,startI = startIndex+1,endI=endIndex+1;
case1.2:
标签4移动到标签0,标签4随手指移动,所以需要执行位移动画的是标签0~标签3,所以startI=0,endI=4,所以而startindex=0,endindex=5;
所以forward = false,startI = startIndex,endI = endIndex

case2:是指手指拖动不放,来回拖动,所以通过mOnHover=true参数来确定是否是拖动没放情况,这里面又要细分为三种情况
case2.1:标签0->标签2->标签1,将标签0拖动到2,再回到0的位置,这是标签0一直随手指移动,
后面这段动画,startindex = 1,endindex = 2,touchindex = 0,只有标签2需要执行动画,标签1不动,所以startI = 2,endI = 3
所以mOnHover = true,touchindex

if (i % ITEM_NUM == (ITEM_NUM - 1) && !forward
                    && holder.row == i / ITEM_NUM && holder.column == i % ITEM_NUM) {
                //下移
                holder.row++;
                isMoveY = true;
                animation = new TranslateAnimation(0, directY * (ITEM_NUM - 1) * moveX, 0, directX * moveY);
            } else if (i % ITEM_NUM == 0 && forward
                    && holder.row == i / ITEM_NUM && holder.column == i % ITEM_NUM) {
                //上移
                holder.row--;
                isMoveY = true;
                animation = new TranslateAnimation(0, directY * (ITEM_NUM - 1) * moveX, 0, directX * moveY);
            } else if (mOnHover && holder.row < i / ITEM_NUM) {
                //onHover 下移
                holder.row++;
                isMoveY = true;
                animation = new TranslateAnimation(0, -(ITEM_NUM - 1) * moveX, 0, moveY);
            } else if (mOnHover && holder.row > i / ITEM_NUM) {
                //onHover 上移
                holder.row--;
                isMoveY = true;
                animation = new TranslateAnimation(0, (ITEM_NUM - 1) * moveX, 0, -moveY);
            } else {//y轴不动,仅x轴移动
                holder.column += directX;
                isMoveY = false;
                animation = new TranslateAnimation(0, directX * moveX, 0, 0);
            }

case1:当是一行的最后一个,forward=false(后面的标签往前挤),标签的Tag中的x,y没有变化(也就是第一次拖动和mOnHover=true区分),这时下移
case2:当是一行的第一个,forward=true(上面的标签往下挤),标签的Tag中的x,y没有变化(也就是第一次拖动和mOnHover=true区分),这时上移
case3:当mOnHover=true,标签当前所在行<标签初始所在行,这时下移
case4:当mOnHover=true,标签当前所在行>标签初始所在行,这时上移
case5:X轴的平移,y轴不动

后面设置了child的动画监听,当动画结束后,需要将child的实际位置设置为当前位置(因为这里用的不是属性动画,所以执行动画后child的实际位置并没有变化,还是原始位置)

UP事件:

case MotionEvent.ACTION_UP:
                    setTouchIndex(x, y);
                    mOnHover = false;
                    mTouchIndex = -1;
                    mTouchChildView = null;
                    return  true;

这里主要看setTouchIndex事件

/**
     * ---up事件触发
     * 设置拖动的View的位置
     * @param x
     * @param y
     */
    private void setTouchIndex(float x,float y){
        if(mTouchChildView!= null){
            int resultIndex = findChildIndex(x, y);
            Log.e("resultindex", "" + resultIndex);
            if(resultIndex == mTouchIndex||resultIndex == -1){
                refreshView(mTouchIndex);
            }else{
                swapView(mTouchIndex, resultIndex);
            }
        }
    }

可以看到,这里拖动结束后就需要将拖动位置变化的child实际改变它在ViewGroup中的位置
这里有两种情况
case1:拖动到最后,child的顺序没有改变,只有touchview小浮动的位置变化,这时只需要刷新touchview即可
case2:将位置变换的child刷新其在viewgroup中的顺序。

/**
     *刷新View
     * ------------------------------重要------------------------------
     * 移除前需要先移除View的动画效果,不然无法移除,可看源码
     */
    private void refreshView(int index) {
        //移除原来的View
        getChildAt(index).clearAnimation();
        removeViewAt(index);
        //添加一个View
        TextView tv = new TextView(mContext);
        LayoutParams params = new ViewGroup.LayoutParams((int) (mItemScale * ITEM_WIDTH),
                (int) (mItemScale * ITEM_HEIGHT));
        tv.setText(mData.get(index));
        tv.setTextColor(TEXT_COLOR);
        tv.setBackgroundResource(ITEM_BACKGROUND);
        tv.setGravity(Gravity.CENTER);
        tv.setTextSize(TypedValue.COMPLEX_UNIT_PX,TEXT_SIZE);
        tv.setTag(new ViewHolder(index / ITEM_NUM, index % ITEM_NUM));
        this.addView(tv,index ,params);
        tv.startAnimation(mSnake);
    }

刷新index的View,这里有个需要注意的点,因为每个child都在执行抖动动画,这时候直接removeViewAt是没有办法起效果的,需要先clearAnimation再执行,具体我已经写了一篇博客从源码分析了
Animation导致removeView无效(源码分析)

 private void swapView(int fromIndex, int toIndex) {
        if(fromIndex < toIndex){
            mData.add(toIndex+1,mData.get(fromIndex));
            mData.remove(fromIndex);
        }else{
            mData.add(toIndex,mData.get(fromIndex));
            mData.remove(fromIndex+1);
        }

        for (int i = Math.min(fromIndex, toIndex); i <= Math.max(fromIndex, toIndex); i++) {
            refreshView(i);
        }
    }

这里交换touch和最终位置的child,所以首先实际改变Data数据集,再利用for循环,通过refreshView函数,刷新位置变化的child。

主要代码已经分析完了,详细Demo和源码这里给出GitHub地址。
TabMoveLayout

你可能感兴趣的:(自定义View)