Android仿QQ側滑菜單

先上效果圖:
GIF.gif

GIF圖有點模糊,源碼已上傳Github:Android仿QQ側滑菜單

整體思路:

自定義ItemView的根佈局(SwipeMenuLayout extends LinearLayout),複寫onTouchEvent來處理滑動事件,注意這裏的滑動是View裏面內容的滑動而不是View的滑動,View裏內容的滑動主要是通過scrollTo、scrollBy來實現,然後自定義SwipeRecycleView,複寫其中的onInterceptTouchEvent和onTouchEvent來處理滑動衝突。

實現過程:

先來看每個ItemView的佈局文件:

<?xml version="1.0" encoding="utf-8"?>
<org.ninetripods.mq.study.recycle.swipe_menu.SwipeMenuLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/swipe_menu"
    android:layout_width="match_parent"
    android:layout_height="70dp"
    android:layout_centerInParent="true"
    android:background="@color/white"
    android:orientation="horizontal"
    app:content_id="@+id/ll_layout"
    app:right_id="@+id/ll_right_menu">

    <LinearLayout
        android:id="@+id/ll_layout"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="horizontal">

        <TextView
            android:id="@+id/tv_content"
            android:layout_width="wrap_content"
            android:layout_height="match_parent"
            android:layout_marginLeft="20dp"
            android:gravity="center_vertical"
            android:text="HelloWorld"
            android:textSize="16sp" />

        <TextView
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:layout_gravity="right"
            android:layout_marginLeft="20dp"
            android:layout_marginRight="20dp"
            android:gravity="center_vertical|end"
            android:text="左滑←←←"
            android:textSize="16sp" />
    </LinearLayout>

    <LinearLayout
        android:id="@+id/ll_right_menu"
        android:layout_width="wrap_content"
        android:layout_height="match_parent"
        android:orientation="horizontal">

        <TextView
            android:id="@+id/tv_to_top"
            android:layout_width="90dp"
            android:layout_height="match_parent"
            android:background="@color/gray_holo_light"
            android:gravity="center"
            android:text="置頂"
            android:textColor="@color/white"
            android:textSize="16sp" />

        <TextView
            android:id="@+id/tv_to_unread"
            android:layout_width="90dp"
            android:layout_height="match_parent"
            android:background="@color/yellow"
            android:gravity="center"
            android:text="標爲未讀"
            android:textColor="@color/white"
            android:textSize="16sp" />

        <TextView
            android:id="@+id/tv_to_delete"
            android:layout_width="90dp"
            android:layout_height="match_parent"
            android:background="@color/red_f"
            android:gravity="center"
            android:text="刪除"
            android:textColor="@color/white"
            android:textSize="16sp" />
    </LinearLayout>
</org.ninetripods.mq.study.recycle.swipe_menu.SwipeMenuLayout>

android:id=”@+id/ll_layout” 的LinearLayout寬度設置的match_parent,所以右邊的三個菜單按鈕默認我們是看不到的,根佈局是SwipeMenuLayout,是個自定義ViewGroup,主要的滑動事件也是在這裏面完成的。

RecycleView的佈局文件:

<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <include
        android:id="@+id/toolbar"
        layout="@layout/m_toolbar" />

    <org.ninetripods.mq.study.recycle.swipe_menu.SwipeRecycleView
        android:id="@+id/swipe_recycleview"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_below="@id/toolbar" />
</RelativeLayout>

我們用到的SwipeRecycleView也是自定義RecycleView,主要是處理一些和SwipeMenuLayout的滑動衝突。

先分析SwipeMenuLayout代碼:
public static final int STATE_CLOSED = 0;//關閉狀態
public static final int STATE_OPEN = 1;//打開狀態
public static final int STATE_MOVING_LEFT = 2;//左滑將要打開狀態
public static final int STATE_MOVING_RIGHT = 3;//右滑將要關閉狀態

首先定義了SwipeMenuLayout的四種狀態:
STATE_CLOSED 關閉狀態
STATE_OPEN 打開狀態
STATE_MOVING_LEFT 左滑將要打開狀態
STATE_MOVING_RIGHT 右滑將要關閉狀態

接着通過自定義屬性來獲得右側菜單根佈局的id,然後通過findViewById()來得到根佈局的View,進而獲得其寬度值。

//獲取右邊菜單id
TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.SwipeMenuLayout);
mRightId = typedArray.getResourceId(R.styleable.SwipeMenuLayout_right_id, 0); 
typedArray.recycle();

相應的attr.xml文件:

<declare-styleable name="SwipeMenuLayout">
     <!-- format="reference"意爲參考某一資源ID -->
     <attr name="content_id" format="reference" />
     <attr name="right_id" format="reference" />
 </declare-styleable>
@Override
 protected void onFinishInflate() {
     super.onFinishInflate();
     if (mRightId != 0) {
         rightMenuView = findViewById(mRightId);
     }
 }

接着來看onTouchEvent,先看ACTION_DOWN事件和ACTION_MOVE事件:

@Override
public boolean onTouchEvent(MotionEvent event) {
    switch (event.getAction()) {
        case MotionEvent.ACTION_DOWN:
            mDownX = (int) event.getX();
            mDownY = (int) event.getY();
            mLastX = (int) event.getX();
            break;
        case MotionEvent.ACTION_MOVE:
            int dx = (int) (mDownX - event.getX());
            int dy = (int) (mDownY - event.getY());
            //如果Y軸偏移量大於X軸偏移量 不再滑動
            if (Math.abs(dy) > Math.abs(dx)) return false;

            int deltaX = (int) (mLastX - event.getX());
            if (deltaX > 0) {
                //向左滑動
                currentState = STATE_MOVING_LEFT;
                if (deltaX >= menuWidth || getScrollX() + deltaX >= menuWidth) {
                    //右邊緣檢測
                    scrollTo(menuWidth, 0);
                    currentState = STATE_OPEN;
                    break;
                }
            } else if (deltaX < 0) {
                //向右滑動
                currentState = STATE_MOVING_RIGHT;
                if (deltaX + getScrollX() <= 0) {
                    //左邊緣檢測
                    scrollTo(0, 0);
                    currentState = STATE_CLOSED;
                    break;
                }
            }
            scrollBy(deltaX, 0);
            mLastX = (int) event.getX();
            break;
    }
    return super.onTouchEvent(event);
}

在ACTION_MOVE事件中通過點擊所在座標和上一次滑動記錄的座標之差來判斷左右滑動,並進行左邊緣和右邊緣檢測,如果還未到左右內容的邊界,則通過scrollBy來實現滑動。
接着看ACTION_UP和ACTION_CANCEL事件:

        case MotionEvent.ACTION_UP:
        case MotionEvent.ACTION_CANCEL:
            if (currentState == STATE_MOVING_LEFT) {
                //左滑打開
                mScroller.startScroll(getScrollX(), 0, menuWidth - getScrollX(), 0, 300);
                invalidate();
            } else if (currentState == STATE_MOVING_RIGHT || currentState == STATE_OPEN) {
                //右滑關閉
                smoothToCloseMenu();
            }
            //如果小於滑動距離並且菜單是關閉狀態 此時Item可以有點擊事件
            int deltx = (int) (mDownX - event.getX());
            return !(Math.abs(deltx) < mScaledTouchSlop && isMenuClosed()) || super.onTouchEvent(event);
    }
    return super.onTouchEvent(event);

這裏主要是當鬆開手時執行ACTION_UP事件,如果不處理,則會變成菜單顯示一部分然後卡在那裏了,這當然是不行的,這裏通過OverScroller.startScroll()來實現慣性滑動,然而當我們調用startScroll()之後還是不會實現慣性滑動的,這裏還需要調用invalidate()去重繪,重繪時會執行computeScroll()方法:

@Override
public void computeScroll() {
    if (mScroller.computeScrollOffset()) {
        // Get current x and y positions
        int currX = mScroller.getCurrX();
        int currY = mScroller.getCurrY();
        scrollTo(currX, currY);
        postInvalidate();
    }
    if (isMenuOpen()) {
        currentState = STATE_OPEN;
    } else if (isMenuClosed()) {
        currentState = STATE_CLOSED;
    }
}

在computeScroll()方法中,我們通過Scroller.getCurrX()和scrollTo()來滑動到指定座標位置,然後調用postInvalidate()又去重繪,不斷循環,直到滑動到邊界爲止。

再分析下SwipeRecycleView:

SwipeRecycleView是SwipeMenuLayout的父View,事件分發時,先到達的SwipeRecycleView,

@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
    boolean isIntercepted = super.onInterceptTouchEvent(event);
    switch (event.getAction()) {
        case MotionEvent.ACTION_DOWN:
            mLastX = (int) event.getX();
            mLastY = (int) event.getY();
            mDownX = (int) event.getX();
            mDownY = (int) event.getY();
            isIntercepted = false;
            //根據MotionEvent的X Y值得到子View
            View view = findChildViewUnder(mLastX, mLastY);
            if (view == null) return false;
            //點擊的子View所在的位置
            final int touchPos = getChildAdapterPosition(view);
            if (touchPos != mLastTouchPosition && mLastMenuLayout != null
                        && mLastMenuLayout.currentState != SwipeMenuLayout.STATE_CLOSED) {
                if (mLastMenuLayout.isMenuOpen()) {
                    //如果之前的菜單欄處於打開狀態,則關閉它
                    mLastMenuLayout.smoothToCloseMenu();
                }
                isIntercepted = true;
            } else {
                //根據點擊位置獲得相應的子View
                ViewHolder holder = findViewHolderForAdapterPosition(touchPos);
                if (holder != null) {
                    View childView = holder.itemView;
                    if (childView != null && childView instanceof SwipeMenuLayout) {
                        mLastMenuLayout = (SwipeMenuLayout) childView;
                        mLastTouchPosition = touchPos;
                    }
                }
            }
            break;
        case MotionEvent.ACTION_MOVE:
        case MotionEvent.ACTION_UP:
        case MotionEvent.ACTION_CANCEL:
            int dx = (int) (mDownX - event.getX());
            int dy = (int) (mDownY - event.getY());
            if (Math.abs(dx) > mScaleTouchSlop && Math.abs(dx) > Math.abs(dy)
                        || (mLastMenuLayout != null && mLastMenuLayout.currentState != SwipeMenuLayout.STATE_CLOSED)) {
                //如果X軸偏移量大於Y軸偏移量 或者上一個打開的菜單還沒有關閉 則禁止RecycleView滑動 RecycleView不去攔截事件
                return false;
            }
            break;
    }
    return isIntercepted;
}

通過findChildViewUnder()找到ItemView,進而通過getChildAdapterPosition(view)來獲得點擊位置,如果是第一次點擊,則會通過findViewHolderForAdapterPosition()找到對應的ViewHolder 並獲得子View;如果不是第一次點擊,和上次點擊不是同一個item並且前一個ItemView的菜單處於打開狀態,那麼此時調用smoothToCloseMenu()關閉菜單。在ACTION_MOVE、ACTION_UP、ACTION_CANCEL事件中,如果X軸偏移量大於Y軸偏移量 或者上一個打開的菜單還沒有關閉 則禁止SwipeRecycleView滑動,SwipeRecycleView不去攔截事件,相應的將事件傳到SwipeMenuLayout中去。

@Override
 public boolean onTouchEvent(MotionEvent e) {
    switch (e.getAction()) {
        case MotionEvent.ACTION_DOWN:
            //若某個Item的菜單還沒有關閉,則RecycleView不能滑動
            if (!mLastMenuLayout.isMenuClosed()) {
                return false;
            }
            break;
         case MotionEvent.ACTION_MOVE:
         case MotionEvent.ACTION_UP:
            if (mLastMenuLayout != null && mLastMenuLayout.isMenuOpen()) {
                mLastMenuLayout.smoothToCloseMenu();
            }
            break;
    }
    return super.onTouchEvent(e);
 }

在onTouchEvent的ACTION_DOWN事件中,如果某個Item的菜單還沒有關閉,則SwipeRecycleView不能滑動,在ACTION_MOVE、ACTION_UP事件中,如果前一個ItemView的菜單是打開狀態,則先關閉它。

踩過的坑:

說起踩坑尼瑪真是一把鼻涕一把淚,因爲水平有限遇到了很多坑,當時要不是趕緊看了一下銀行卡的餘額不足,我差一點就把電腦砸了去買新的了~當時的心情是下面這樣的:

fuck_the_world.gif

1、當在某個ItemView (SwipeMenuLayout) 保持按下操作,然後手勢從SwipeMenuLayout控件內部轉移到外部,然後菜單滑到一半就卡在那裏了,在那裏卡住了~那裏卡住了~卡住了~住了~了~,當時有點不知所措,後來通過Debug發現SwipeMenuLayout的ACTION_UP已經不會執行了,想想也是,你都滑動外面了,人家憑啥還執行ACTION_UP方法,後來通過google發現SwipeMenuLayout不執行ACTION_UP但是會執行ACTION_CANCEL,ACTION_CANCEL是當前滑動手勢被打斷時調用,比如在某個控件保持按下操作,然後手勢從控件內部轉移到外部,此時控件手勢事件被打斷,會觸發ACTION_CANCEL,解決方法也就出來了,即ACTION_UP和ACTION_CANCEL都根據判斷條件去執行慣性滑動的邏輯。

2、假如某個ItemView (SwipeMenuLayout) 的右側菜單欄處於打開狀態,此時去上下滑動SwipeRecycleView,發現菜單欄關閉了,但同時SwipeRecycleView也跟着上下滑動了,這裏的解決方法是在SwipeRecycleView的onTouchEvent中去判斷:

@Override
 public boolean onTouchEvent(MotionEvent e) {
    switch (e.getAction()) {
        case MotionEvent.ACTION_DOWN:
            //若某個Item的菜單還沒有關閉,則RecycleView不能滑動
            if (!mLastMenuLayout.isMenuClosed()) {
                return false;
            }
     ................省略其他..................
    }
    return super.onTouchEvent(e);
 }

通過判斷,若某個Item的菜單還沒有關閉,直接返回false,那麼SwipeRecycleView就不會再消費此次事件,即SwipeRecycleView不會上下滑動了。

後記:

本文主要運用的是View滑動的相關知識,如scrollTo、scrollBy、OverScroller等,水平有限,如果發現文章有誤,還請不吝賜教,不勝感激~最後再貼下源碼地址:
Android仿QQ側滑菜單,如果對您有幫助,給個star吧,感謝老鐵~

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章