Android控制元件人生第一站,小紅書任意拖拽標籤控制元件

文淑發表於2019-03-14

前言

工作三年有餘,年紀大了專業技能到沒長進,有時候閒的時候總想寫點東西出來,由於自己的懶惰一直拖拖拉拉,好幾次還沒開始就放棄了,大家也都知道,學程式設計的大多數不善於表達,加上自己的專業技能確實不怎麼樣。這次因緣巧合之下正好負責迭代版本中的控制元件部分,於是就有了控制元件人生系列文章。

先來看看兩張效果圖:

在這裡插入圖片描述
在這裡插入圖片描述
emmm,參考的是小紅書編輯頁的標籤效果, 拿在手裡玩了一會,標籤可以跟隨手指移動,當前拖動的標籤覆蓋在其他標籤之上,還可以擠壓,切換標籤方向,拖到刪除區域手指放開標籤被移除。。。玩著,玩著卻讓我玩出了一個bug,捂臉:當有7,8張圖片時(圖片切換是以viewpager實現),在第一張圖片新增標籤,然後來回切換viewpager,標籤的位置會錯亂。。。

初步分析

先看看小紅書的效果:

在這裡插入圖片描述
在這裡插入圖片描述
emmm,從效果上看呢,並不複雜,主要是細節的處理。接下來我們具體一步一步分析,從而打造屬於我們自己的效果。

仔細觀察,你會發現:

  • 標籤跟隨手指移動並且當前所觸控的標籤位於其他標籤之上;

  • 標籤不能移出圖片區域(除下方向外),同時手指按下與抬起,刪除區域顯示與隱藏(暴露介面);

  • 當標籤超過一定的長度,移動到圖片邊緣,標籤出現擠壓效果;

  • 點選呼吸燈區域(橫躺的棒棒糖),切換標籤方向;

  • 當前圖片新增標籤後,再次切回當前圖片,標籤資料依舊存在(儲存與恢復);

好,現在我們基本分析的差不多了,下面開始構思程式碼。

構思程式碼

標籤有新增與移除,自然會想到ViewGroup,同時ViewGroup的寬高需與圖片保持一致,標籤可能在ViewGroup的任意位置,那麼就需要標籤動態改變Translation值,怎麼樣才能讓當前觸控的標籤位於其他標籤之上?大家都知道ViewGroup的子view索引值越大越能顯示在螢幕的前面。那麼當手指觸控到標籤時,就需要改變子View的索引值,可ViewGroup並沒有提供直接改變子View索引值的方法。父類直接新增會報父類已存在的異常,那麼我可不可以先移除,再新增到ViewGroup的最後面,這方案不錯,最終也是按著這個方案來實現的。

在最開始的兩張效果圖中,產品還有這樣一個需求:需要拖動標籤到螢幕底部【移動到此處】進行刪除。剛剛已經分析了標籤的父控制元件大小與圖片一致,考慮到檢視層級的關係,標籤移出父控制元件,可能會出現被其他View遮擋的現象,那又怎麼樣才能不讓遮擋呢?

還記不記得很早以前的自定義View之案列篇(三):仿QQ小紅點呢?父控制元件預設裁剪子view,那麼可以通過:

	android:clipChildren="false"
複製程式碼

設定父控制元件不裁剪。

在這裡插入圖片描述
在上文中提到,當標籤超過一定的長度,移動到圖片邊緣,標籤出現擠壓效果。記得在漫畫播放器一吐槽功能中已經實現了類似的功能。

那個思路也能用到這裡來:動態改變控制元件的寬度,就能實現文字的擠壓效果。

還有一個效果:點選呼吸燈區域,切換標籤方向。說說最開始的實現思路:左右標籤分別是兩個xml佈局檔案,切換方向的時候,通過inflate來載入對應的xml檔案實現方向的切換。每次切換方向都會重新載入xml檔案,這樣效率並不高。沒想到我這樣的年輕司機也有翻車的時候啊,哈哈。後來,細細一折磨,為何不把左右標籤放在一個xml檔案,通過隱藏顯示控制標籤方向,哈哈,好傢伙,效率比兩個xml檔案好很多。

接下來,開工寫程式碼洛~~

起名字

起名字一直是一門藝術,一個好的控制元件必須有一個好的名字,我看就叫:RandomDragTagLayout(標籤父控制元件)RandomDragTagView(標籤控制元件)

編寫程式碼

RandomDragTagView

先來看看標籤的xml佈局檔案(R.layout.random_tag_layout):

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:orientation="horizontal">

    <!-- 左側標籤 -->
    <LinearLayout...>

    <View
        android:id="@+id/left_line_view"
        android:layout_width="13.5dp"
        android:layout_height="1dp"
        android:layout_gravity="center_vertical"
        android:layout_marginRight="-3.5dp"
        android:background="#FFFFFF"></View>

    <!-- 中點呼吸燈 -->
    <FrameLayout...>

    <View
        android:id="@+id/right_line_view"
        android:layout_width="13.5dp"
        android:layout_height="1dp"
        android:layout_gravity="center_vertical"
        android:layout_marginLeft="-3.5dp"
        android:background="#FFFFFF"></View>

    <!-- 右側標籤 -->
    <LinearLayout...>

</LinearLayout>
複製程式碼

xml的預覽效果圖:

在這裡插入圖片描述
好,xml佈局檔案比較簡單,接著我們來看看RandomDragTagView應該怎麼寫: RandomDragTagView類繼承LinearLayout,先是成員變數:


    // 左側檢視
    private LinearLayout mLeftLayout;
    private TextView mLeftText;
    private View mLeftLine;
    // 右側檢視
    private LinearLayout mRightLayout;
    private TextView mRightText;
    private View mRightLine;
    // 中間檢視
    private View mBreathingView;
    private FrameLayout mBreathingLayout;

    // 是否顯示左側檢視  預設顯示左側檢視
    private boolean mIsShowLeftView = true;

    // 呼吸燈動畫
    private ValueAnimator mBreathingAnimator;
    // 回彈動畫
    private ValueAnimator mReboundAnimator;
    private float mStartReboundX;
    private float mStartReboundY;
    private float mLastMotionRawY;
    private float mLastMotionRawX;

    // 是否多跟手指按下
    private boolean mPointerDown = false;
    private int mTouchSlop = -1;

    // 是否可以拖拽
    private boolean mCanDrag = true;

    // 是否可以拖拽出父控制元件區域
    private boolean mDragOutParent = true;

    // 父控制元件最大的高度
    private int mMaxParentHeight = 0;

    // 最大擠壓寬度 預設400
    private int mMaxExtrusionWidth = 400;
    // 文字圓角矩形的最大寬度
    private int mMaxTextLayoutWidth = 0;

    // 刪除標籤區域的高度
    private int mDeleteRegionHeight;

    // 暴露介面
    private boolean mStartDrag = false;
    private OnRandomDragListener mDragListener;
複製程式碼

再到一參,二參,三參的構造方法,引數的話,Context,attrs,defStyleAttr是不用說了,一參,二參指向三參構造:

    public RandomDragTagView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        setOrientation(HORIZONTAL);
        inflate(context, R.layout.random_tag_layout, this);
        initView();
        initListener();
        initData();
        startBreathingAnimator();
    }
複製程式碼

initView,initListener方法也不用說了,用於初始化控制元件與事件監聽的方法。initData方法隱藏右側標籤部分,而startBreathingAnimator方法用於開啟呼吸燈動畫,在效果中,呼吸燈有來回縮放的效果,就好似一呼一吸。

    // 開啟呼吸燈動畫 注動畫無線迴圈注意回收防止記憶體洩露
    private void startBreathingAnimator() {
        if (mBreathingAnimator != null && mBreathingAnimator.isRunning()) {
            mBreathingAnimator.cancel();
            mBreathingAnimator = null;
        }
        mBreathingAnimator = ValueAnimator.ofFloat(0.8F, 1.0F);
        mBreathingAnimator.setRepeatMode(ValueAnimator.REVERSE);
        mBreathingAnimator.setDuration(800);
        mBreathingAnimator.setStartDelay(200);
        mBreathingAnimator.setRepeatCount(-1);
        mBreathingAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                float value = (float) animation.getAnimatedValue();
                mBreathingView.setScaleX(value);
                mBreathingView.setScaleY(value);
            }
        });
        mBreathingAnimator.start();
    }
複製程式碼

注意呼吸燈動畫設定了setRepeatCount重複次數為-1,表示無限迴圈。onAnimationUpdate方法會被一直呼叫,同時方法內部持有mBreathingView的引用,最終會導致mBreathingView所屬的activity被持有無法回收,從而引起記憶體洩露。

那麼我們需要在合適的時機呼叫動畫cancel並置為null,就像這樣:

    @Override
    protected void onDetachedFromWindow() {
        if (mBreathingAnimator != null && mBreathingAnimator.isRunning()) {
            mBreathingAnimator.cancel();
            mBreathingAnimator = null;
        }
        super.onDetachedFromWindow();
    }
複製程式碼

標籤的預設效果,就像這樣:

在這裡插入圖片描述
好了,在效果中標籤跟隨手指移動,重寫onTouchEvent方法,在觸發拖動事件時,我們需要對一些數值進行初始化並改變標籤在父控制元件中的索引值,讓當前所觸控的標籤顯示在其他標籤之上:

   switch (event.getActionMasked()) {
       case MotionEvent.ACTION_DOWN:
           final float x = event.getRawX();
           final float y = event.getRawY();
           // 允許父控制元件不攔截事件
           getParent().requestDisallowInterceptTouchEvent(true);
           mStartDrag = false;
           mPointerDown = false;
           mLastMotionRawX = x;
           mLastMotionRawY = y;
           mStartReboundX = getTranslationX();
           mStartReboundY = getTranslationY();
           // 調整索引 位於其他標籤之上
           adjustIndex();
           break;
複製程式碼

adjustIndex方法用於調整索引:

    /**
     * 調整索引 位於其他標籤之上
     */
    private void adjustIndex() {
        ViewParent parent = getParent();
        if (parent != null) {
            if (parent instanceof ViewGroup) {
                ViewGroup parentView = (ViewGroup) parent;
                int childCount = parentView.getChildCount();
                if (childCount > 1 && indexOfChild(this) != (childCount - 1)) {
                    parentView.removeView(this);
                    parentView.addView(this);
                    // 重新開啟呼吸燈動畫
                    startBreathingAnimator();
                }
            }
        }
    }
複製程式碼

emmmm,接下來到移動了,更新當前觸控座標值,根據座標值偏移量來動態設定setTranslation,同時對越界,擠壓處理:

    case MotionEvent.ACTION_MOVE:
        final float rawY = event.getRawY();
        final float rawX = event.getRawX();
        if (!mStartDrag) {
            mStartDrag = true;
            if (mDragListener != null) {
                mDragListener.onStartDrag();
            }
        }
        if (!mPointerDown) {
            final float yDiff = rawY - mLastMotionRawY;
            final float xDiff = rawX - mLastMotionRawX;
            // 處理move事件
            handlerMoveEvent(yDiff, xDiff);
            mLastMotionRawY = rawY;
            mLastMotionRawX = rawX;
        }
        break;
複製程式碼

首先暴露開始拖動的介面回撥,有同學就會有疑問為啥不在事件ACTION_DOWN中回撥呢?主要是因為,觀察小紅書快速點選也沒有執行開始拖動的回撥。還有這裡的回撥判定並不是很合理,如果能夠加上mTouchSlop,那就再好不過呢。不要問我為什麼不加,懶唄

mPointerDown引數主要用來控制是否有多根手指按下,同樣也是觀察小紅書,在多根手指按下的情況下,標籤並沒有跟隨手指移動,只有在單根手指的情況才會移動。

那麼mPointerDown在多根手指按下與抬起的事件中更新狀態:

   // 多根手指按下
   case MotionEvent.ACTION_POINTER_DOWN:
       mPointerDown = true;
       break;
  // 多根手指抬起     
  case MotionEvent.ACTION_POINTER_UP:
       mPointerDown = false;
       break;
複製程式碼

接下來對越界與擠壓的處理:

    /**
     * 處理手勢的move事件
     *
     * @param yDiff y軸方向的偏移量
     * @param xDiff x軸方向的偏移量
     */
    private void handlerMoveEvent(float yDiff, float xDiff) {
        float translationX = getTranslationX() + xDiff;
        float translationY = getTranslationY() + yDiff;

        // 越界處理 最大最小原則
        int parentWidth = ((View) getParent()).getWidth();
        int parentHeight = ((View) getParent()).getHeight();
        if (mMaxParentHeight == 0) {
            int parentParentHeight = ((View) getParent().getParent()).getHeight();
            mMaxParentHeight = (mDragOutParent ? parentParentHeight : parentHeight) - getHeight();
        }
        int maxWidth = parentWidth - getWidth();

        // 分情況處理越界 寬度
        if (translationX <= 0) {
            translationX = 0;
            // 標籤文字出現擠壓效果
            if (isShowLeftView()) {
                extrusionTextRegion(xDiff);
            }
        } else if (translationX >= maxWidth) {
            translationX = maxWidth;
            // 右側擠壓
            if (!isShowLeftView()) {
                extrusionTextRegion(-xDiff);

                handleWidthError();
            }
        } else {
            int textWidth = isShowLeftView() ? mLeftLayout.getWidth() : mRightLayout.getWidth();
            // 左側檢視
            if (isShowLeftView()) {
                if (getTranslationX() == 0 && textWidth < mMaxTextLayoutWidth) {
                    translationX = 0;
                    extrusionTextRegion(xDiff);
                }
            } else {
                if (textWidth < mMaxTextLayoutWidth) {
                    extrusionTextRegion(-xDiff);
                    handleWidthError();
                }
            }
        }

        // 高度越界處理
        if (translationY <= 0) {
            translationY = 0;
        } else if (translationY >= mMaxParentHeight) {
            translationY = mMaxParentHeight;
        }

        setTranslationX(translationX);
        setTranslationY(translationY);
    }
複製程式碼

在上文中已經提到過,產品新增標籤可以拖出父控制元件底部區域(小紅書不允許),不要問我為什麼,三個字:產品最大。

作為一名程式猿,必須保證程式碼的健壯性,同時也為了防止產品哪天提出:不允許拖出父控制元件的底部區域的需求?

那就需要一個標識來標識是否拖出父控制元件底部區域,這就是mDragOutParent引數的由來。根據標識獲取到父控制元件的最大高度mMaxParentHeight,用於後面的越界處理。

觀察小紅書的擠壓是分情況來處理的:

  • 標籤在呼吸燈的左側,只能向左擠壓。擠壓的條件,1、標籤長度大於一定值;2、標籤靠在父控制元件左側邊緣,手指並向左側拖動。

  • 標籤在呼吸燈的右側,只能向右擠壓。擠壓條件同上。

  • 有擠壓就有拉伸,與上面兩種情況正好相反,標籤在呼吸燈左側只能向右拉伸;右側只能向左拉伸。拉伸的條件,1、標籤長度小於最大值;2、標籤靠在父控制元件的左、右邊緣同時向相反的方向拖動。

擠壓拉伸的方法如下:

    /**
     * 擠壓拉伸文字區域
     *
     * @param deltaX 偏移量
     */
    private void extrusionTextRegion(float deltaX) {
        int textWidth = isShowLeftView() ? mLeftLayout.getWidth() : mRightLayout.getWidth();
        LinearLayout.LayoutParams lp = (LayoutParams) (isShowLeftView() ?
                mLeftLayout.getLayoutParams() : mRightLayout.getLayoutParams());
        if (textWidth >= mMaxExtrusionWidth) {
            lp.width = (int) (textWidth + deltaX);

            // 越界判定
            if (lp.width <= mMaxExtrusionWidth) {
                lp.width = mMaxExtrusionWidth;
            } else if (lp.width >= mMaxTextLayoutWidth) {
                lp.width = mMaxTextLayoutWidth;
            }

            if (isShowLeftView()) {
                mLeftLayout.setLayoutParams(lp);
            } else {
                mRightLayout.setLayoutParams(lp);
            }
        }
    }
複製程式碼

注意:由於文字控制元件寬度改變,文字顯示的字元數會發生變化,字元數的增減會導致文字寬度與deltaX不一致,導致標籤在呼吸燈右側擠壓拉伸有機率並沒有靠在右側邊緣。 所以有了以下的相容誤差處理:

    // 處理寬度誤差
    private void handleWidthError() {
        post(new Runnable() {
            @Override
            public void run() {
                int parentWidth = ((View) getParent()).getWidth();
                int maxWidth = parentWidth - getWidth();
                setTranslationX(maxWidth);
            }
        });
    }
複製程式碼

處理完了擠壓與拉伸,就剩下高度的越界處理與改變setTranslation值:

    // 高度越界處理
    if (translationY <= 0) {
        translationY = 0;
    } else if (translationY >= mMaxParentHeight) {
        translationY = mMaxParentHeight;
    }
    setTranslationX(translationX);
    setTranslationY(translationY);
複製程式碼

來,看看效果:

在這裡插入圖片描述
好,ACTION_MOVE處理完,到ACTION_UP了。根據getTranslationY值來判定標籤是否滑出父控制元件區域,如果滑動到刪除區域,則移除標籤控制元件;如果滑出圖片區域並沒有滑到刪除區域(上圖的黑色區域),則開始回彈動畫。最後暴露結束拖動的回撥。

case MotionEvent.ACTION_UP:
    mPointerDown = false;
    mStartDrag = false;
    getParent().requestDisallowInterceptTouchEvent(false);
    
    final float translationY = getTranslationY();
    final int parentHeight = ((View) getParent()).getHeight();
    
    if (mMaxParentHeight - mDeleteRegionHeight < translationY) {
        removeTagView();
    } else if (parentHeight - getHeight() < translationY) {
        startReBoundAnimator();
    }
    
    if (mDragListener != null) {
        mDragListener.onStopDrag();
    }
    break;
複製程式碼

回彈動畫以手指按下與抬起為開始與結束點進行平移,程式碼非常簡單:

    // 開始回彈動畫
    private void startReBoundAnimator() {
        if (mReboundAnimator != null && mReboundAnimator.isRunning()) {
            mReboundAnimator.cancel();
        }
        mReboundAnimator = ValueAnimator.ofFloat(1F, 0F);
        mReboundAnimator.setDuration(400);
        final float startTransX = getTranslationX();
        final float startTransY = getTranslationY();
        mReboundAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                float value = (float) animation.getAnimatedValue();
                setTranslationX(mStartReboundX + (startTransX - mStartReboundX) * value);
                setTranslationY(mStartReboundY + (startTransY - mStartReboundY) * value);
            }
        });
        mReboundAnimator.start();
    }
複製程式碼

對了,還有一功能,點選呼吸燈切換標籤方向:

    // 切換方向
    public void switchDirection() {
        mIsShowLeftView = !mIsShowLeftView;
        visibilityLeftLayout();
        visibilityRightLayout();

        // 第一步更改 重置 textLayout 的高度
        final int preSwitchWidth = getWidth();
        LinearLayout.LayoutParams lp = (LayoutParams) (isShowLeftView() ?
                mLeftLayout.getLayoutParams() : mRightLayout.getLayoutParams());
        lp.width = LayoutParams.WRAP_CONTENT;
        if (mIsShowLeftView) {
            mLeftText.setText(mRightText.getText());
            mLeftLayout.setLayoutParams(lp);
        } else {
            mRightText.setText(mLeftText.getText());
            mRightLayout.setLayoutParams(lp);
        }

        post(new Runnable() {
            @Override
            public void run() {
                // 第二步 重新設定setTranslationX的值
                float newTranslationX = 0;
                if (!isShowLeftView()) {
                    newTranslationX = getTranslationX() + preSwitchWidth - mBreathingView.getWidth();
                } else {
                    newTranslationX = getTranslationX() - getWidth() + mBreathingView.getWidth();
                }

                // 邊界檢測
                checkBound(newTranslationX, getTranslationY());

            }
        });
    }
複製程式碼

首先根據標籤方向,顯示與隱藏左右標籤檢視;然後給標籤設定文字,同時重置標籤的寬度屬性;接著重新設定標籤的setTranslationX值,最後邊界檢測。

邊界檢測方法程式碼如下:

    /**
     * @param newTranslationX  
     * @param newTranslationY
     */
    private void checkBound(float newTranslationX, float newTranslationY) {
        setTranslationX(newTranslationX);

        // 越界的情況下 改變textLayout 的高度
        final int parentWidth = ((View) getParent()).getWidth();
        final int parentHeight = ((View) getParent()).getHeight();
        float translationX = getTranslationX();
        if (translationX <= 0) {
            extrusionTextRegion(translationX);
        } else if (getTranslationX() >= (parentWidth - getWidth())) {
            final float offsetX = getWidth() - (parentWidth - getTranslationX());
            extrusionTextRegion(-offsetX);

            // 越界檢測
            post(new Runnable() {
                @Override
                public void run() {
                    if (getTranslationX() >= (parentWidth - getWidth())) {
                        setTranslationX(parentWidth - getWidth());
                    }
                }
            });
        }

        // 越界檢測
        if (getTranslationX() <= 0) {
            setTranslationX(0);
        }

        if (newTranslationY <= 0) {
            newTranslationY = 0;
        } else if (newTranslationY >= parentHeight - getHeight()) {
            newTranslationY = parentHeight - getHeight();
        }

        setTranslationY(newTranslationY);
    }
複製程式碼

針對方法流程,並沒有細講,如果有疑問,請給我留言。讓我們一起看看標籤切換的效果圖:

在這裡插入圖片描述
RandomDragTagView還有一些暴露資料的方法,這裡就不一一列出了。

RandomDragTagLayout

RandomDragTagLayout類繼承FrameLayout,只有一個方法:

    /**
     * 新增標籤
     *
     * @param text           標籤文字
     * @param x              相對於父控制元件的x座標百分比
     * @param y              相對於父控制元件的y座標百分比
     * @param isShowLeftView 是否顯示左側標籤
     */
    public boolean addTagView(String text, final float x, final float y, boolean isShowLeftView) {
        if (text == null || text.equals("")) return false;
        RandomDragTagView tagView = new RandomDragTagView(getContext());
        addView(tagView, new LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT));
        tagView.initTagView(text, x * getWidth(), y * getHeight(), isShowLeftView);
        return true;
    }
複製程式碼

儲存、恢復

儲存,新建TagModel 類用於儲存標籤屬性:

    private void saveTag() {
        mTagList.clear();
        for (int i = 0; i < mRandomDragTagLayout.getChildCount(); i++) {
            View childView = mRandomDragTagLayout.getChildAt(i);
            if (childView instanceof RandomDragTagView) {
                RandomDragTagView tagView = (RandomDragTagView) childView;
                TagModel tagModel = new TagModel();
                tagModel.direction = tagView.isShowLeftView();
                tagModel.text = tagView.getTagText();
                tagModel.x = tagView.getPercentTransX();
                tagModel.y = tagView.getPercentTransY();
                mTagList.add(tagModel);
            }
        }
    }
複製程式碼

恢復:

    private void restoreTag() {
        if (!mTagList.isEmpty()) {
            mRandomDragTagLayout.removeAllViews();
            for (TagModel tagModel : mTagList) {
                mRandomDragTagLayout.addTagView(tagModel.text, tagModel.x, tagModel.y, tagModel.direction);
            }
        }
    }
複製程式碼

最後讓我們用一張動圖,來感受標籤控制元件的強大:

在這裡插入圖片描述

好了,本篇文章到此結束,有錯誤的地方請指出,多謝~

Github地址:https://github.com/HpWens/MeiWidgetView 歡迎Star

qrcode_for_gh_232b5a56667d_258.jpg

掃一掃 關注我的公眾號
新號希望大家能夠多多支援我~

相關文章