转载请注明出处:
http://blog.csdn.net/a740169405/article/details/49720973
大家应该对侧滑菜单很熟悉了,大多数是从左侧滑出。其实实现原理是v4支持包提供的一个类DrawerLayout。今天我要带大家自己定义一个DrawerLayout,并且支持从屏幕四个边缘划出来。GO~
自定义的容器里,包含三个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事件分发的时候处理。也就是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 = 0;
float mStartTranslationY = 0;
switch (mTouchViewGravity) {
case Gravity.LEFT:
mStartTranslationX = -mContentLayout.getWidth();
mStartTranslationY = 0;
break;
case Gravity.RIGHT:
mStartTranslationX = mContentLayout.getWidth();
mStartTranslationY = 0;
break;
case Gravity.TOP:
mStartTranslationX = 0;
mStartTranslationY = -mContentLayout.getHeight();
break;
case Gravity.BOTTOM:
mStartTranslationX = 0;
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 > 0) {
// 完全打开
move = 0;
} else {
move = getCurTranslation() + moveX;
}
break;
case Gravity.RIGHT:
if (getCurTranslation() + moveX > mContentLayout.getWidth()) {
move = mContentLayout.getWidth();
} else if (getCurTranslation() + moveX< 0) {
move = 0;
} else {
move = getCurTranslation() + moveX;
}
break;
case Gravity.TOP:
if (getCurTranslation() + moveY < -mContentLayout.getHeight()) {
move = -mContentLayout.getHeight();
} else if (getCurTranslation() + moveY > 0) {
move = 0;
} else {
move = getCurTranslation() + moveY;
}
break;
case Gravity.BOTTOM:
if (getCurTranslation() + moveY > mContentLayout.getHeight()) {
move = mContentLayout.getHeight();
} else if (getCurTranslation() + moveY < 0) {
move = 0;
} else {
move = getCurTranslation() + moveY;
}
break;
default:
move = 0;
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(1000);
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() * (1 - 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() * (1 - 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 == 0 || width < 0) {
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 <= 0) {
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) {
}
}
后期有维护,有问题请留言,谢谢。
新增抽屉留白功能,支持抽屉拉出一部分,留出部分空白区域:详见: gitHub
为了不断更新,我把源码提交到了gitHub
gitHub:https://github.com/a740169405/GenericDrawerLayout
CSDN下载的可能不是最新代码,建议到gitHub下载,当然前提是可以打开gitHub。
CSDN下载:http://download.csdn.net/detail/a740169405/9253119