Android 仿 QQ 侧滑菜单

效果图

GIF图有点模糊,源码已上传Github:Android 仿 QQ 侧滑菜单

https://github.com/crazyqiang/AndroidStudy/blob/master/app/src/main/java/org/ninetripods/mq/study/recycle/SwipeMenuActivity.java

整体思路

自定义 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吧,感谢~

https://github.com/crazyqiang/AndroidStudy/blob/master/app/src/main/java/org/ninetripods/mq/study/recycle/SwipeMenuActivity.java

与之相关

Android之高仿QQ6.6.0侧滑效果(背景动画、透明+沉浸式状态栏、渐变效果)

Android仿QQ侧滑功能

关键词:code小生

(0)

相关推荐