天天看點

Android 自定義萬能的抽屜布局(側滑菜單)GenericDrawerLayout

轉載請注明出處:

http://blog.csdn.net/a740169405/article/details/49720973

前言

大家應該對側滑菜單很熟悉了,大多數是從左側滑出。其實實作原理是v4支援包提供的一個類DrawerLayout。今天我要帶大家自己定義一個DrawerLayout,并且支援從螢幕四個邊緣劃出來。GO~

先看看效果圖:

Android 自定義萬能的抽屜布局(側滑菜單)GenericDrawerLayout

一、布局

自定義的容器裡,包含三個View,一個是用來繪制背景顔色的,第二個是在抽屜關閉時,用來響應觸摸事件接着打開抽屜。另一個是用來存放抽屜視圖的FrameLayout。

PS: 這裡的三個View都是自定義View,為什麼要自定義,後面會講到。

我們看看初始化函數initView:

private void initView() {
    // 初始化背景色變化控件
    mDrawView = new DrawView(mContext);
    addView(mDrawView, generateDefaultLayoutParams());
    // 初始化用來相應觸摸的透明View
    mTouchView = new TouchView(mContext);
    mClosedTouchViewSize = dip2px(mContext, TOUCH_VIEW_SIZE_DIP);
    mOpenedTouchViewSize = mClosedTouchViewSize;
    // 初始化用來存放布局的容器
    mContentLayout = new ContentLayout(mContext);
    mContentLayout.setVisibility(View.INVISIBLE);
    // 添加視圖
    addView(mTouchView, generateTouchViewLayoutParams());
    addView(mContentLayout,
            new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
    // 用來判斷事件下發的臨界距離
    mMinDisallowDispatch = dip2px(mContext, MIN_CONSUME_SIZE_DIP);
}
           

接着,既然要支援四個方向拉出抽屜,我設定了變量mTouchViewGravity 用來存放抽屜的相對螢幕的中心,其取值範圍在系統類Gravity,的LEFT, TOP, RIGHT, BOTTOM裡。

/* 目前抽屜的Gravity /

private int mTouchViewGravity = Gravity.LEFT;

抽屜預設為LEFT從左邊拉出來。

提供一個接口,友善使用者設定抽屜位置:

/**
 * 設定抽屜的位置
 *
 * @param drawerPosition 抽屜位置
 * @see Gravity
 */
public void setDrawerGravity(int drawerPosition) {
    if (drawerPosition != Gravity.LEFT && drawerPosition != Gravity.TOP
            && drawerPosition != Gravity.RIGHT && drawerPosition != Gravity.BOTTOM) {
        // 如果不是LEFT, TOP, RIGHT, BOTTOM中的一種,直接傳回
        return;
    }
    this.mTouchViewGravity = drawerPosition;
    // 更新抽屜位置
    mTouchView.setLayoutParams(generateTouchViewLayoutParams());
}
           

二、Touch事件處理

在這裡,我們需要攔截Touch事件的傳遞,我這裡講攔截動作放在Touch事件分發的時候處理。也就是dispatchTouchEvent(MotionEvent event);函數裡。

講到這,大家應該明白了為什麼我要自定義View來攔截事件。通過重寫dispatchTouchEvent函數來擷取事件,并根據目前情況判斷是否需要将事件繼續下發。

1. 拉出抽屜

直接上代碼:

@Override
public boolean dispatchTouchEvent(MotionEvent event) {
    if (!mIsOpenable) {
        // 如果禁用了抽屜
        return super.dispatchTouchEvent(event);
    }
    switch (event.getAction()) {
    case MotionEvent.ACTION_DOWN:
        if (getVisibility() == View.INVISIBLE) {
            // 如果目前TouchView不可見
            return super.dispatchTouchEvent(event);
        }
        // 顯示抽屜
        mContentLayout.setVisibility(View.VISIBLE);
        // 調整抽屜位置
        adjustContentLayout();
        if (mDrawerCallback != null) {
            // 回調事件(開始打開抽屜)
            mDrawerCallback.onPreOpen();
        }
        // 隐藏TouchView
        setVisibility(View.INVISIBLE);
        break;
    }
    // 處理Touch事件
    performDispatchTouchEvent(event);
    return true;
}
           

當TouchDown時,需要調整抽屜的位置:

/**
 * 拖拽開始前,調整内容視圖位置
 */
private void adjustContentLayout() {
    float mStartTranslationX = ;
    float mStartTranslationY = ;
    switch (mTouchViewGravity) {
    case Gravity.LEFT:
        mStartTranslationX = -mContentLayout.getWidth();
        mStartTranslationY = ;
        break;
    case Gravity.RIGHT:
        mStartTranslationX = mContentLayout.getWidth();
        mStartTranslationY = ;
        break;
    case Gravity.TOP:
        mStartTranslationX = ;
        mStartTranslationY = -mContentLayout.getHeight();
        break;
    case Gravity.BOTTOM:
        mStartTranslationX = ;
        mStartTranslationY = mContentLayout.getHeight();
        break;
    }
    // 移動抽屜
    ViewHelper.setTranslationX(mContentLayout, mStartTranslationX);
    ViewHelper.setTranslationY(mContentLayout, mStartTranslationY);
}
           

我們看看是怎麼處理Touch事件分發的:

private void performDispatchTouchEvent(MotionEvent event) {
    if (mVelocityTracker == null) {
        // 速度測量
        mVelocityTracker = VelocityTracker.obtain();
    }
    // 調整事件資訊,用于測量速度
    MotionEvent trackerEvent = MotionEvent.obtain(event);
    trackerEvent.setLocation(event.getRawX(), event.getRawY());
    mVelocityTracker.addMovement(trackerEvent);
    switch (event.getAction()) {
    case MotionEvent.ACTION_DOWN:
        // 記錄目前觸摸的位置
        mCurTouchX = event.getRawX();
        mCurTouchY = event.getRawY();
        break;
    case MotionEvent.ACTION_MOVE:
        float moveX = event.getRawX() - mCurTouchX;
        float moveY = event.getRawY() - mCurTouchY;
        // 移動抽屜
        translateContentLayout(moveX, moveY);
        mCurTouchX = event.getRawX();
        mCurTouchY = event.getRawY();
        break;
    case MotionEvent.ACTION_UP:
    case MotionEvent.ACTION_CANCEL:
        // 處理擡起事件
        handleTouchUp();
        break;
    }
}
           

我們用到了VelocityTracker來測量手指滑動的速度,需要注意的是,VelocityTracker是通過MotionEvent的getX();以及getY();擷取目前X、Y軸的值,因為這兩個值是相對父容器的位置,這裡我把Touch事件的X,Y值調整為相對螢幕左上角的X,Y軸值,這樣能夠擷取精确的速度值。

接着,需要實作拖拽效果,這裡借用了NineOldAndroids開源庫,能夠在android 2.x的固件上實作移動效果。接着看看如何處理的:

/**
 * 移動視圖
 *
 * @param moveX
 * @param moveY
 */
private void translateContentLayout(float moveX, float moveY) {
    float move;
    switch (mTouchViewGravity) {
        case Gravity.LEFT:
            if (getCurTranslation() + moveX < -mContentLayout.getWidth()) {
                // 完全關閉
                move = -mContentLayout.getWidth();
            } else if (getCurTranslation() + moveX > ) {
                // 完全打開
                move = ;
            } else {
                move = getCurTranslation() + moveX;
            }
            break;
        case Gravity.RIGHT:
            if (getCurTranslation() + moveX > mContentLayout.getWidth()) {
                move = mContentLayout.getWidth();
            } else if (getCurTranslation() + moveX< ) {
                move = ;
            } else {
                move = getCurTranslation() + moveX;
            }
            break;
        case Gravity.TOP:
            if (getCurTranslation() + moveY < -mContentLayout.getHeight()) {
                move = -mContentLayout.getHeight();
            } else if (getCurTranslation() + moveY > ) {
                move = ;
            } else {
                move = getCurTranslation() + moveY;
            }
            break;
        case Gravity.BOTTOM:
            if (getCurTranslation() + moveY > mContentLayout.getHeight()) {
                move = mContentLayout.getHeight();
            } else if (getCurTranslation() + moveY < ) {
                move = ;
            } else {
                move = getCurTranslation() + moveY;
            }
            break;
        default:
            move = ;
            break;
    }
    if (isHorizontalGravity()) {
        // 使用相容低版本的方法移動抽屜
        ViewHelper.setTranslationX(mContentLayout, move);
        // 回調事件
        translationCallback(mContentLayout.getWidth() - Math.abs(move));
    } else {
        // 使用相容低版本的方法移動抽屜
        ViewHelper.setTranslationY(mContentLayout, move);
        // 回調事件
        translationCallback(mContentLayout.getHeight() - Math.abs(move));
    }
}
           

這個函數裡主要是根據目前移動的距離調整抽屜的位置。

當手指放開的時候,需要處理TouchUp事件:

private void handleTouchUp() {
    // 計算從Down到Up每秒移動的距離
    final VelocityTracker velocityTracker = mVelocityTracker;
    velocityTracker.computeCurrentVelocity();
    int velocityX = (int) velocityTracker.getXVelocity();
    int velocityY = (int) velocityTracker.getYVelocity();

    // 回收測量器
    if (mVelocityTracker != null) {
        mVelocityTracker.recycle();
        mVelocityTracker = null;
    }

    switch (mTouchViewGravity) {
    case Gravity.LEFT:
        if (velocityX > VEL || (getCurTranslation() > -mContentLayout.getWidth() * SCALE_AUTO_OPEN_CLOSE) && velocityX > -VEL) {
            // 速度足夠,或者移動距離足夠,打開抽屜
            autoOpenDrawer();
        } else {
            autoCloseDrawer();
        }
        break;
    case Gravity.RIGHT:
        if (velocityX < -VEL || (getCurTranslation() < mContentLayout.getWidth() * ( - SCALE_AUTO_OPEN_CLOSE) && velocityX < VEL)) {
            // 速度足夠,或者移動距離足夠,打開抽屜
            autoOpenDrawer();
        } else {
            autoCloseDrawer();
        }
        break;
    case Gravity.TOP:
        if (velocityY > VEL || (getCurTranslation() > -mContentLayout.getHeight() * SCALE_AUTO_OPEN_CLOSE) && velocityY > -VEL) {
            // 速度足夠,或者移動距離足夠,打開抽屜
            autoOpenDrawer();
        } else {
            autoCloseDrawer();
        }
        break;
    case Gravity.BOTTOM:
        if (velocityY < -VEL || (getCurTranslation() < mContentLayout.getHeight() * ( - SCALE_AUTO_OPEN_CLOSE)) && velocityY < VEL) {
            // 速度足夠,或者移動距離足夠,打開抽屜
            autoOpenDrawer();
        } else {
            autoCloseDrawer();
        }
        break;
    }
}
           

根據放手的時候的速度大小是否滿足最小速度要求,以及滑動的距離是否滿足最小要求,判斷目前是要打開還是關閉。

抽屜的打開與關閉,需要平緩的過度,需要做一個過度動畫,這裡同樣是使用nineoldandroids實作的:

/**
 * 自動打開抽屜
 */
private void autoOpenDrawer() {
    mAnimating.set(true);
    // 從目前移動的位置,平緩移動到完全打開抽屜的位置
    mAnimator = ObjectAnimator.ofFloat(getCurTranslation(), getOpenTranslation());
    mAnimator.setDuration(DURATION_OPEN_CLOSE);
    mAnimator.addUpdateListener(new MyAnimatorUpdateListener());
    mAnimator.addListener(new AnimatorListenerAdapter() {
        @Override
        public void onAnimationStart(Animator animation) {
            // 回掉事件
            if (!AnimStatus.OPENING.equals(mAnimStatus) && !AnimStatus.OPENED.equals(mAnimStatus)) {
                if (mDrawerCallback != null) {
                    mDrawerCallback.onStartOpen();
                }
            }
            // 更新狀态
            mAnimStatus = AnimStatus.OPENING;
        }

        @Override
        public void onAnimationEnd(Animator animation) {
            if (!mAnimating.get()) {
                // 正在播放動畫(打開/關閉)
                return;
            }
            if (mDrawerCallback != null) {
                mDrawerCallback.onEndOpen();
            }
            mAnimating.set(false);
            mAnimStatus = AnimStatus.OPENED;
        }
    });
    mAnimator.start();
}
           
/**
 * 自動關閉抽屜
 */
private void autoCloseDrawer() {
    mAnimating.set(true);
    mAnimator = ObjectAnimator.ofFloat(getCurTranslation(), getCloseTranslation());
    mAnimator.setDuration(DURATION_OPEN_CLOSE);
    mAnimator.addUpdateListener(new MyAnimatorUpdateListener());
    mAnimator.addListener(new AnimatorListenerAdapter() {
        @Override
        public void onAnimationStart(Animator animation) {
            if (!AnimStatus.CLOSING.equals(mAnimStatus) && !AnimStatus.CLOSED.equals(mAnimStatus)) {
                if (mDrawerCallback != null) {
                    mDrawerCallback.onStartClose();
                }
            }
            mAnimStatus = AnimStatus.CLOSING;
        }

        @Override
        public void onAnimationEnd(Animator animation) {
            if (!mAnimating.get()) {
                return;
            }
            if (mDrawerCallback != null) {
                mDrawerCallback.onEndClose();
                mAnimStatus = AnimStatus.CLOSED;
            }
            // 當抽屜完全關閉的時候,将響應打開事件的View顯示
            mTouchView.setVisibility(View.VISIBLE);
            mAnimating.set(false);
        }
    });
    mAnimator.start();
}
           

這樣,打開抽屜的做完了。接着要實作關閉的過程,這個過程相對比較複雜,因為觸摸事件需要分發給抽屜裡的視圖,情況比較多,我們還是從抽屜容器的dispatchTouchEvent方法入手。

/**
 * 抽屜容器
 */
private class ContentLayout extends FrameLayout {

    private float mDownX, mDownY;
    private boolean isTouchDown;

    public ContentLayout(Context context) {
        super(context);
    }

    @Override
    public void requestDisallowInterceptTouchEvent(boolean disallowIntercept) {
        super.requestDisallowInterceptTouchEvent(disallowIntercept);
    }

    @Override
    public boolean dispatchTouchEvent(MotionEvent event) {
        if (getVisibility() != View.VISIBLE) {
            // 抽屜不可見
            return super.dispatchTouchEvent(event);
        }

        // TOUCH_DOWN的時候未消化事件
        if (MotionEvent.ACTION_DOWN != event.getAction() && !isTouchDown) {
            isChildConsumeTouchEvent = true;
        }

        // 把事件攔截下來,按條件下發給子View;
        switch (event.getAction()) {
        case MotionEvent.ACTION_DOWN:
            if (mAnimating.get()) {
                mAnimating.set(false);
                // 停止播放動畫
                mAnimator.end();
                isTouchDown = true;
            } else {
                // 判斷是否點選在響應區域内
                isTouchDown = isDownInRespondArea(event);
            }
            if (isTouchDown) {
                mDownX = event.getRawX();
                mDownY = event.getRawY();
                performDispatchTouchEvent(event);
            } else {
                // 标記為子視圖消費事件
                isChildConsumeTouchEvent = true;
            }
            // 傳遞給子視圖
            super.dispatchTouchEvent(event);
            // 攔截事件
            return true;
        case MotionEvent.ACTION_MOVE:
            if (!isConsumeTouchEvent && !isChildConsumeTouchEvent) {

                // 先下發給子View看看子View是否需要消費
                boolean b = super.dispatchTouchEvent(event);

                // 如果自己還沒消化掉事件,看看子view是否需要消費事件
                boolean goToConsumeTouchEvent = false;
                switch (mTouchViewGravity) {
                    case Gravity.LEFT:
                        if ((Math.abs(event.getRawY() - mDownY) >= mMinDisallowDispatch) && b) {
                            // 當抽屜在左側,手指在Y軸移動的距離大于臨界值,并且子視圖消費了Move事件,則标記為子視圖已經消費
                            isChildConsumeTouchEvent = true;
                        } else if (event.getRawX() - mDownX < -mMinDisallowDispatch) {
                            // 當X軸方向移動的距離大于臨界值的時候,标記為抽屜消費了事件,這時候需要移動抽屜
                            isConsumeTouchEvent = true;
                            goToConsumeTouchEvent = true;
                        }
                        break;
                    case Gravity.RIGHT:
                        if ((Math.abs(event.getRawY() - mDownY) >= mMinDisallowDispatch) && b) {
                            // 當抽屜在右側,手指在Y軸移動的距離大于臨界值,并且子視圖消費了Move事件,則标記為子視圖已經消費
                            isChildConsumeTouchEvent = true;
                        } else if (event.getRawX() - mDownX > mMinDisallowDispatch) {
                            // 當X軸方向移動的距離大于臨界值的時候,标記為抽屜消費了事件,這時候需要移動抽屜
                            isConsumeTouchEvent = true;
                            goToConsumeTouchEvent = true;
                        }
                        break;
                    case Gravity.BOTTOM:
                        if ((Math.abs(event.getRawX() - mDownX) >= mMinDisallowDispatch) && b) {
                            // 當抽屜在下側,手指在X軸移動的距離大于臨界值,并且子視圖消費了Move事件,則标記為子視圖已經消費
                            isChildConsumeTouchEvent = true;
                        } else if (event.getRawY() - mDownY > mMinDisallowDispatch) {
                            // 當Y軸方向移動的距離大于臨界值的時候,标記為抽屜消費了事件,這時候需要移動抽屜
                            isConsumeTouchEvent = true;
                            goToConsumeTouchEvent = true;
                        }
                        break;
                    case Gravity.TOP:
                        if ((Math.abs(event.getRawX() - mDownX) >= mMinDisallowDispatch) && b) {
                            // 當抽屜在上側,手指在X軸移動的距離大于臨界值,并且子視圖消費了Move事件,則标記為子視圖已經消費
                            isChildConsumeTouchEvent = true;
                        } else if (event.getRawY() - mDownY < -mMinDisallowDispatch) {
                            // 當Y軸方向移動的距離大于臨界值的時候,标記為抽屜消費了事件,這時候需要移動抽屜
                            isConsumeTouchEvent = true;
                            goToConsumeTouchEvent = true;
                        }
                        break;
                }
                if (goToConsumeTouchEvent) {
                    // 如果自己消費了事件,則下發TOUCH_CANCEL事件(防止Button一直處于被按住的狀态)
                    MotionEvent obtain = MotionEvent.obtain(event);
                    obtain.setAction(MotionEvent.ACTION_CANCEL);
                    super.dispatchTouchEvent(obtain);
                }
            }
            break;
        }

        if (isChildConsumeTouchEvent || !isConsumeTouchEvent) {
            // 自己未消費之前,先下發給子View
            super.dispatchTouchEvent(event);
        } else if (isConsumeTouchEvent && !isChildConsumeTouchEvent) {
            // 如果自己消費了,則不給子View
            performDispatchTouchEvent(event);
        }

        switch (event.getAction()) {
        case MotionEvent.ACTION_UP:
        case MotionEvent.ACTION_CANCEL:
            if (!isConsumeTouchEvent && !isChildConsumeTouchEvent) {
                // 如果子View以及自己都沒消化,則自己消化,防止點選一下,抽屜卡住
                performDispatchTouchEvent(event);
            }
            isConsumeTouchEvent = false;
            isChildConsumeTouchEvent = false;
            isTouchDown = false;
            break;
        }

        return true;
    }

}
           

寫的有點複雜,對事件分發了解的可能該不夠,哈哈。

下面是判斷一次Touch事件是否有落在響應區域内。

/** 是否點選在響應區域 */
private boolean isDownInRespondArea(MotionEvent event) {
    float curTranslation = getCurTranslation();
    float x = event.getRawX();
    float y = event.getRawY();
    switch (mTouchViewGravity) {
        case Gravity.LEFT:
            if (x > curTranslation - mOpenedTouchViewSize && x < curTranslation) {
                return true;
            }
            break;
        case Gravity.RIGHT:
            if (x > curTranslation && x < curTranslation + mOpenedTouchViewSize) {
                return true;
            }
            break;
        case Gravity.BOTTOM:
            if (y > curTranslation && y < curTranslation + mOpenedTouchViewSize) {
                return true;
            }
            break;
        case Gravity.TOP:
            if (y > curTranslation - mOpenedTouchViewSize && y < curTranslation) {
                return true;
            }
            break;
        default:
            break;
    }
    return false;
}
           

上面說到兩個響應區,一個是打開時的,一個是關閉時的,響應區的打開,也是提供給外部設定的:

/** 設定關閉狀态下,響應觸摸事件的控件寬度 */
public void setTouchSizeOfClosed(int width) {
    if (width ==  || width < ) {
        mClosedTouchViewSize = dip2px(mContext, TOUCH_VIEW_SIZE_DIP);
    } else {
        mClosedTouchViewSize = width;
    }
    ViewGroup.LayoutParams lp = mTouchView.getLayoutParams();
    if (lp != null) {
        if (isHorizontalGravity()) {
            lp.width = mClosedTouchViewSize;
            lp.height = ViewGroup.LayoutParams.MATCH_PARENT;
        } else {
            lp.height = mClosedTouchViewSize;
            lp.width = ViewGroup.LayoutParams.MATCH_PARENT;
        }
        mTouchView.requestLayout();
    }
}

/** 設定打開狀态下,響應觸摸事件的控件寬度 */
public void setTouchSizeOfOpened(int width) {
    if (width <= ) {
        mOpenedTouchViewSize = dip2px(mContext, TOUCH_VIEW_SIZE_DIP);
    } else {
        mOpenedTouchViewSize = width;
    }
}
           

抽屜在滑動的時候有很多事件,在各個事件觸發的地方做個回調。

因為需要回調的事件比較多,是以使用内部類實作接口,這樣設定回調接口的時候就不用去實作一些沒必要的回調方法:

public void setDrawerCallback(DrawerCallback drawerCallback) {
    this.mDrawerCallback = drawerCallback;
}

public interface DrawerCallback {

    void onStartOpen();

    void onEndOpen();

    void onStartClose();

    void onEndClose();

    void onPreOpen();

    /**
     * 正在移動回調
     * @param gravity
     * @param translation 移動的距離(目前移動位置到邊界的距離,永遠為正數)
     */
    void onTranslating(int gravity, float translation);
}

public static class DrawerCallbackAdapter implements DrawerCallback {

    @Override
    public void onStartOpen() {

    }

    @Override
    public void onEndOpen() {

    }

    @Override
    public void onStartClose() {

    }

    @Override
    public void onEndClose() {

    }

    @Override
    public void onPreOpen() {

    }

    @Override
    public void onTranslating(int gravity, float translation) {

    }
}
           

結束語:

後期有維護,有問題請留言,謝謝。

2015年12月12日17:29:41:

新增抽屜留白功能,支援抽屜拉出一部分,留出部分空白區域:詳見: gitHub

Android 自定義萬能的抽屜布局(側滑菜單)GenericDrawerLayout

源碼分享:

為了不斷更新,我把源碼送出到了gitHub

gitHub:https://github.com/a740169405/GenericDrawerLayout

CSDN下載下傳的可能不是最新代碼,建議到gitHub下載下傳,當然前提是可以打開gitHub。

CSDN下載下傳:http://download.csdn.net/detail/a740169405/9253119