效果图
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
,进而获得其宽度值。
//获取右边菜单id
TypedArray 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
事件:
@Override
public 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()
方法:
@Override
public 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
,
@Override
public 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吧,感谢老铁~