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

前言

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

先來看看兩張效果圖: java

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

初步分析

先看看小紅書的效果: android

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

仔細觀察,你會發現:git

  • 標籤跟隨手指移動而且當前所觸摸的標籤位於其餘標籤之上;github

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

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

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

  • 當前圖片添加標籤後,再次切回當前圖片,標籤數據依舊存在(保存與恢復);佈局

好,如今咱們基本分析的差很少了,下面開始構思代碼。post

構思代碼

標籤有添加與移除,天然會想到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,用於後面的越界處理。

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

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

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

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

擠壓拉伸的方法以下:

/** * 擠壓拉伸文本區域 * * @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

掃一掃 關注個人公衆號
新號但願你們可以多多支持我~
相關文章
相關標籤/搜索