https://github.com/yanzhenjie/SwipeRecyclerView
定义了一个继承RecyclerView的子类SwipeRecyclerView。在该类中重写了onInterceptTouchEvent方法。其中ItemView为SwipeMenuLayout继承FrameLayout。itemView的布局如下:
<com.yanzhenjie.recyclerview.SwipeMenuLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:contentViewId="@+id/swipe_content"
app:leftViewId="@+id/swipe_left"
app:rightViewId="@+id/swipe_right">
<com.yanzhenjie.recyclerview.SwipeMenuView
android:id="@+id/swipe_left"
android:layout_width="wrap_content"
android:layout_height="match_parent"/>
<FrameLayout
android:id="@+id/swipe_content"
android:layout_width="match_parent"
android:layout_height="wrap_content"/>
<com.yanzhenjie.recyclerview.SwipeMenuView
android:id="@+id/swipe_right"
android:layout_width="wrap_content"
android:layout_height="match_parent"/>
com.yanzhenjie.recyclerview.SwipeMenuLayout>
AdapterWrapper的onCreateViewHolder中,将child添加到swipe_content view中。
public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View contentView = mHeaderViews.get(viewType);
if (contentView != null) {
return new ViewHolder(contentView);
}
contentView = mFootViews.get(viewType);
if (contentView != null) {
return new ViewHolder(contentView);
}
final RecyclerView.ViewHolder viewHolder = mAdapter.onCreateViewHolder(parent, viewType);
...
contentView = mInflater.inflate(R.layout.x_recycler_view_item, parent, false);
ViewGroup viewGroup = contentView.findViewById(R.id.swipe_content);
viewGroup.addView(viewHolder.itemView);
try {
Field itemView = getSupperClass(viewHolder.getClass()).getDeclaredField("itemView");
if (!itemView.isAccessible()) itemView.setAccessible(true);
itemView.set(viewHolder, contentView);
} catch (Exception ignored) {
}
return viewHolder;
}
swipe_content view的child的布局如下:
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?selectableItemBackground"
android:gravity="center_vertical"
android:minHeight="@dimen/dp_70"
android:padding="@dimen/dp_10">
<TextView
android:id="@+id/tv_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
tools:text="Testing"/>
LinearLayout>
假设在列表中手指向左下方划动,划动的轨迹是一个理想的线段,且该线段与x轴的夹角< 45度,则线段上任意两点的横向位移dx > dy。通过ViewConfiguration分别得到引起滚动的最小偏移量touchSlop(假如是24px)、使View Fling的maxmumFlingVelocity(24000 pixels per second)和minimumFlingVelocity(150 pexels per second)。
SwipeRecyclerView.java
public boolean onInterceptTouchEvent(MotionEvent e) {
boolean isIntercepted = super.onInterceptTouchEvent(e);
if (allowSwipeDelete || mSwipeMenuCreator == null) {
return isIntercepted;
} else {
if (e.getPointerCount() > 1) return true;
int action = e.getAction();
// 1 获取MotinEvent对应的坐标(x, y)。该坐标是相对自身左上角顶点的坐标。
int x = (int)e.getX();
int y = (int)e.getY();
// 2 根据坐标(x, y)确定对应的position、ViewHolder、ItemView。
int touchPosition = getChildAdapterPosition(findChildViewUnder(x, y));
ViewHolder touchVH = findViewHolderForAdapterPosition(touchPosition);
SwipeMenuLayout touchView = null;
if (touchVH != null) {
View itemView = getSwipeMenuView(touchVH.itemView);
if (itemView instanceof SwipeMenuLayout) {
touchView = (SwipeMenuLayout)itemView;
}
}
boolean touchMenuEnable = mSwipeItemMenuEnable && !mDisableSwipeItemMenuList.contains(touchPosition);
if (touchView != null) {
touchView.setSwipeEnable(touchMenuEnable);
}
if (!touchMenuEnable) return isIntercepted;
switch (action) {
case MotionEvent.ACTION_DOWN: {
// 3 Down事件赋值第一个触点的坐标(mDownX, mDownY)。
mDownX = x;
mDownY = y;
isIntercepted = false;
// 4 如果上一次发生的触摸事件,且上一次的postion和这一次的position不相等;且上一次position的
// itemView打开了菜单,那么关闭这个菜单,然后清除上一次触摸事件的记录。且对应的方法的返回值为true
// 拦截该DOWN事件。
if (touchPosition != mOldTouchedPosition
&& mOldSwipedLayout != null
&& mOldSwipedLayout.isMenuOpen()) {
mOldSwipedLayout.smoothCloseMenu();
isIntercepted = true;
}
// 5 如果这次触摸事件是第一次,或者上一次触摸事件处理中没有打开菜单,那么记录这次触摸事件。
// 通过记录mOldTouchPosition和mOldSwipedLayout即可。并且方法的返回值为false,即不拦截Down
//事件 将事件传递给childView SwipeMenuLayout。
if (isIntercepted) {
mOldSwipedLayout = null;
mOldTouchedPosition = INVALID_POSITION;
} else if (touchView != null) {
mOldSwipedLayout = touchView;
mOldTouchedPosition = touchPosition;
}
break;
}
// They are sensitive to retain sliding and inertia.
case MotionEvent.ACTION_MOVE: {
// 6 调用handleUnDown方法,参数isIntercepted为false。如果横向的位移和纵向的位移小于
// 可引起滚动的偏移量,则方法的返回值为false,在最初的Move事件横向和纵向的位移都是小于
//touchSlop的。
isIntercepted = handleUnDown(x, y, isIntercepted);
if (mOldSwipedLayout == null) break;
ViewParent viewParent = getParent();
if (viewParent == null) break;
// 10 记录这次MotionEvent相对上次MotionEvent的横向位移。如果横向发生了位移则
// 对ViewParent设置Disallow_intercept的Flag。申请parentView不要拦截以后的事件。
int disX = mDownX - x;
// 向左滑,显示右侧菜单,或者关闭左侧菜单。
boolean showRightCloseLeft = disX > 0 &&
(mOldSwipedLayout.hasRightMenu() || mOldSwipedLayout.isLeftCompleteOpen());
// 向右滑,显示左侧菜单,或者关闭右侧菜单。
boolean showLeftCloseRight = disX < 0 &&
(mOldSwipedLayout.hasLeftMenu() || mOldSwipedLayout.isRightCompleteOpen());
Log.d(TAG, "onInterceptTouchEvent: wyj showRightCloseLeft:" + showRightCloseLeft
+ " showLeftCloseRight:" + showLeftCloseRight);
viewParent.requestDisallowInterceptTouchEvent(showRightCloseLeft || showLeftCloseRight);
}
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL: {
isIntercepted = handleUnDown(x, y, isIntercepted);
break;
}
}
}
Log.d(TAG, "onInterceptTouchEvent: wyj isIntercepted:" + isIntercepted);
return isIntercepted;
}
private boolean handleUnDown(int x, int y, boolean defaultValue) {
Log.d(TAG, "handleUnDown: wyj defaultValue:" + defaultValue);
// 7 计算这次MotionEvent相对上次MotionEvent的横向和纵向的位移。
int disX = mDownX - x;
int disY = mDownY - y;
Log.d(TAG, "handleUnDown: wyj disX:" + disX + " disY:" + disY + " mScaleTouchSlop:" + mScaleTouchSlop);
// 8 如果横向位移大于可引起滚动的偏移量,并且横向的位移大于纵向的位移,则返回false。
// swipe
if (Math.abs(disX) > mScaleTouchSlop && Math.abs(disX) > Math.abs(disY)) {
Log.d(TAG, "handleUnDown: wyj > scaleTouchSlop and return false");
return false;
}
// 9 如果纵向的位移小于可引起滚动的偏移量,并且横向的位移也小于可引起滚动的偏移量,则方法返回false。
// click
if (Math.abs(disY) < mScaleTouchSlop && Math.abs(disX) < mScaleTouchSlop) {
Log.d(TAG, "handleUnDown: wyj < scaleTouchSlop and return false");
return false;
}
Log.d(TAG, "handleUnDown: wyj return defaultValue:" + defaultValue);
return defaultValue;
}
SwipeMenuLayout.java
public boolean onInterceptTouchEvent(MotionEvent ev) {
boolean isIntercepted = super.onInterceptTouchEvent(ev);
if (!isSwipeEnable()) {
return isIntercepted;
}
int action = ev.getAction();
switch (action) {
case MotionEvent.ACTION_DOWN: {
// 1 Down事件记录第一个触点的坐标(mDownX, mDownY),方法的返回值为false,即不拦截
// 将Down事件传递给childView(id为swipe_content的FrameLayout)。默认情况下
// swipe_content的view是会处理Down事件。
mDownX = mLastX = (int)ev.getX();
mDownY = (int)ev.getY();
Log.d(TAG, "onInterceptTouchEvent: wyj down result false.");
return false;
}
case MotionEvent.ACTION_MOVE: {
// 2 记录这次MotionEvent相对于上一次MotionEvent的横向和纵向的位移。
// 如果横向的位移大于可引起滚动的偏移量touchSlop,并且横向的位移大会纵向的位移,则方法的返回值为
// true,表明拦截该Move事件,但是由于mFirstTouchTarget != null,仍会调用
// childView(swipe_content)的dispatchTouchEvent方法,但是会将mFirstTouchTarget置为null。
// 由于parentView SwipeRecyclerView的mFirstTouchTarget != null,则下一个Move事件仍会传到
// SwipeMenuLayout中,由于mFirstTouchTarget = null,则会调用onTouchEvent方法。
int disX = (int)(ev.getX() - mDownX);
int disY = (int)(ev.getY() - mDownY);
Log.d(TAG, "onInterceptTouchEvent: wyj disX:" + disX + " disY:" + disY
+ " mScaledTouchSlop:" + mScaledTouchSlop);
boolean result = Math.abs(disX) > mScaledTouchSlop && Math.abs(disX) > Math.abs(disY);
Log.d(TAG, "onInterceptTouchEvent: wyj move result:" + result);
return result;
}
case MotionEvent.ACTION_UP: {
boolean isClick = mSwipeCurrentHorizontal != null &&
mSwipeCurrentHorizontal.isClickOnContentView(getWidth(), ev.getX());
if (isMenuOpen() && isClick) {
smoothCloseMenu();
Log.d(TAG, "onInterceptTouchEvent: wyj up result true");
return true;
}
Log.d(TAG, "onInterceptTouchEvent: wyj up result false");
return false;
}
case MotionEvent.ACTION_CANCEL: {
if (!mScroller.isFinished()) mScroller.abortAnimation();
Log.d(TAG, "onInterceptTouchEvent: wyj cancel result false");
return false;
}
}
return isIntercepted;
}
public boolean onTouchEvent(MotionEvent ev) {
if (!isSwipeEnable()) {
return super.onTouchEvent(ev);
}
if (mVelocityTracker == null) mVelocityTracker = VelocityTracker.obtain();
mVelocityTracker.addMovement(ev);
int dx;
int dy;
int action = ev.getAction();
Log.d(TAG, "onTouchEvent: wyj ev.getAction:" + action);
switch (action) {
case MotionEvent.ACTION_DOWN: {
mLastX = (int)ev.getX();
mLastY = (int)ev.getY();
Log.d(TAG, "onTouchEvent: wyj down");
break;
}
case MotionEvent.ACTION_MOVE: {
// 3 这次MotionEvent事件onTouchEvent方法被调用说明了上次MotionEvent Move事件
// 被SwipeMenuLayout拦截且mFirstTouchTarget == null,
// 如果mDragging为false,即没有横向滚动(调用scrollBy方法)则mLastX是down事件对应的触点
// 坐标x,mLastY 为0。如果mDagging为true,即之前发生了滚动(调用了scrollBy)则
// mLastX、mLstY为上次MotionEvent的x、y坐标。
// 随着 dix 的值越来越大直到超过纵向的位移,并且超过了touchSlop则视为发起滚动的信号,
// mDragging记为true,且调用scollBy(dix, 0)。mLastX,mLastY重新赋值。调用scrollBy
// 对调用scollTo方法和computeScoll方法,会慢慢拉开隐藏的菜单,如果dx > 0 会拉开右边的菜单。
// 接下来的的move事件也是这样的处理,会使得scrollX越来越大。
int disX = (int)(mLastX - ev.getX());
int disY = (int)(mLastY - ev.getY());
Log.d(TAG, "onTouchEvent: wyj move");
Log.d(TAG, "onTouchEvent: wyj disX:" + disX + " disY:" + disY + " mScaledTouchSlop:" + mScaledTouchSlop);
if (!mDragging && Math.abs(disX) > mScaledTouchSlop && Math.abs(disX) > Math.abs(disY)) {
mDragging = true;
}
if (mDragging) {
if (mSwipeCurrentHorizontal == null || shouldResetSwipe) {
if (disX < 0) {
if (mSwipeLeftHorizontal != null) {
mSwipeCurrentHorizontal = mSwipeLeftHorizontal;
} else {
mSwipeCurrentHorizontal = mSwipeRightHorizontal;
}
} else {
if (mSwipeRightHorizontal != null) {
mSwipeCurrentHorizontal = mSwipeRightHorizontal;
} else {
mSwipeCurrentHorizontal = mSwipeLeftHorizontal;
}
}
}
Log.d(TAG, "onTouchEvent: wyj scrollBy");
scrollBy(disX, 0);
mLastX = (int)ev.getX();
mLastY = (int)ev.getY();
shouldResetSwipe = false;
}
break;
}
case MotionEvent.ACTION_UP: {
// 4 松手,通过VelocityTracker 获取当前横向的速度,假如是左滑,那么是< 0 的,
// 如果速率 > minimumFlingVelocity,则完全打开菜单。通过OverScroller#startScroll打开。
// 调用scroll执行动画,会调用computeScoll方法。
dx = (int)(mDownX - ev.getX());
dy = (int)(mDownY - ev.getY());
mDragging = false;
mVelocityTracker.computeCurrentVelocity(1000, mScaledMaximumFlingVelocity);
int velocityX = (int)mVelocityTracker.getXVelocity();
int velocity = Math.abs(velocityX);
if (velocity > mScaledMinimumFlingVelocity) {
if (mSwipeCurrentHorizontal != null) {
int duration = getSwipeDuration(ev, velocity);
if (mSwipeCurrentHorizontal instanceof RightHorizontal) {
if (velocityX < 0) {
// 5 velocityX < 0 ,左划,则打开菜单;否则右划,关闭菜单。
//
smoothOpenMenu(duration);
} else {
smoothCloseMenu(duration);
}
} else {
if (velocityX > 0) {
smoothOpenMenu(duration);
} else {
smoothCloseMenu(duration);
}
}
ViewCompat.postInvalidateOnAnimation(this);
}
} else {
judgeOpenClose(dx, dy);
}
mVelocityTracker.clear();
mVelocityTracker.recycle();
mVelocityTracker = null;
if (Math.abs(mDownX - ev.getX()) > mScaledTouchSlop ||
Math.abs(mDownY - ev.getY()) > mScaledTouchSlop || isLeftMenuOpen() || isRightMenuOpen()) {
ev.setAction(MotionEvent.ACTION_CANCEL);
super.onTouchEvent(ev);
return true;
}
Log.d(TAG, "onTouchEvent: wyj up");
break;
}
case MotionEvent.ACTION_CANCEL: {
mDragging = false;
if (!mScroller.isFinished()) {
mScroller.abortAnimation();
} else {
dx = (int)(mDownX - ev.getX());
dy = (int)(mDownY - ev.getY());
judgeOpenClose(dx, dy);
}
Log.d(TAG, "onTouchEvent: wyj cancel");
break;
}
}
boolean result = super.onTouchEvent(ev);
Log.d(TAG, "onTouchEvent: wyj result:" + result);
return result;
}
@Override
public void scrollTo(int x, int y) {
Log.d(TAG, "scrollTo: wyj x:" + x + " y:" + y);
if (mSwipeCurrentHorizontal == null) {
super.scrollTo(x, y);
} else {
Horizontal.Checker checker = mSwipeCurrentHorizontal.checkXY(x, y);
shouldResetSwipe = checker.shouldResetSwipe;
if (checker.x != getScrollX()) {
super.scrollTo(checker.x, checker.y);
}
}
}
public void computeScroll() {
// 6 判断是否执行了scroll的动画。在调用OverScroll#startScroll方法的时候,且动画未完成,则返回
// false。
boolean isOffset = mScroller.computeScrollOffset();
int currX = mScroller.getCurrX();
Log.d(TAG, "computeScroll: wyj isOffset:" + isOffset + " currX:" + currX);
if (isOffset && mSwipeCurrentHorizontal != null) {
if (mSwipeCurrentHorizontal instanceof RightHorizontal) {
scrollTo(Math.abs(mScroller.getCurrX()), 0);
invalidate();
} else {
scrollTo(-Math.abs(mScroller.getCurrX()), 0);
invalidate();
}
}
}
总结:
SwipeRecyclerView重写onInterceptTouchEvent方法,SwipeMenuLayout重写onInterceptTouchEvent、onTouchEvent、scrollTo、computeScroll方法。
(1) SwipeRecyclerView不拦截Down事件,将Down事件传递给SwipeMenuLayout,记录第一个触点的坐标(mDownX, mDownY)。
(2) SwipeMenuLayout不拦截Down事件,记录第一个触点的坐标(mDownX, mDownY)。并初始化mLastX = mDownX。
(3) SwipeRecyclerView处理Move事件;计算这次触点和第一个触点的位移,如果有横向的位移,则获取parentView并设置disallow_intercept_flag;如果横向的位移dx < touchSlop && dy < touchSlop,则将事件传递给SwipeMenuLaout;如果dx > touchSlop && dx > dy,将事件传递给SwipeMenuLayout;否则onInterceptTouchEvent方法的返回值取super.onInterceptTouchEvent方法的返回值。
(4) SwipeMenuLayout的onInterceptTouchEvent处理Move事件:计算这次触点和第一触点的横向和纵向的位移,如果dx > touchSlop && dx > dy,则方法的返回值为true,拦截这个事件;否则方法的返回值取super.onInterceptTouchEvent返回值。
(5) 如果上一次Move事件被SwipeMenuLayout拦截,则接下来的Move事件不会传递给child view,SwipeMenuLayout的onTouchEvent方法被调用。
(6) SwipeMenuLayout的onTouchEvent方法处理Move事件。将mLastX - ev.getX记为dx,将mLastY - ev.getY记为dy,由于mLastX = mDownX,mLastY第一次为0,则有可能dx的绝对值小于dy的绝对值,只有当dx的绝对值 > touchSlop且,dx的绝对值大于dy,才判定为发起拖动,意图打开菜单。为什么不在onInterceptTouchEvent的处理Down事件逻辑中不设置mLastY = mDownY,推断是这里不希望将打开菜单做的敏感,希望当划动轨迹与x轴的夹角比较小,或者划动轨迹的线段足够长才触发打开菜单,这里是体验上的优化。如果满足这两个场景,则判定为发起拖动意图打开菜单。字段mDraging = true,重置mLastX,mLastY,并调用scrollBy(dx, 0)。
(7) View的scrollBy,如果方法的dx > 0,则将View中的内容向左划,否则向右划。调用scrollBy之后,会调用scrollTo,computeScroll方法。
(8) 如果发起了滚动,在接下来的Move事件中,也会调用scrollBy,导致getScrollX越来越大,其绝对值逐渐接近menuView的宽度。
(9)SwipeMenuLayout的onTouchEvent方法处理UP事件。根据VelocityTracker获取当前横向的速度,如果左划,则velocityX < 0,如果右划,则velocity > 0,取横向速率和minimunFlingVelocity比较,如果横向速率大于minumumFlingVelocity,则打开菜单,通过OverScroller#startScoll执行滚动动画打开菜单。否则调用judgeOpenClose。
(10) SwipeMenuLayout处理Up事件的judgeOpenClose方法。如果当前横向滚动的偏移量scollX的绝对值 < 菜单宽度的1/2,则隐藏菜单,通过OverScoller#startScroll隐藏菜单。如果当前横向滚动的偏移量不小于菜单宽度的1/2,判断如果菜单时开的状态则关,如果菜单时关的状态则开。
(11) OverScroller#startScroll方法执行滚动动画,会触发View的computeScoll方法。