说明
QQ 和 IOS 的应用都有一种功能,那就是左滑弹出删除选项。如下图:
未左滑时 QQ 会员的显示样式:
左滑后 QQ 会员的显示样式:
IOS 系统存在这种效果。这个功能在 Android 系统中默认是没有的,需要我们自定义 View 才能实现。本篇博文讲讲如何使用 RecyclerView 实现 QQ 的侧滑效果。
讲逻辑之前,先假定一些内容。
- 默认情况下,RecyclerView 显示的内容,称为内容布局(content)
- 侧滑后出现的布局,称为菜单布局(menu),菜单布局中的每个功能项,称为菜单项(menu item)
QQ 的效果
定义了上面的内容,就可以描述 QQ 的侧滑功能了。经过本人实测,QQ 的侧滑功能包含如下:
- 显示内容布局时:
- 可点击,点击时进入对应的 item
- 可侧滑,侧滑时弹出对应的 item
- 最大侧滑距离为菜单宽度
- 如果侧滑距离不足阙值,则不显示菜单栏(滚动隐藏)
- 如果侧滑距离大于阙值,但不足最大菜单宽度,则显示菜单栏(滚动以显示剩下未显示的内容)
- 可长按,长按执行对应的操作
- 长按、侧滑动作相互排斥
- 显示菜单布局时:
- 可点击,点击隐藏已显示的菜单布局
- 可侧滑,只有按住当前显示菜单栏的位置(Position),才能侧滑,否则按下时就隐藏菜单栏,并且不响应后续事件
- 侧滑和竖向滑动,一次点击事件流中,只响应一个方向。
明确了 QQ 具有的交互之后,就可以考虑如何实现侧滑了。如果使用 RecyclerView,则有以下两种方式实现侧滑,主要是从解决滑动冲突的角度考虑,分为外部拦截和内部拦截两种模式:
- 使用 RecyclerView + OnItemTouchHelper
- 自定义 RecyclerView
本文使用的是第 2 种方式(外部拦截),虽然第 1 种更简单,但是第 2 种涉及到的知识更多,实现侧滑功能后能掌握更多的知识。有助于学习。
知识预热
Scroll 与 Fling
使用过 ScrollView 的小伙伴应该知道,ScrollView 有两种滚动方式:手指按下时的拖动和手指松开时的自动滚动。在 Android 中,手指按下时的拖动被称为 Scroll,手指松开时的自动滚动被称为 Fling。
在了解了 Scroll 和 Fling 的概念后,我们就要思考两者的触发条件和处理方式了。想不通?没关系。我们知道 ScrollView 有这两个功能,看看 ScrollView 的源码。
在看 ScrollView 的源码之前,我们需要了解事件的分发机制。因为手指的拖动本质上是事件分发和 MotionEvent 的处理。
Android 的事件分发主要有三个方法:dispatchTouchEvent/onInterceptTouchEvent/onTouchEvent。三者对应的顺序是 分发/拦截/处理。对于 ScrollView,我们看下 onTouchEvent 方法。
在 ScrollView 源码中搜索 Scroll,发现下面两个变量。见名知意,就是用于 Scroll 和 Fling 的。
搜索两个变量的使用位置,很轻松的定位到了 onTouchEvent 方法。分析 onTouchEvent 方法,找到了很多有用的知识:
从上面的代码中,我们得到了最重要的知识点便是:
- Scroll 动作是在 Move 事件中触发的,在 Up 事件(Cancel 事件可选)中结束的
- Fling 动作是在 Up 事件中触发的,并且需要搭配速度追踪器
- Scroll 和 Fling 是互斥的两个动作
- 计算 Scroll 和 Fling 的过程中,不能忘了多指事件
- Down 事件来临时,应结束上一次未完成的滚动
- 使用 Scroller + VelocityTracker 来完成 Fling 动作,使用 Scroller 可以避免无动画直接闪现到目标位置的问题。使用 View 的 scrollTo 方法,完成 Scroll 动作。关于这两个使用说明,官方也提供了一个例子,以及 Scroller 的一个固定使用模版。有兴趣的可以谷歌一下。
scrollX 和 scrollY
在了解了 Scroll 和 Fling 动作的触发时机后,我们还需要了解下 Scroll 时需要用到的变量信息 scrollX,scrollY 的含义。
对于 scrollX,scrollY,我们得明确这两个的意思,是滚动的距离,而不是滑动的距离。滚动的英语是 Scroll,而滑动的英语是 Slide。我们可以把滑动看作是 View 本身的移动(平移),在 View 中用 translate 表示。而滚动则是 View 的内容的移动,此时 View 本身是没有移动的。明确了这两点,我们就能明白 scrollX,scrollY 的含义了。
Scroller 与 OverScroller 与 VelocityTracker
在 Android 中,Fling 动作是一个比较特殊的动作。原生的 Fling 是一个闪现到目标位置的动作,并没有我们通常见到的那种自动滚动的丝滑感。所以 Google 提供了一个工具类:Scroller,可别被它的名字误导了。实际上,它的名称叫做 ScrollerUtil 更为贴切。Scroller 自身并不具备滚动能力。实际上,Scroller 的使用是有一个固定的模版代码的。
public class TestView extends View {
Scroller scroller;
public TestView(Context context) {
super(context);
scroller = new Scroller(getContext());
}
@Override
public void computeScroll() {
if (scroller.computeScrollOffset()) {
// 实际是调用 View 的 scrollTo/scrollBy 方法进行滚动
scrollTo(scroller.getCurrX(), scroller.getCurrY());
// View 的 scroll 并不会触发重绘,需要手动触发
postInvalidate();
}
}
// 缓慢滚动到指定位置
private void smoothScrollTo(int destX, int destY) {
int scrollX = getScrollX();
int delta = destX - scrollX;
// 1000ms 内滚向 destX
scroller.startScroll(scrollX, 0, delta, 0, 1000);
invalidate();
}
}
Scroller 与 OverScroller 的区别就是是否支持过度滚动。过度滚动是指实际滚动距离会比 View 可滚动距离多出来一截,当 View 滚动到多出来的这一截时,可回弹,呈现出一个滚动回弹的视觉效果。
VelocityTracker,顾名思义,是 Google 提供的一个计算手指滑动速度的工具类。通常我们实现 Fling 效果时,都需要使用这个类,VelocityTracker 通常搭配 Scroller 使用,使用方法可以参考 ScrollView,也是一个固定的模版代码。
public class ScrollView extends FrameLayout {
private VelocityTracker mVelocityTracker;
public boolean onInterceptTouchEvent(MotionEvent ev) {
switch (action & MotionEvent.ACTION_MASK) {
case MotionEvent.ACTION_DOWN: {
if (mVelocityTracker == null) {
mVelocityTracker = VelocityTracker.obtain();
} else {
mVelocityTracker.clear();
}
// 开始追踪
mVelocityTracker.addMovement(ev);
break;
}
case MotionEvent.ACTION_MOVE: {
mVelocityTracker.addMovement(ev);
break;
}
case MotionEvent.ACTION_POINTER_UP: {
onSecondaryPointerUp(ev);
break;
}
}
// 如果在 Scroll,则拦截事件
return mIsBeingDragged;
}
public boolean onTouchEvent(MotionEvent ev) {
if (mVelocityTracker == null) {
mVelocityTracker = VelocityTracker.obtain();
}
switch (ev.getActionMasked()) {
case MotionEvent.ACTION_MOVE: {
// 如果成功滚动,则不需要跟踪速度了
// 速度追踪用于 Fling,Scroll 用不上
if(scrollSuccess()) {
mVelocityTracker.clear();
}
break;
}
case MotionEvent.ACTION_UP: {
// 抬手时计算速度,用于 Fling 动作
mVelocityTracker.computeCurrentVelocity(1000, mMaximumVelocity);
int initialVelocity = (int) velocityTracker
.getYVelocity(mActivePointerId);
// 大于速度阙值,才触发 Fling 动作
if ((Math.abs(initialVelocity) > mMinimumVelocity)) {
fling(-initialVelocity);
// 结束 Scroll 动作
endDrag();
}
break;
}
case MotionEvent.ACTION_POINTER_UP: {
// 多指事件的抬起动作
/*
* 如果抬起的是当前生效的手指,则需要结束速度追踪
* 即用于计算速度的手指改变了
*/
if (pointerId == mActivePointerId) {
if (mVelocityTracker != null) {
mVelocityTracker.clear();
}
}
break;
}
}
if (mVelocityTracker != null) {
mVelocityTracker.addMovement(ev);
}
return true;
}
}
上面的代码参考自 ScrollView,部分逻辑需要说明下:
- VelocityTracker 在 Down 事件来临时需要重置状态,在 Up 事件时计算速度(Fling 动作在 Up 时触发,需要用到速度)
- computeCurrentVelocity 方法传入 1000 的作用是计算每秒的速度(比如生活中常见的 米/秒,就是一秒走了多少米)。1000 的单位是毫秒,通常我们传入 1000 即可
- VelocityTracker 需要追踪完整的事件流(Donw ---> Move ---> Up),多指事件也要考虑在内。Down 时开始追踪事件,Up 时计算速度,并提供给 Scroller 使用
- Fling 的触发是有速度阙值的。速度阙值的获取下面讲解。
滑动中用到的阙值
我们知道,Android 系统是十分灵敏的,可能会在我们意想不到的时间点触发一些动作,比如 Move 事件。但是我们并不想在这种时候触发对应的动作。此时就需要给相关动作设置一个阙值,说是阙值,其实就是可接受的误差范围。在 Android 中,默认就定义了一些阙值,它们都存储在 ViewConfiguration 类中。在本文中,我们可能用到的阙值如下:
// 下面只列举了部分,非全部
public class ViewConfiguration {
/**
* 长按事件的时间阙值,Up 和 Down 的时间间隔小于此值,被视为点击,否则是长按
*/
private static final int DEFAULT_LONG_PRESS_TIMEOUT = 500;
/**
* Scroll 动作的距离阙值,Move 距离大于此阙值,才算是触发了 Scroll
*/
private static final int TOUCH_SLOP = 8;
/**
* 触发 Fling 动作的最小速度,小于此速度,则不触发 Fling 动作
*/
private static final int MINIMUM_FLING_VELOCITY = 50;
/**
* Fling 动作支持的最大速度
*/
private static final int MAXIMUM_FLING_VELOCITY = 8000;
}
// 获取阙值的方法如下
private void test(Context context) {
// 获取长按阙值
ViewConfiguration.getLongPressTimeout();
// 获取 Scroll 的距离阙值
ViewConfiguration.get(context).getScaledTouchSlop();
// 获取触发 Fling 的最小速度
ViewConfiguration.get(context).getScaledMinimumFlingVelocity();
// 获取 Fling 支持的最大速度
ViewConfiguration.get(context).getScaledMaximumFlingVelocity();
}
事件拦截过程
本文中,对于事件的拦截机制,我们需要明白一点:在一次事件流中,ViewGroup 只会触发一次拦截动作,一旦拦截成功,后续便不会重复触发拦截。直到下一次事件流开始。即在 onInterceptTouchEvent 方法的返回值为 false 时,onInterceptTouchEvent 方法会在每个事件中都调用一下。如果 onInterceptTouchEvent 方法的返回值为 true,则事件会被当前 View 拦截,不会向下分发,并且后续事件来临时,onInterceptTouchEvent 不会再被调用。
附事件分发机制的一些结论。结论选自《Android 开发艺术探索》。根据这些结论可以更好地理解整个传递机制,如下所示:
- 同一个事件序列是指从手指接触屏幕的那一刻起,到手指离开屏幕的那一刻结束,在这个过程中所产生的一系列事件,这个事件序列以 down 事件开始,中间含有数量不定的 move 事件,最终以 up 事件结束。
- 正常情况下,一个事件序列只能被一个 View 拦截且消耗。这一条的原因可以参考 3,因为一旦一个元素拦截了某此事件,那么同一个事件序列内的所有事件都会直接交给它处理,因此同一个事件序列中的事件不能分别由两个 View 同时处理,但是通过特殊手段可以做到,比如一个 View 将本该自己处理的事件通过 onTouchEvent 强行传递给其他 View 处理。
- 某个 View 一旦决定拦截,那么这一个事件序列都只能由它来处理(如果事件序列能够传递给它的话),并且它的 onInterceptTouchEvent 不会再被调用。这条也很好理解,就是说当一个 View 决定拦截一个事件后,那么系统会把同一个事件序列内的其他方法都直接交给它来处理,因此就不用再调用这个 View 的 onInterceptTouchEvent 去询问它是否要拦截了。
- 某个 View 一旦开始处理事件,如果它不消耗 ACTION_DOWN 事件(onTouchEvent 返回了 false),那么同一事件序列中的其他事件都不会再交给它来处理,并且事件将重新交由它的父元素去处理,即父元素的 onTouchEvent 会被调用。意思就是事件一旦交给一个 View 处理,那么它就必须消耗掉,否则同一事件序列中剩下的事件就不再交给它来处理了,这就好比上级交给程序员一件事,如果这件事没有处理好,短期内上级就不敢再把事情交给这个程序员做了,二者是类似的道理。
- 如果 View 不消耗除 ACTION_DOWN 以外的其他事件,那么这个点击事件会消失,此时父元素的 onTouchEvent 并不会被调用,并且当前 View 可以持续收到后续的事件,最终这些消失的点击事件会传递给 Activity 处理。
- ViewGroup 默认不拦截任何事件。Android 源码中 ViewGroup 的 onInterceptTouchEvent 方法默认返回 false。
- View 没有 onInterceptTouchEvent 方法,一旦有点击事件传递给它,那么它的 onTouchEvent 方法就会被调用。
- View 的 onTouchEvent 默认都会消耗事件(返回 true),除非它是不可点击的(clickable 和 longClickable 同时为 false)。View 的 longClickable 属性默认都为 false, clickable 属性要分情况,比如 Button 的 clickable 属性默认为 true,而 TextView 的 clickable 属性默认为 false。
- View 的 enable 属性不影响 onTouchEvent 的默认返回值。哪怕一个 View 是 disable 状态的,只要它的 clickable 或者 longClickable 有一个为 true,那么它的 onTouchEvent 就返回 true。
- onClick 会发生的前提是当前 View 是可点击的,并且它收到了 down 和 up 的事件。
- 事件传递过程是由外向内的,即事件总是先传递给父元素,然后再由父元素分发给子 View,通过 requestDisallowInterceptTouchEvent 方法可以在子元素中干预父元素的事件分发过程,但是ACTION_DOWN 事件除外。
多指事件的处理
多指事件,需要考虑 Index 和 id 的区别,就不细讲了。具体实现可以考虑 ScrollView 中 Google 的官方实现,ScrollView 中的实现可以看作一个模版代码:
public class ScrollView extends FrameLayout {
/**
* 在多指事件中,生效的手指的 id,Scroll/Fling 只需要用到一个手指
*/
private int mActivePointerId = MotionEvent.INVALID_POINTER_ID;
public boolean onInterceptTouchEvent(MotionEvent ev) {
switch (ev.getActionMasked()) {
case MotionEvent.ACTION_DOWN: {
// Down 来临时,取第一个手指
mActivePointerId = ev.getPointerId(0);
break;
}
case MotionEvent.ACTION_MOVE: {
// 拿取 X、Y 的固定模版
int pointerIndex = ev.findPointerIndex(mActivePointerId);
if (pointerIndex == MotionEvent.INVALID_POINTER_ID) {
break;
}
int y = (int) ev.getY(pointerIndex);
break;
}
case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_UP:
// Up/Cancel 取消生效的手指
mActivePointerId = MotionEvent.INVALID_POINTER_ID;
break;
}
// ......
}
public boolean onTouchEvent(MotionEvent ev) {
switch (ev.getActionMasked()) {
case MotionEvent.ACTION_DOWN: {
// 拦截方法可能不会执行,所以要在 onTouchEvent 中重复一次
mActivePointerId = ev.getPointerId(0);
break;
}
case MotionEvent.ACTION_MOVE: {
int activePointerIndex = ev.findPointerIndex(mActivePointerId);
if (activePointerIndex == MotionEvent.INVALID_POINTER_ID) {
break;
}
int y = (int) ev.getY(activePointerIndex);
break;
}
case MotionEvent.ACTION_POINTER_DOWN: {
int index = ev.getActionIndex();
mLastMotionY = (int) ev.getY(index);
mActivePointerId = ev.getPointerId(index);
break;
}
case MotionEvent.ACTION_POINTER_UP: {
int pointerIndex = event.getActionIndex();
int pointerId = ev.getPointerId(pointerIndex);
if (pointerId == mActivePointerId) {
// index总是连续的,比如 0、1、2 三根手指,1 抬起了,此时 2 会变为 1,但 id 不变
int newPointerIndex = pointerIndex == 0 ? 1 : 0;
mLastMotionY = (int) ev.getY(newPointerIndex);
mActivePointerId = ev.getPointerId(newPointerIndex);
}
mLastMotionY = (int) ev.getY(ev.findPointerIndex(mActivePointerId));
break;
}
case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_UP:
// Up/Cancel 取消生效的手指
mActivePointerId = MotionEvent.INVALID_POINTER_ID;
break;
}
// ......
}
}
对上述代码的一些说明:
- 多指事件的判断横跨了多个事件类型,包括 Down、Move、Up、Cancel、Pointer Down、Pointer Up。
- 需要分清手指的 id 和 index 的区别。id 可以认为是一个手指的唯一标识,在一个事件流中是唯一的。而 index 则不然。
- 想要通过 index 找 id,可以使用 MotionEvent.getPointerId 方法
- 想要通过 id 找 index,可以使用 MotionEvent.findPointerIndex 方法
- 使用 MotionEvent.getActionIndex 可以找到事件对应的手指的 index
- 想要找到手指 id、x、y 等信息,都需要用到 pointerIndex
- 生效的 pointerId 默认是 index 为 0 的手指的 id,在 Down 事件中赋值,在 Up、Cancel 事件或者其他特定条件下重置。pointerDown 事件中,生效的 pointerId 会变更为 按下的手指的 id。在 pointerUp 事件中,会判断松开的手指是否是当前生效的手指。如果是,则会重新寻找一个可用的手指 id
一些基础的知识就了解这么多,下面开始造轮子。
实现过程
确定 item 位置
既然决定使用外部拦截的方式,那么就需要思考一个,如何确定当前点击的是哪个 item?既如何通过 MotionEvent 确定 item。幸运的是 RecyclerView(后面简称 RV) 已经提供了方法,如下:
private ViewHolder findViewHolder(MotionEvent event) {
// findChildViewUnder 是 RV 中的方法
View view = findChildViewUnder(event.getX(), event.getY());
if(view == null) {
return null;
}
// findContainingViewHolder 是 RV 中的方法
return findContainingViewHolder(view);
}
其具体思路就是通过 X/Y 坐标确定当前点击的 View,再通过 View 确定 ViewHolder,确定了 ViewHolder 后,我们就能确定点击的 position 等信息了。
定义侧滑菜单布局
在实际的实现过程中,为了简化难度(满足需求的情况下),特做了如下处理:
- RV 的 ViewHolder 的布局,采用 LinearLayout 作为 Root ViewGroup(后面简称根 LL),并将方向设置为 Horizontal
- 菜单布局的宽度尺寸必须确定,即测量模式必须是 EXACTLY。因为测量模式如果不是 EXACTLY,而是 AT_MOST 的话,那么在内容布局 Match_Parent 的情况下,根 LL 不会测量菜单布局的宽度。导致菜单布局的宽度为 0。不管菜单布局的子 item 声明的是怎样的尺寸,结果都一样
- 为了保证菜单布局中的选项可受开关动态控制,特将菜单布局的获取改为全 Java 代码获取。并且菜单布局的方向是 Horizontal。但是因为创建布局时,无法获取到 bean 类,所以将此菜单选项显示与否的判断移至数据绑定阶段(onBindViewHolder),而不是在视图创建阶段(onCreateViewHolder)判断
- 内容布局、菜单布局、菜单选项等的 id 固定
下面的代码用于获取菜单布局,并将菜单布局加入 根 LL。菜单包含了两个选项:关注和删除。
public class SwipeAdapter extends RecyclerView.Adapter<SwipeViewHolder> {
@Override
public SwipeViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
return new SwipeViewHolder(parent.getContext(), getItemView(parent, viewType));
}
private View getItemView(ViewGroup parent, int viewType) {
LayoutInflater inflater = LayoutInflater.from(parent.getContext());
ViewGroup rootView = (ViewGroup) inflater.inflate(R.layout.root, parent, false);
// 获取菜单选项的布局
List<View> itemList = buildMenuItemList(parent.getContext(), viewType);
if(ListUtil.isEmpty(itemList)) {
return rootView;
}
int width = 0;
// 获取菜单布局的宽度
View view;
for(int i = 0; i < itemList.size(); i++) {
view = itemList.get(i);
if(view == null) {
continue;
}
width += view.getLayoutParams().width;
}
// 构建菜单布局
LinearLayout menuLayout = new LinearLayout(parent.getContext());
menuLayout.setId(MenuFuncId.MENU);
menuLayout.setOrientation(LinearLayout.HORIZONTAL);
// 菜单布局的宽度固定为 width
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
width, LinearLayout.LayoutParams.MATCH_PARENT);
menuLayout.setLayoutParams(params);
// 添加菜单布局 item 到菜单布局中
for(int i = 0; i < itemList.size(); i++) {
view = itemList.get(i);
if(view == null) {
continue;
}
menuLayout.addView(view);
}
// 将菜单布局添加到根布局中
rootView.addView(menuLayout);
return rootView;
}
private List<View> buildMenuItemList(Context context, int viewType) {
List<View> viewList = new Vector<>();
// 菜单选项的宽度固定,为 82 dp
// dp 转 px 的方法就不带了,网上一大堆
// 关注选项
TextView tvFollow = new TextView(context);
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
dpToPx(context, 82)), LinearLayout.LayoutParams.MATCH_PARENT);
tvFollow.setLayoutParams(params);
tvFollow.setId(MenuFuncId.FOLLOW);
tvFollow.setText("关注");
tvFollow.setTextSize(17);
tvFollow.setTextColor(Color.WHITE);
tvFollow.setGravity(Gravity.CENTER);
tvFollow.setBackgroundColor(Color.BLUE);
// 删除选项
TextView tvRemove = new TextView(context);
params = new LinearLayout.LayoutParams(
dpTopx(context, 82), LinearLayout.LayoutParams.MATCH_PARENT);
tvRemove.setLayoutParams(params);
tvRemove.setId(MenuFuncId.REMOVE);
tvRemove.setText("删除");
tvRemove.setTextSize(17);
tvRemove.setTextColor(Color.WHITE);
tvRemove.setGravity(Gravity.CENTER);
tvRemove.setBackgroundColor(Color.RED);
viewList.add(tvFollow);
viewList.add(tvRemove);
return viewList;
}
}
R.layout.root 的布局样式如下,就是一个水平方向的 LL:
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="73dp"
android:orientation="horizontal">
<!-- 内容布局 -->
<include layout="@layout/content"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</LinearLayout>
1. 实现 Scroll 动作
因为是仿 QQ 的交互,所以菜单的侧滑是水平侧滑。实现水平滚动 Scroll 动作的核心实现逻辑如下:
public class TestView extends View {
/**
* Move 事件(上次事件)的坐标点,用于 Scroll 动作
*/
private float lastX;
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getActionMasked()) {
case MotionEvent.ACTION_DOWN: {
// Down 事件记录初始坐标
pointerIndex = event.getActionIndex();
lastX = event.getX(pointerIndex);
break;
}
case MotionEvent.ACTION_MOVE: {
// Scroll 的核心逻辑
// Move 时不停计算差值,并调用滚动的相关方法
float x = event.getX(pointerIndex);
int offsetX = (int) (lastX - x);
scrollBy(offsetX, 0);
// Scroll 不会自动刷新,需要手动刷新
invalidate();
lastX = x;
break;
}
}
return super.onTouchEvent(event);
}
}
再配合上上面讲到的多指事件的处理模版,便可以输出第一版可 Scroll 的代码了。
第一版的交互逻辑为:手指移动时,item 可以跟着滚动,此时不考虑点击事件什么的。此时的代码逻辑为:
- 在拦截事件的方法 onInterceptTouchEvent 中,全部拦截事件
- 在 onTouchEvent 中处理 Scroll 动作。Down 时记录坐标、布局、手指等信息,Move 计算与上次事件的坐标的差值,此差值为需要滚动的距离。在 PointerDown 和 PointerUp 中变更手指的信息。在 Up/Cancel 中重置手指信息。
public class SwipeRecyclerView extends RecyclerView {
/**
* 当前点击的 ViewHolder
* */
private ViewHolder curHolder;
/**
* Move 事件(上次事件)的坐标点,用于 Scroll 动作
*/
private float lastX;
/**
* 在多指事件中,生效的手指的 id,Scroll/Fling 只需要用到一个手指
*/
private int mActivePointerId;
// 构造函数省略
@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
return true;
}
@Override
public boolean onTouchEvent(MotionEvent event) {
int pointerIndex = event.getActionIndex();
switch (event.getActionMasked()) {
case MotionEvent.ACTION_DOWN:
case MotionEvent.ACTION_POINTER_DOWN: {
// 重置坐标,手指id,ViewHolder 等变量
mActivePointerId = event.getPointerId(pointerIndex);
lastX = event.getX(pointerIndex);
curHolder = findContainingViewHolder(findChildViewUnder(
event.getX(pointerIndex), event.getY(pointerIndex)));
break;
}
case MotionEvent.ACTION_MOVE: {
// 处理滚动事件
int tempPointerIndex = event.findPointerIndex(mActivePointerId);
float value = event.getX(tempPointerIndex);
int deltaX = (int)(lastX - value);
curHolder.itemView.scrollBy(deltaX, 0);
invalidate();
lastX = value;
break;
}
case MotionEvent.ACTION_POINTER_UP: {
int id = event.getPointerId(pointerIndex);
if(id == mActivePointerId) {
// index 总是连续的,比如 index 为 0、1、2 的三根手指。
// 1 抬起了,此时 2 手指的 index 会变为 1,但 id 不变
int newIndex = pointerIndex == 0 ? 1 : 0;
mActivePointerId = event.getPointerId(newIndex);
}
break;
}
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL: {
mActivePointerId = MotionEvent.INVALID_POINTER_ID;
break;
}
}
return true;
}
}
2. 为 Scroll 动作添加边界值
上面的代码中,Scroll 动作是无拘无束的,没有限制。但是 QQ 的交互中,实际上是有最大和最小的滚动距离。那么我们也加入临界值的判断。
首先,我们需要确定可滚动的最大距离。从上文菜单布局的构建中,我们知道,菜单布局的宽度是固定的那么只需要拿到菜单布局即可。然后,我们在 SwipeRecyclerView 增加一个变量,用以表示可滚动的最大距离。并且在 Down 事件中更新,因为不同的 item,菜单布局宽度可能不同。代码逻辑如下:
public class SwipeRecyclerView extends RecyclerView {
// 其他的变量省略
/**
* 菜单栏的最大显示宽度,也是每个 item 可滚动的最大宽度
* */
private int maxScrollDistance;
@Override
public boolean onTouchEvent(MotionEvent event) {
int pointerIndex = event.getActionIndex();
switch (event.getActionMasked()) {
case MotionEvent.ACTION_DOWN:
case MotionEvent.ACTION_POINTER_DOWN: {
// 其他代码省略
maxScrollDistance = calculateMaxScrollDistance(curHolder);
break;
}
case MotionEvent.ACTION_MOVE: {
// 处理滚动事件
int tempPointerIndex = event.findPointerIndex(mActivePointerId);
float value = event.getX(tempPointerIndex);
int deltaX = (int)(lastX - value);
// 限制滚动距离
// 手指从右向左移动,左移到达最大值
if(deltaX > 0 && curHolder.itemView.getScrollX()
+ deltaX > maxScrollDistance) {
deltaX = maxScrollDistance - curHolder.itemView.getScrollX();
}
// 手指从左向右移动,右移到达最小值
if(deltaX < 0 && curHolder.itemView.getScrollX() + deltaX < 0) {
deltaX = -holder.itemView.getScrollX();
}
curHolder.itemView.scrollBy(deltaX, 0);
invalidate();
lastX = value;
break;
}
// 其他代码省略
}
return true;
}
/**
* -1 代表不可滚动
* */
private int calculateMaxScrollDistance(ViewHolder viewHolder) {
if(viewHolder == null) {
return -1;
}
ViewGroup rootViewGroup = viewHolder.itemView.findViewById(MenuFuncId.MENU);
if(rootViewGroup == null || rootViewGroup.getChildCount() < 0) {
return -1;
}
int result = 0;
// 根布局是水平方向的 LL,直接从左向右加 item 的宽度
for(int i = 0; i < rootViewGroup.getChildCount(); i++) {
View view = rootViewGroup.getChildAt(i);
// 子布局为 Gone 时,不计入宽度
if(view == null || view.getVisibility() == View.GONE) {
continue;
}
result += view.getMeasuredWidth();
}
return result;
}
}
3. 处理 Fling 动作?
在限制了最大滚动距离之后,我们来考虑下 Fling 动作,Fling 是松手后的自动滚动。我们就暂时称为自动滚动吧。传统的 Fling 动作,会结合 VelocityTracker,来模拟惯性移动。但是使用 QQ 的侧滑,并没有感觉到明显的惯性移动过程,松开手指时,即使没有速度,菜单也会自动滚动。本文也做类似处理。即本文设计的 Fling 动作,不是真正的 Fling 动作,而是使用 Scroll 模拟的动作。并没有速度的概念,不会用到 VelocityTracker。
QQ 的 Fling 动作的触发时机是菜单布局滚动的距离超过了阙值,这里也模仿这种处理。取距离阙值为菜单宽度的 1/3。
为了方便理解,将 Scroll 和 Fling 的处理抽取成单独的方法:
/**
* Scroll 动作是在 Move 事件中触发的
*
* @return true 表示需要滚动,false 不需要滚动
* */
private boolean dealScroll(ViewHolder holder, MotionEvent event) {
if (holder == null) {
return false;
}
if(maxScrollDistance <= 0) {
return false;
}
int pointerIndex = event.findPointerIndex(mActivePointerId);
float x = event.getX(pointerIndex);
int offsetX = (int) (lastX - x);
// 限制滚动范围
// 手指从右向左移动,布局也从右向左移动,左移到达最大值
if(offsetX > 0 && holder.itemView.getScrollX() + offsetX > maxScrollDistance) {
offsetX = maxScrollDistance - holder.itemView.getScrollX();
}
// 手指从左向右移动,布局也从左向右移动,右移到达最大值
if(offsetX < 0 && holder.itemView.getScrollX() + offsetX < 0) {
offsetX = -holder.itemView.getScrollX();
}
// 竖直方向不移动
holder.itemView.scrollBy(offsetX, 0);
invalidate();
lastX = x;
return true;
}
/**
* 是否判定为水平方向的 Fling,如果是,则执行 Fling 动作
*
* Fling 动作是在 Up 事件中触发的
* */
private boolean dealFling(ViewHolder holder, MotionEvent event) {
float nowX = event.getX();
// downX 只会在 Down 和 pointerDown 事件中更改
float offsetX = downX - nowX;
if(holder == null || holder.itemView.getScrollX() <= 0) {
return false;
}
// 没有可滚动的距离
if(maxScrollDistance < 0) {
return false;
}
int dx;
// 菜单栏显示
// 拖动距离不足 1/3 时,已滚动距离不生效,回弹
if(Math.abs(offsetX) < (float) maxScrollDistance / 3) {
if(offsetX > 0) {
dx = -holder.itemView.getScrollX();
} else {
dx = maxScrollDistance - holder.itemView.getScrollX();
}
} else {
if(offsetX > 0) {
dx = maxScrollDistance - holder.itemView.getScrollX();
} else {
dx = -holder.itemView.getScrollX();
}
}
// 滚动到目标位置
mScroller.startScroll(holder.itemView.getScrollX(), 0, dx, 0);
invalidate();
return true;
}
4. 水平滚动/竖直滚动 2 选 1
在 QQ 中,竖直滚动和水平滚动是互斥的,二者只能同时生效一个。本文也需要实现这种效果。
首先我们需要思考一个问题,水平方向和竖直方向上的滚动是哪里来的?水平方向很好理解,是我们自定义的。但是竖直方向呢?很明显,就是父类的实现了。所以,对于滚动的生效方向的判断与选择,可以用下面的代码实现。
public class SwipeRecyclerView extends RecyclerView {
/**
* 一次事件流中,是水平滚动,还是竖直滚动的标识
* */
private boolean isVerScroll;
/**
* Down 事件的坐标,这两个变量的值,只会在 Down 和 PointerDown 中改变
* */
private float downX, downY;
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getActionMasked()) {
case MotionEvent.ACTION_DOWN: {
downX = event.getX(pointerIndex);
downY = event.getY(pointerIndex);
// 避免自动 Scroll 的影响
isVerScroll = false;
break;
}
case MotionEvent.ACTION_MOVE: {
// 状态只检查一次
if(!isVerScroll) {
checkVerScrollState(event);
}
// 触发了竖直滚动,直接调用父类的实现
if(isVerScroll) {
return super.onTouchEvent(event);
}
// 水平滚动
dealScroll(curHolder, event);
}
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL: {
if(!isVerScroll) {
checkVerScrollState(event);
}
// 触发了竖直滚动,直接调用父类的实现
if(isVerScroll) {
return super.onTouchEvent(event);
}
dealFling(curHolder, event);
}
}
return true;
}
private void checkVerScrollState(MotionEvent event) {
if(isVerScroll) {
return;
}
int pointerIndex = event.findPointerIndex(mActivePointerId);
float x = event.getX(pointerIndex);
float y = event.getY(pointerIndex);
// 检测竖直方向的滑动,如果滑动距离大于阙值,则竖直滑动生效
if(!isVerScroll && Math.abs(x - downX) < Math.abs(y - downY)
&& Math.abs(y - downY) > scrollSlop) {
isVerScroll = true;
}
}
}
上面的流程比较简单,就是在需要 Scroll 或者 Fling 时,判断竖直方向的滚动是否生效,如果生效,则不执行 Scroll 或者 Fling 动作,这两个动作是自定义的逻辑;转而使用父类的逻辑,父类是 RecyclerView,已实现竖直滚动的逻辑。
5. 只显示 1 个菜单
现在我们已经实现了 拖动、自动滚动、滚动方向 2 选 1 的逻辑,菜单能正常显示与隐藏。但是存在一个问题,菜单栏能显示多个,即下个菜单显示时,上个菜单并不会主动隐藏。而 QQ 是会保证列表中只有 1 个菜单布局显示的。我们接下来实现这个逻辑。这个逻辑的实现挺复杂的,需要先梳理一下。
- 如何检测菜单布局是否显示?上面的代码逻辑中,有实现一个逻辑,那就是在 Up 时,会执行 Fling 动作,这就限定了 item 的 Scroll 范围:scrollX 要么为 0 (菜单布局隐藏),要么为最大滚动距离(菜单布局显示)。不会出现中间值。我们就可以根据这个条件来判断是否有菜单布局显示。
- 定义拦截模式与非拦截模式:当 RV 中有菜单显示时,我们需要启用拦截模式。当 RV 中无菜单显示时,我们启用非拦截模式。拦截模式针对的是上个显示菜单布局的 item。而非拦截模式则是针对当前被点击的 item。
public class SwipeRecyclerView extends RecyclerView {
/**
* lastHolder 为 Down 事件来临时,正在显示菜单栏的 ViewHolder。
* curHolder 为 Down 事件来临时,对应的被点击的 ViewHolder。
* */
private ViewHolder curHolder, lastHolder;
/**
* RV 是否处于拦截模式的标识
* */
private boolean isInterceptMode;
public TestRecyclerView(@NonNull Context context) {
super(context);
}
public TestRecyclerView(@NonNull Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
}
public TestRecyclerView(@NonNull Context context, @Nullable AttributeSet attrs,
int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
switch (event.getActionMasked()) {
case MotionEvent.ACTION_DOWN:
lastHolder = curHolder;
curHolder = findViewHolder(event);
isInterceptMode = isMenuShowing(lastHolder);
break;
}
return isInterceptMode;
}
@Override
public boolean onTouchEvent(MotionEvent event) {
// Scroll 与 LongClick 只生效一个
switch (event.getActionMasked()) {
case MotionEvent.ACTION_DOWN:
if(isInterceptMode && lastHolder != null) {
// 点击的不是正在显示菜单的 item,则菜单直接隐藏
hideMenu(lastHolder);
}
return true;
case MotionEvent.ACTION_MOVE:
if(isInterceptMode) {
return true;
}
if(isVerScroll) {
return super.onTouchEvent(event);
}
// 水平滚动
return dealScroll(curHolder, event);
}
return super.onTouchEvent(event);
}
private ViewHolder findViewHolder(MotionEvent event) {
View view = findChildViewUnder(event.getX(), event.getY());
if(view == null) {
return null;
}
return findContainingViewHolder(view);
}
private boolean isMenuShowing(ViewHolder holder) {
if(holder == null) {
return false;
}
return holder.itemView.getScrollX() > 0;
}
private boolean hideMenu(ViewHolder holder) {
if(holder == null) {
return false;
}
mScroller.startScroll(holder.itemView.getScrollX(), 0, -holder.itemView.getScrollX(), 0);
invalidate();
return true;
}
}
6. item 区别对待
上面的拦截模式中,没有区分不同 item 的点击,而 QQ 是有的。QQ 的交互逻辑如下:
- 点击了没显示菜单布局的 item,菜单在 Down 时收回
- 点击了显示菜单的 item 的内容布局,菜单在 Up 时收回
- 点击了显示菜单的 item 的菜单布局,菜单不收回,菜单选项响应点击事件
- 按住了显示菜单的 item 的内容布局,响应 Scroll 动作
基于上面的逻辑,我们梳理下 RV 什么时候需要处于拦截模式,什么时候不需要:
- 未显示菜单布局时,不拦截
- 显示菜单布局时,点击了非菜单 item,拦截。Down 事件时菜单收回,后续事件全部消费
- 显示菜单布局时,点击了菜单 item 的内容布局,拦截。Up 事件时菜单收回,后续事件全部消费
- 显示菜单布局时,点击了菜单 item 的菜单布局,不拦截。Down 事件时菜单收回,事件交给对应的子 item 消费
- 显示菜单布局时,按住了菜单 item,拦截。处理 Scroll 动作
总结一下,分类图如下:
根据上述的分类图可知,只自定义 RV,是满足不了需求的,还需要配合自定义子 View,才能实现 OK 的功能。下面的代码实现了部分要求:
public class SwipeRecyclerView extends RecyclerView {
/**
* RV 是否处于拦截模式的标识
* */
private boolean isInterceptMode;
/**
* 是否点击的菜单布局的标识
* */
private boolean isTouchMenu;
public TestRecyclerView(@NonNull Context context) {
super(context);
}
public TestRecyclerView(@NonNull Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
}
public TestRecyclerView(@NonNull Context context, @Nullable AttributeSet attrs,
int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
switch (event.getActionMasked()) {
case MotionEvent.ACTION_DOWN:
isInterceptMode = isMenuShowing(lastHolder);
isTouchMenu = false;
if(lastHolder == null || curHolder == null) {
break;
}
if(lastHolder.getBindingAdapterPosition()
== curHolder.getBindingAdapterPosition()) {
// 判断点击的是否是菜单布局
isTouchMenu = isTouchMenu(event);
}
if(isInterceptMode && lastHolder.getBindingAdapterPosition()
!= curHolder.getBindingAdapterPosition()) {
// 点击的不是正在显示菜单的 item,则菜单直接隐藏
hideMenu(lastHolder);
}
if(isInterceptMode && isTouchMenu) {
hideMenu(lastHolder);
}
break;
}
return isInterceptMode && !isTouchMenu;
}
@Override
public boolean onTouchEvent(MotionEvent event) {
// Scroll 与 LongClick 只生效一个
switch (event.getActionMasked()) {
case MotionEvent.ACTION_MOVE:
// 大于阙值,才进行滚动
if(isInterceptMode && lastHolder.getBindingAdapterPosition()
== curHolder.getBindingAdapterPosition()
&& Math.abs(downX - event.getX(pointerIndex)) > scrollSlop) {
isInterceptMode = false;
return dealScroll(lastHolder, event);
}
return true;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
if(isInterceptMode && !isTouchMenu && lastHolder.getBindingAdapterPosition()
== curHolder.getBindingAdapterPosition()) {
hideMenu(lastHolder);
return true;
}
break;
}
return super.onTouchEvent(event);
}
/**
* 判断是否点击的是菜单
* */
private boolean isTouchMenu(MotionEvent event) {
if(lastHolder == null) {
return false;
}
View view = lastHolder.itemView.findViewById(MenuFuncId.MENU);
if(view == null) {
return false;
}
Rect rect = new Rect();
view.getGlobalVisibleRect(rect);
return rect.contains((int)event.getRawX(), (int)event.getRawY());
}
}
7. 响应点击事件 + 菜单隐藏显示回调
上面的代码中,我们忽略了 item 的点击响应,现在加上,然后加上一些其他功能后,形成的完整代码如下:
public class SwipeRecyclerView extends RecyclerView {
private static final String TAG = "SwipeRecyclerView";
/**
* 滚动事件的阙值
* */
private int scrollSlop;
/**
* 拦截模式下,是否有滚动过
* */
private boolean isScrollInIntercept;
/**
* 滚动相关类
* */
private Scroller mScroller;
/**
* Down 事件的坐标点,用于 Fling 动作
* */
private float rawDownX, rawDownY;
/**
* Move 事件(上次事件)的坐标点,用于 Scroll 动作
*/
private float lastX;
/**
* 记录的主手指
* */
private int pointerId;
/**
* 一次事件流中,是水平滑动,还是竖直滑动的标识
* */
private boolean isHorScroll, isVerScroll;
/**
* Down 事件按下时的 ViewHolder,lastHolder 为手指按下时,正在显示菜单栏的 ViewHolder
* 拦截模式下,是对 lastHolder 进行操作,非拦截模式下,对 curHolder 进行操作
* */
private ViewHolder curHolder, lastHolder;
/**
* 菜单是否显示,将 RV 分成两种模式:拦截模式和非拦截模式
* 菜单栏显示时,二次点击的不是当前显示菜单的 item,就是拦截模式,否则就是非拦截模式
* */
private boolean isInterceptMode;
/**
* 菜单栏的最大显示宽度,也是每个 item 可滚动的最大宽度
* */
private int maxScrollDistance;
/**
* 当前滑动的方向,大于 0 表示向左,小于 0 表示向右,等于 0 则表示既不左滑也不右滑
* 此值还用于判断菜单显示隐藏的方法是否需要回调
* */
private int scrollDirection;
private OnMenuStateChangeListener mOnMenuStateChangeListener;
public SwipeRecyclerView(@NonNull Context context) {
this(context, null);
}
public SwipeRecyclerView(@NonNull Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}
public SwipeRecyclerView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
init();
}
private void init() {
ViewConfiguration configuration = ViewConfiguration.get(getContext());
scrollSlop = configuration.getScaledTouchSlop();
mScroller = new Scroller(getContext());
}
/**
* 设置菜单栏状态变化的回调
* */
public SwipeRecyclerView setOnMenuStateChangeListener(OnMenuStateChangeListener listener) {
mOnMenuStateChangeListener = listener;
return this;
}
@Override
protected void onDetachedFromWindow() {
super.onDetachedFromWindow();
curHolder = null;
lastHolder = null;
}
@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
// 拦截模式下,事件全部拦截
int pointerIndex;
float x, y;
switch (event.getActionMasked()) {
case MotionEvent.ACTION_DOWN:
if(!mScroller.isFinished()) {
mScroller.forceFinished(true);
}
// 避免多指事件干扰
pointerIndex = event.getActionIndex();
x = event.getX(pointerIndex);
y = event.getY(pointerIndex);
ViewHolder holder = findViewHolder(event);
// 显示菜单栏,就拦截
lastHolder = curHolder;
if(isMenuShowing(lastHolder)) {
maxScrollDistance = calculateMaxScrollDistance(lastHolder);
// 菜单栏显示时,如果二次点击的不是当前显示菜单栏的 item,则需要拦截事件,隐藏菜单栏
if(holder != null && lastHolder != null && holder.getBindingAdapterPosition()
!= lastHolder.getBindingAdapterPosition()) {
hideMenu(lastHolder);
// ----------------------------拦截模式----------------------------
} else if(isClickMenu(lastHolder, event)) {
isInterceptMode = false;
return super.onInterceptTouchEvent(event);
}
isInterceptMode = true;
} else {
// ----------------------------非拦截模式----------------------------
isInterceptMode = false;
maxScrollDistance = calculateMaxScrollDistance(holder);
}
curHolder = holder;
// 二次点击时,点击的不是显示菜单的 item,则拦截,隐藏 item
rawDownX = lastX = x;
rawDownY = y;
// Down 事件时,必定有至少一根手指,这个手指的 index 至少为 0
pointerId = event.getPointerId(0);
// 避免自动 Scroll 的影响
isHorScroll = isVerScroll = false;
isScrollInIntercept = false;
scrollDirection = 0;
if(isInterceptMode) {
return true;
}
return super.onInterceptTouchEvent(event);
case MotionEvent.ACTION_MOVE:
// 需要水平或者竖直滚动时,走到自身 View 的 onTouchEvent 方法
// Move 拦截后,后续的 Up、Cancel 都会交给此 View 处理
if(isVerScroll || isHorScroll) {
return true;
}
pointerIndex = event.findPointerIndex(pointerId);
x = event.getX(pointerIndex);
y = event.getY(pointerIndex);
// 优先检测竖直方向的滑动
if(!isVerScroll && Math.abs(x - rawDownX) < Math.abs(y - rawDownY) && Math.abs(y - rawDownY) > scrollSlop) {
isVerScroll = true;
return true;
}
if(!isHorScroll && Math.abs(x - rawDownX) > Math.abs(y - rawDownY) && Math.abs(x - rawDownX) > scrollSlop) {
isHorScroll = true;
return true;
}
break;
}
return super.onInterceptTouchEvent(event);
}
@Override
public boolean onTouchEvent(MotionEvent event) {
int pointerIndex;
switch (event.getActionMasked()) {
case MotionEvent.ACTION_MOVE:
// ----------------------------拦截模式----------------------------
if(isInterceptMode) {
if(lastHolder == null || curHolder == null) {
return true;
}
if(lastHolder.getBindingAdapterPosition() != curHolder.getBindingAdapterPosition()) {
return true;
} else {
pointerIndex = event.findPointerIndex(pointerId);
// 大于阙值,才进行滚动
if(Math.abs(rawDownX - event.getX(pointerIndex)) > scrollSlop) {
isScrollInIntercept = true;
return dealScroll(lastHolder, event);
}
}
}
// ----------------------------非拦截模式----------------------------
// 竖直滚动,不额外实现逻辑
if(isVerScroll) {
return super.onTouchEvent(event);
}
// 水平滚动
return dealScroll(curHolder, event);
case MotionEvent.ACTION_POINTER_UP:
// 此段代码是 Google 官方写法
// 抬起的手指的索引
pointerIndex = event.getActionIndex();
int tempPointerId = event.getPointerId(pointerIndex);
// 如果抬起的手指是当前追踪的手指,则换下个手指追踪
if(tempPointerId == pointerId) {
int newPointerIndex = pointerIndex == 0 ? 1 : 0;
lastX = event.getX(newPointerIndex);
pointerId = event.getPointerId(newPointerIndex);
}
return super.onInterceptTouchEvent(event);
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
// ----------------------------拦截模式----------------------------
if(isInterceptMode) {
if(isMenuShowing(lastHolder)) {
if(isScrollInIntercept) {
// 滚动后当做 Fling 处理
dealFling(lastHolder, event);
} else {
hideMenu(lastHolder);
}
}
return true;
}
// ----------------------------非拦截模式----------------------------
if(isVerScroll) {
return super.onTouchEvent(event);
} else {
dealFling(curHolder, event);
return true;
}
}
return super.onTouchEvent(event);
}
/**
* Scroll 动作是在 Move 事件中触发的
*
* @return true 表示需要滚动,false 不需要滚动
* */
private boolean dealScroll(ViewHolder holder, MotionEvent event) {
if (holder == null) {
return false;
}
if(maxScrollDistance <= 0) {
return false;
}
int pointerIndex = event.findPointerIndex(pointerId);
float x = event.getX(pointerIndex);
int offsetX = (int) (lastX - x);
// 判断条件范围
// 手指从右向左移动,布局也从右向左移动,左移到达最大值
if(offsetX > 0 && holder.itemView.getScrollX() + offsetX > maxScrollDistance) {
scrollDirection = offsetX;
offsetX = maxScrollDistance - holder.itemView.getScrollX();
}
// 手指从左向右移动,布局也从左向右移动,右移到达最大值
if(offsetX < 0 && holder.itemView.getScrollX() + offsetX < 0) {
scrollDirection = offsetX;
offsetX = -holder.itemView.getScrollX();
}
// 竖直方向不移动
curHolder.itemView.scrollBy(offsetX, 0);
invalidate();
lastX = x;
return true;
}
/**
* 是否判定为水平方向的 Fling,如果是,则执行 Fling 动作
*
* Fling 动作是在 Up 事件中触发的
* */
private boolean dealFling(ViewHolder holder, MotionEvent event) {
float nowX = event.getX();
float offsetX = rawDownX - nowX;
if(holder == null || holder.itemView.getScrollX() <= 0) {
return false;
}
// 没有可滚动的距离
if(maxScrollDistance < 0) {
return false;
}
int dx;
// 菜单栏显示
// 拖动距离不足 1/3 时动作不生效
if(Math.abs(offsetX) < (float) maxScrollDistance / 3) {
if(offsetX > 0) {
dx = -holder.itemView.getScrollX();
} else {
// 回弹不隐藏
dx = maxScrollDistance - holder.itemView.getScrollX();
}
} else {
scrollDirection = (int)offsetX;
if(offsetX > 0) {
dx = maxScrollDistance - holder.itemView.getScrollX();
} else {
dx = -holder.itemView.getScrollX();
}
// 不需要滑动,说明到达最大值,不再回调
scrollDirection = dx;
}
// 滑动到目标位置
mScroller.startScroll(holder.itemView.getScrollX(), 0, dx, 0);
invalidate();
return true;
}
@Override
public void computeScroll() {
if(mScroller.computeScrollOffset()) {
// 拦截模式下,需要的是上个显示菜单的 item 滚动,而不是当前被点的 item 滚动
if(isInterceptMode) {
if(lastHolder == null) {
return;
}
lastHolder.itemView.scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
onScrolling(mScroller.getCurrX(), mScroller.getCurrY());
} else {
if(curHolder == null) {
return;
}
curHolder.itemView.scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
onScrolling(mScroller.getCurrX(), mScroller.getCurrY());
}
invalidate();
}
}
private void onScrolling(int scrollX, int scrollY) {
// 左滑达到最大值
if(scrollDirection > 0 && scrollX >= maxScrollDistance) {
notifyItem(true);
// 回调一次之后,不再回调
scrollDirection = 0;
} else if(scrollDirection < 0 && scrollX <= 0) {
notifyItem(false);
scrollDirection = 0;
}
}
/**
* -1 代表不可滑动
* */
private int calculateMaxScrollDistance(ViewHolder viewHolder) {
if(viewHolder == null) {
return -1;
}
// TODO View 的 id 需要自己定义
ViewGroup rootViewGroup = viewHolder.itemView.findViewById(MenuFuncId.MENU);
if(rootViewGroup == null || rootViewGroup.getChildCount() < 0) {
return -1;
}
int result = 0;
View view;
// 根布局是水平方向的 LL,直接从左向右加 item 的宽度
for(int i = 0; i < rootViewGroup.getChildCount(); i++) {
view = rootViewGroup.getChildAt(i);
if(view == null || view.getVisibility() == View.GONE) {
continue;
}
result += view.getMeasuredWidth();
}
return result;
}
private ViewHolder findViewHolder(MotionEvent event) {
View view = findChildViewUnder(event.getX(), event.getY());
if(view == null) {
return null;
}
return findContainingViewHolder(view);
}
/**
* 菜单栏是否在显示的标识
*
* @return true 表示菜单在显示,false 表示菜单未显示
* */
private boolean isMenuShowing(ViewHolder holder) {
if(holder == null) {
return false;
}
return holder.itemView.getScrollX() > 0;
}
/**
* 隐藏菜单栏
* */
private boolean hideMenu(ViewHolder holder) {
if(holder == null) {
return false;
}
mScroller.startScroll(holder.itemView.getScrollX(), 0, -holder.itemView.getScrollX(), 0);
invalidate();
return true;
}
private void notifyItem(boolean isVisible) {
ViewHolder holder;
if(isInterceptMode) {
holder = lastHolder;
} else {
holder = curHolder;
}
if(mOnMenuStateChangeListener != null && holder != null) {
mOnMenuStateChangeListener.onMenuVisibilityChange(holder.getAdapterPosition(), isVisible);
}
}
private boolean isClickMenu(ViewHolder holder, MotionEvent event) {
if(!isMenuShowing(holder)) {
return false;
}
View menuView = holder.itemView.findViewById(MenuFuncId.MENU);
if(menuView == null) {
return false;
}
int pointerIndex = event.getActionIndex();
float x, y;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
x = event.getRawX(pointerIndex);
y = event.getRawY(pointerIndex);
} else {
x = event.getRawX();
y = event.getRawY();
}
Rect location = new Rect();
menuView.getGlobalVisibleRect(location);
return location.contains((int)x, (int)y);
}
public interface OnMenuStateChangeListener {
/**
* 菜单栏可见性变化时,会回调此接口
*
* @param menuPos 菜单栏变化的位置,对应 RV 中的 pos
* @param isVisible true 表示菜单栏变的可见,false 表示菜单栏变的不可见
* */
void onMenuVisibilityChange(int menuPos, boolean isVisible);
}
}
到目前为止,上面的代码,除了没有实现长按效果,其他的都实现了。就先讲到这吧。后面会出一篇内部拦截法实现 QQ 侧滑效果的文章。