前言

之前写过一篇关于RecyclerView的进阶使用来搭建一个简易的ToDoList列表(link),里面是通过侧滑滑倒底直接删除,但这样显然不够美观,也容易导致误触。我们可以像QQ那样实现一个侧滑到一定程度显示一个按钮,我们点击按钮进行删除,比如:

那这个又应该如何实现呢?

实现

我们可以设置一个LinearLayout布局,让text占满屏幕,让按钮隐藏在屏幕外,然后通过整个布局的onTouchEvent()方法,来改变位置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
parentLayout.setOnTouchListener { v, event ->
when(event.action) {
MotionEvent.ACTION_DOWN -> {
initialX = event.x.toInt()
}

MotionEvent.ACTION_MOVE -> {
val transitionX = (event.x - initialX).coerceIn(-parentDelete.width.toFloat(),0F)
textLayout.translationX = transitionX
parentDelete.translationX = transitionX
}

MotionEvent.ACTION_UP -> {
if(-textLayout.translationX> parentDelete.width / 2) {
textLayout.animate().translationX(-parentDelete.width.toFloat())
parentDelete.animate().translationX(-parentDelete.width.toFloat())
} else {
textLayout.animate().translationX(0F)
parentDelete.animate().translationX(0F)
}
}
}

return true
}

MotionEvent.ACTION_DOWN指手指按下去时,这时我们去获取他的初始位置;MotionEvent.ACTION_MOVE表示手指开始滑动时,我们用event.x - initialX来获取他的偏移值,并用coerceIn决定他的最大最小值,因为向左滑他的偏移值是负的。然后我们将文字和删除按钮的偏移值同步设置达到一个将删除键拉出来的效果。

注意这里不能直接parentLayout.translationX = transitionX,因为此时delete按钮被canvas会裁剪掉。

但这样又会有许多的问题,比如你会发现点击事件不能用了,当你滑出来后再滑回去会一下次跳回去。

问题解决

解决无法点击的问题

这时如果我们给textLayout或者View设置一个setOnClickListener,是不会生效的,这是为什么呢?

点击事件不生效是因为事件分发的问题,先看一下事件分发的流程图:

事件分发流程

看上面的DOWN部分,子ViewdispatchTouchEvent方法传到onTouchEvent时,因为我们处理了滑动事件,返回了true,导致父类和子类会认为已经消费了点击事件,故不会处理父类和子类的点击事件,即不会从子类的onTouchEvent到父类的onTouchEvent,故父类的view无法被点击。

gpt回答:

  1. 触摸事件的消费

    • 当你在 onTouchEvent 中处理滑动事件时,如果你消费了事件(即返回 true),那么点击事件就不会再传递到其他视图或父视图。
    • 如果你在 onTouchEvent 中处理了 ACTION_MOVE 事件并返回 true,但没有正确处理 ACTION_UP 事件,点击事件可能会被视为未完成。
  2. 事件冲突

    • 当一个视图处理滑动事件时,父视图可能会认为子视图已经消费了事件,从而不会再处理点击事件。
    • 如果父视图拦截了触摸事件(onInterceptTouchEvent 返回 true),子视图将无法接收到后续的事件。

那么如何解决呢?我们可以在父ViewdispatchTouchEvent传到子ViewdispatchTouchEvent时,检测如果滑动距离大于slopTouch(这是官方提供的滑动距离的最小值),我们就认为此时应该处理滑动,因此重写onInterceptTouchEvent返回true去拦截向下分发事件的这个操作,从而让父类自己处理触摸事件,也就是调用父类自己的onTouchEvent。相反,如果滑动距离小于slopTouch,那么就是分发给子类去处理点击事件,即处理子类的onTouchEvent

总结一下流程:

事件冲突解决流程

代码实现应该通过重写LinearLayout布局来解决:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
class ListLinearLayout(context: Context, attrs: AttributeSet?) : LinearLayout(context, attrs) {

private val slopTouch = ViewConfiguration.get(context).scaledTouchSlop

private var lastX = 0
private var lastY = 0
var initialX = 0

//需在这个方法里面设置初始位置
override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
when(ev.action) {
MotionEvent.ACTION_DOWN -> {
initialX = ev.x.toInt()
lastX = ev.x.toInt()
lastY = ev.y.toInt()
}

//判断滑动距离是否大于官方推荐
MotionEvent.ACTION_MOVE -> {
if(abs(ev.x - lastX) > slopTouch || abs(ev.y - lastY) > slopTouch) {
return true //返回true拦截事件
}
lastX = ev.x.toInt()
lastY = ev.y.toInt()
}
}
return super.onInterceptTouchEvent(ev)
}


//这里面down事件不会触发
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
val parentDelete = getChildAt(1)
val textLayout = getChildAt(0)
when(event.action) {
MotionEvent.ACTION_DOWN -> {
initialX = event.x.toInt()
}

MotionEvent.ACTION_MOVE -> {
val transitionX = (event.x - initialX).coerceIn(-parentDelete.width.toFloat(),0F)
textLayout.translationX = transitionX
parentDelete.translationX = transitionX
}

MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
if(-textLayout.translationX> parentDelete.width / 2) {
textLayout.animate().translationX(-parentDelete.width.toFloat())
parentDelete.animate().translationX(-parentDelete.width.toFloat())
} else {
textLayout.animate().translationX(0F)
parentDelete.animate().translationX(0F)
}
}
}

return true
}

}

然后外部布局替换成自定义的LinearLayout就可以了。然后在Adapter中就可以删除所有关于onTouchEvent的代码了。

1
private val slopTouch = ViewConfiguration.get(context).scaledTouchSlop

这一行代码就是获取官方推荐的滑动值。

在一个父类View只有两个子布局的情况下,可以通过getChildAt(1),getChildAt(0)的方法快速获取子View的实例,等同于findViewById。如:

1
2
val parentDelete = getChildAt(1)
val textLayout = getChildAt(0)

这里面onTouchEventDOWN事件里面不能处理initialX,因为这里的DOWN事件需要子View不拦截onTouchEvent才能向上传到父类,而子View的点击事件拦截了这个DOWNonTouchEvent,所以父View无法接受DOWN事件,故需要在上面onInterceptTouchEvent这里面处理初始位置,否则是滑不出来的。

解决滑回去时突然一下子滑回去的问题

这个问题是因为在初始化坐标时initialX = event.x.toInt(),已经滑出来的状态下,此时initialX = 90假设我们只滑动很小一部分,,因为是往回滑动,假设滑倒x = 95,那么此时差值就只有5,根据代码就会将translationX设为5,趋近于没滑动,故直接跳到初始位置,即不展现的状态了。

解决方式也很简单,我们初始化initialX - getChildAt(1).translationX.toInt()就可以了,因为相当于我们要回到真正的初始位置需要向右移动,而getChildAt(1).translationX.toInt()是负数,故需要减来让他回到正确的初始位置而不是滑出来的位置。

给rv添加长按交换位置

因为我们自定义了滑动,所以正常rv封装的滑动是不能用了,但我们不能简简单单的删除onSwipe方法,因为是必须要重写的,我们只是留空。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
val helper = ItemTouchHelper(object : ItemTouchHelper.Callback() {
override fun getMovementFlags(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder
): Int {
val dragFlags = ItemTouchHelper.UP or ItemTouchHelper.DOWN
return makeFlag(ACTION_STATE_DRAG,dragFlags) or makeFlag(ACTION_STATE_IDLE, dragFlags)
}

override fun onMove(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder
): Boolean {
val from = viewHolder.adapterPosition
val to = target.adapterPosition

Collections.swap(taskInfoList, from, to)
recyclerViewAdapter.submitList(taskInfoList.toList())

return true
}

override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {

}

})
helper.attachToRecyclerView(recyclerview)

在第6到第7行的位置我们删除了val swipeFlags = ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT这一句,然后将第7行的returnreturn makeMovementFlags(dragFlags, swipeFlags)改成了return makeFlag(ACTION_STATE_DRAG,dragFlags) or makeFlag(ACTION_STATE_IDLE, dragFlags),这个makeFlag意思是当前的状态为滑动时,就允许上下滑动,然后加了个位运算空闲状态下也允许上下动(可以不加后面这个),这样就能只进行上下拖动了。

总结

没想到这一个小demo能引出这么多的知识,设计了rv的进阶用法,事件分发冲突以及各种零碎的小问题。还是获益匪浅的。

github地址:https://github.com/generalio/AndroidStudy/tree/main/TaskListDemo