1. 程式人生 > >仿網易新聞標籤選擇器(可拖動)-TabMoveLayout

仿網易新聞標籤選擇器(可拖動)-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<TranslateAnimation> 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改一種情況,最後實現了這段程式碼。
這裡寫圖片描述
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