左滑删除
- 背后的逻辑
- 1布局的绘制
- onMeasure
- onLayout
- 2 事件的分发
- 都不处理
- 爸爸拦截
- 不吃
- 吃
- 事件分发的结论
- 完整代码的实现
- 效果图
- 代码
背后的逻辑
想要实现左滑删除,在现有控件不满足的情况下,肯定是要自定义View。
然后考虑需要实现的效果,里面肯定具有两个子控件,一个是显示内容,一个是显示按钮,所以毫无疑问要自定义控件需要继承ViewGroup(布局控件)。
1布局的绘制
布局绘制中,最重要的就是onMeasure方法和onLayout方法。一个onMeasure是用来测量本控件的大小,onLayout方法则是排列孩子控件在本控件的位置。
左滑删除具体分析:
1.刚开始应该是这样(屏幕外侧的东西用户是看不到的)
2.左移动的时候
首先,我们自定义控件肯定是放在别的控件里面的,而且通常这种都是放在RecyclerView里面的。
所以我们就这样假定,这个是个item布局,item布局完整的样式如下所示。
很简单外面一个CardView,里面一个SwipeView也就是我们的自定义控件,然后SwipeView里面有两个直接的子控件,一个用来文本,一个用来放按钮。
<androidx.cardview.widget.CardView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:id="@+id/card"
android:layout_margin="3dp"
app:cardCornerRadius="@dimen/cardCornerRadius"
tools:ignore="MissingConstraints">
<com.rengda.sigangapp.SwipeView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/contentView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingRight="@dimen/mainPadding"
android:paddingVertical="@dimen/mainPadding"
android:clickable="true"
>
<TextView
android:id="@+id/No"
android:layout_marginLeft="3dp"
android:layout_marginRight="3dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="@dimen/headTitleFontSize"
android:textColor="@color/black"
android:text="序号">
</TextView>
</androidx.constraintlayout.widget.ConstraintLayout>
<LinearLayout
android:id="@+id/deleteButton"
android:layout_width="100dp"
android:layout_height="wrap_content"
android:gravity="center"
android:background="@color/redButton">
<TextView
android:layout_width="match_parent"
android:textSize="@dimen/headFontSize"
android:layout_height="match_parent"
android:textColor="@color/white"
android:gravity="center"
android:background="@color/redButton"
android:text="删除">
</TextView>
</LinearLayout>
</com.rengda.sigangapp.SwipeView>
</androidx.cardview.widget.CardView>
那么对于自定义控件逻辑很清楚了
1.刚绘制的时候,应该是第一个子控件占满该控件,然后按钮排列在第一个控件的右边。
2.当手势有左滑的时候,整个控件的内容左移
3.当手势右滑的时候,整个控件的内容右移
onMeasure
onMeasure是用来确定本控件该有多大的。
onMeasure里面的两个参数是本控件的父控件给的,按照上面的布局,CardView是自定义控件的父控件。而且很明显,CardView知道自己多宽,但是不知道自己多高的。所以其实我们着重处理的是高度,宽度其实听老爸的就行了。
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
measureChildren(widthMeasureSpec, heightMeasureSpec)//测量孩子的高度和宽度
var width = MeasureSpec.getSize(widthMeasureSpec)//父组件能够给的宽度
val widthSpecMode = MeasureSpec.getMode(widthMeasureSpec)//父组件的宽度的设置方式
var height = MeasureSpec.getSize(heightMeasureSpec)//父组件能够给的高度
val heightSpecMode = MeasureSpec.getMode(heightMeasureSpec)//父组件的高度的设置方式
var resultWidth=0;
var resultHeight=0;
//其实只有三种类型
if(widthSpecMode==MeasureSpec.EXACTLY){//表示父控件给子view一个具体的值,子view要设置成这些值的大小
resultWidth=width //听老爸的
}else if (widthSpecMode==MeasureSpec.UNSPECIFIED){//父组件没告诉你限制 随你发挥
resultWidth= getChildAt(0).measuredWidth //用第一个孩子宽度
}else if(widthSpecMode==MeasureSpec.AT_MOST){//表示父控件个子view一个最大的特定值,而子view不能超过这个值的大小
resultWidth=Math.min(getChildAt(0).measuredWidth,width) //看看谁最小听谁的
}
//其实只有三种类型
if(heightSpecMode==MeasureSpec.EXACTLY){//表示父控件给子view一个具体的值,子view要设置成这些值的大小
resultHeight=height//听老爸的
}else if (heightSpecMode==MeasureSpec.UNSPECIFIED){//父组件没告诉你限制 随你发挥
resultHeight= getChildAt(0).measuredHeight //用第一个孩子的高度
}else if(heightSpecMode==MeasureSpec.AT_MOST){//表示父控件个子view一个最大的特定值,而子view不能超过这个值的大小
resultHeight=Math.min(getChildAt(0).measuredHeight,height) //看看谁最小听谁的
}
//设置
setMeasuredDimension(resultWidth, resultHeight)
}
onLayout
设置第一个孩子在本控件的上下左右位置。
设置第二个孩子在第一个孩子的右边。
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
if (childCount==2){
val view = getChildAt(1)
sendViewWidth=view.measuredWidth;//记录按钮的宽度
view.layout(r, t, r + view.measuredWidth, b) //删除按钮排布在第一个View之后
}
getChildAt(0).layout(l, t, r, b)//设置第一个控件的位置 上下左右的位置
}
这一步结束,其实页面刚进来展示的样子就结束了,至于左滑右滑的展示需要去确定的事件里面进行。
2 事件的分发
1.完整的事件,指的是从手指按下down,到手指移动move,然后到手指抬起up,这一个完整的流程。当然在这个流程中,move可能一次都不被触发,也可能被触发多次。
2.事件:
在处理事件的时候,有三个重要的概念。1.分发 2.拦截 3.消费。
是否分发(dispatchTouchEvent):拿到事件必进dispatchTouchEvent,返回结果表示是不是还给上级处理。true不还给上级了,false返给上级。
是否拦截(onInterceptTouchEvent):当事件分发到自己了,布局文件具有拦截事件的功能,是否让这个事件给自己,不往下发了,如果true拦截,并且去消费,如果false继续下发。
是否消费(onTouchEvent):是否消费掉这个事件,为true是消费,为false是不消费。
很抽象没关系下面慢慢理解:
以下图为例一个嵌套的布局,A是B的父亲,B是C、D的父亲,C、D是兄弟。
都不处理
这几个布局对事件都不做任何处理,那么当我手指在红色区域的位置按下(down事件)。
那么其实有A、B、C都有这个红色区域,D根本没资格参与,他的区域和这个红色区域无关的。
为了好理解,我们把A、B、C替换成 爷爷,爸爸,孩子,把事件替换成苹果。
对于最后一个节点进行的顺序是这样的:
拿到事件:进来 dispatchTouchEvent
思考拦截:进来 onInterceptTouchEvent
思考结果: onInterceptTouchEvent -> 返回值 拦截 true 不拦截 false
思考是否自己吃: 进来 onTouchEvent
结果自己吃不吃 :onTouchEvent -> 返回值 吃 true 不吃 false
还不还回去 :dispatchTouchEvent-> 返回值 不还给老爸 true 还回去给老爸 false
爸爸拦截
不吃
吃
事件分发的结论
1.如果事件以down为例,发送到了某个view,那么必定先进dispatchTouchEvent。
2.某个view有了事件,会进onInterceptTouchEvent,让你思考拦不拦截。这个方法的返回值true 代表拦截,false代表不拦截。
3.不拦截就继续下发,拦截就直接走onTouchEvent方法。
4.onTouchEvent思考自己吃不吃,吃的话true(消费了),不吃false(不消费)。
5.自己不吃也就是没消费,那么自己的dispatchTouchEvent的返回值默认情况下就是false,代表还回去给爸爸处理。如果自己吃,那么默认情况下dispatchTouchEvent为true了,代表爸爸别处理了,我已经吃了。
所以onTouchEvent会有两种情况会进来,一种,我自己拦下来的,然后就到我自己去判断吃不吃,孩子连这个事件都接不到了。
另一种是,我一路发下去发给孩子了,孩子都不吃,还给我处理,那么我也要思考我自己吃不吃。
特别注意:
1.在一个完整事件中也就是手指按下到起来的时候(down开始-到up结束),某个view拦截了完整事件中的一次事件之后,这个后续动作都直接交给这个view了,不会问拦不拦截,默认你直接拦,也不进入onInterceptTouchEvent了,直接到onTouchEvent。(eg:下一次的按下到起来还是重新的逻辑的)。
2.如果down事件,下来一直到还回去最外面都没被消耗,就不会有后续的move或up事件了。因为大家都不吃,所以都没必要问了。
3.某个view,如果接到过down事件,但是爸爸又把其他的事件给拦截了,那么孩子会接到一次cancel事件。eg:孩子接到了down,爸爸把后面的move拦截了,然后爸爸拦截了move之后,会发个cancel事件给孩子,告诉孩子别搞了,整个完整的事件爸爸接手了。
完整代码的实现
效果图
代码
package com.rengda.sigangapp
import android.content.Context
import android.util.AttributeSet
import android.util.Log
import android.view.MotionEvent
import android.view.ViewGroup
import android.widget.Scroller
import kotlin.math.abs
class SwipeView(context:Context,attrs: AttributeSet):ViewGroup(context,attrs) {
private val scroller=Scroller(context);
private var sendViewWidth=0;
private var firstX="0".toFloat();//第一个触点的位置
private var isSendViewShow=false;
private var newX="0".toFloat()
private var lastX="0".toFloat();
private var lastY="0".toFloat();
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
if (childCount==2){
val view = getChildAt(1)
sendViewWidth=view.measuredWidth;//记录按钮的宽度
view.layout(r, t, r + view.measuredWidth, b) //删除按钮排布在第一个View之后
}
getChildAt(0).layout(l, t, r, b)//设置第一个控件的位置 上下左右的位置
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
measureChildren(widthMeasureSpec, heightMeasureSpec)//测量孩子的高度和宽度
var width = MeasureSpec.getSize(widthMeasureSpec)//父组件能够给的宽度
val widthSpecMode = MeasureSpec.getMode(widthMeasureSpec)//父组件的宽度的设置方式
var height = MeasureSpec.getSize(heightMeasureSpec)//父组件能够给的高度
val heightSpecMode = MeasureSpec.getMode(heightMeasureSpec)//父组件的高度的设置方式
var resultWidth=0;
var resultHeight=0;
//其实只有三种类型
if(widthSpecMode==MeasureSpec.EXACTLY){//表示父控件给子view一个具体的值,子view要设置成这些值的大小
resultWidth=width //听老爸的
}else if (widthSpecMode==MeasureSpec.UNSPECIFIED){//父组件没告诉你限制 随你发挥
resultWidth= getChildAt(0).measuredWidth //用第一个孩子宽度
}else if(widthSpecMode==MeasureSpec.AT_MOST){//表示父控件个子view一个最大的特定值,而子view不能超过这个值的大小
resultWidth=Math.min(getChildAt(0).measuredWidth,width) //看看谁最小听谁的
}
//其实只有三种类型
if(heightSpecMode==MeasureSpec.EXACTLY){//表示父控件给子view一个具体的值,子view要设置成这些值的大小
resultHeight=height//听老爸的
}else if (heightSpecMode==MeasureSpec.UNSPECIFIED){//父组件没告诉你限制 随你发挥
resultHeight= getChildAt(0).measuredHeight //用第一个孩子的高度
}else if(heightSpecMode==MeasureSpec.AT_MOST){//表示父控件个子view一个最大的特定值,而子view不能超过这个值的大小
resultHeight=Math.min(getChildAt(0).measuredHeight,height) //看看谁最小听谁的
}
//设置
setMeasuredDimension(resultWidth, resultHeight)
}
//拿到了这个事件 我是否消费这个事件 如果不消费的话就会还给父级 让父取处理
override fun onTouchEvent(event: MotionEvent): Boolean {
var consum=true
Log.d("SwipeView", "onTouchEvent: "+event.action)
var x=event.x//这次进来的x
when (event.action) {
MotionEvent.ACTION_DOWN -> {// 如果孩子不要这个事件 才会到这里来 如果孩子不要的话 自己要消耗掉否则后续的事件都没了
consum=true
}
MotionEvent.ACTION_MOVE -> {//这个会进来多次
var newX = firstX-x;//手指移动距离第一次触点的位置 其实就是现在内容需要在的位置 (左为正)
Log.d("SwipeView", "ACTION_MOVE:isSendViewShow "+isSendViewShow)
Log.d("SwipeView", "ACTION_MOVE:OFFSET "+newX)
if (!isSendViewShow){//按钮还没显示的情况下 向左滑才是有效的 0<=newX<=sendViewWidth
if (newX>sendViewWidth){ //最远只能滑动第二个控件的宽度
newX=sendViewWidth.toFloat()
}
if (newX<0){//无效的
newX="0".toFloat()
}
}
if (isSendViewShow){//按钮已经显示了 向右滑才是有效的有效的距离 -sendViewWidth<=newX<=0
if (newX<-sendViewWidth){
newX=-sendViewWidth.toFloat()
}
if (newX>0){//无效的
newX="0".toFloat()
}
newX=newX+sendViewWidth
}
scrollTo(newX.toInt(), 0)//因为这个方法 是让内容距离原始(也就是第一次绘制)的位置 偏移的位置 所以上面newX算的是距离初始的偏移量
Log.d("SwipeView", "ACTION_MOVE: newX"+newX)
consum=true
}
MotionEvent.ACTION_UP,MotionEvent.ACTION_CANCEL-> {
if (isSendViewShow){//原先是有按钮的
if (scrollX <((sendViewWidth/5)*4)) {//说明真的想关闭了
scrollTo(0, 0)
isSendViewShow=false
} else {
scrollTo(sendViewWidth, 0)
isSendViewShow=true
}
}else{//没展示按钮的
if (scrollX >= sendViewWidth/5) {// 代表真的想打开
scrollTo(sendViewWidth, 0)
isSendViewShow=true
} else {//否则不显示
scrollTo(0, 0)
isSendViewShow=false
}
}
Log.d("SwipeView", "ACTION_UP: isSendViewShow "+isSendViewShow)
}
else -> { consum=false}
}
return consum
}
//做外部拦截----是否消拦截这个事件(true拦截,false 不拦截) 因为孩子设置了click事件,如果直接发下 所有的事件都会被孩子消费掉的,不会再回来用父亲的事件了。所以自己要先把用到的拦截一下 其他的再发给孩子
override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean {
when (ev?.action){
MotionEvent.ACTION_DOWN -> {//因为孩子要触发click 起码要有个down,所以先下发。 当你拦截了一个之后,后续onInterceptTouchEvent不会再调用了
//记录按下的时候的X
firstX=ev.x;//记录
newX="0".toFloat()
return false;
}
MotionEvent.ACTION_MOVE -> {
return true;
}
else->{return false}
}
}
//做内部拦截 就是去控制老爸的
override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
when (ev?.action){
MotionEvent.ACTION_DOWN -> {//让老爸不要拦截
lastX=ev.x;
lastY=ev.y;
getParent().requestDisallowInterceptTouchEvent(true);
}
MotionEvent.ACTION_MOVE -> {
if (abs( ev.x-lastX)> abs(ev.y-lastY)){//说明x滑动的距离大于Y的距离 说明是左右滑动 那么就不允许老爸拦截
getParent().requestDisallowInterceptTouchEvent(true);
}else{//允许老爸去拦截
getParent().requestDisallowInterceptTouchEvent(false);
}
}
MotionEvent.ACTION_UP ->{//让老爸不要拦截
getParent().requestDisallowInterceptTouchEvent(true);
}
else->{return false}
}
return super.dispatchTouchEvent(ev)
}
}