天天看点

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