轉載請注明出處:
http://blog.csdn.net/a740169405/article/details/49720973
前言
大家應該對側滑菜單很熟悉了,大多數是從左側滑出。其實實作原理是v4支援包提供的一個類DrawerLayout。今天我要帶大家自己定義一個DrawerLayout,并且支援從螢幕四個邊緣劃出來。GO~
先看看效果圖:
![](https://img.laitimes.com/img/9ZDMuAjOiMmIsIjOiQnIsICO5YTMwMjMzIDOwETM1EDMy8CX0Vmbu4GZzNmLn9Gbi1yZtl2Lc9CX6MHc0RHaiojIsJye.jpg)
一、布局
自定義的容器裡,包含三個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
源碼分享:
為了不斷更新,我把源碼送出到了gitHub
gitHub:https://github.com/a740169405/GenericDrawerLayout
CSDN下載下傳的可能不是最新代碼,建議到gitHub下載下傳,當然前提是可以打開gitHub。
CSDN下載下傳:http://download.csdn.net/detail/a740169405/9253119