先上效果图:
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,进而获得其宽度值。
//获取右边菜单idTypedArray 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事件:
@Overridepublic 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()方法:
@Overridepublic 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,
@Overridepublic 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的菜单是打开状态,则先关闭它。
踩过的坑:
说起踩坑尼玛真是一把鼻涕一把泪,因为水平有限遇到了很多坑,当时要不是赶紧看了一下银行卡的余额不足,我差一点就把电脑砸了去买新的了~当时的心情是下面这样的:
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吧,感谢老铁~