android (实现左滑删除)自定义控件+事件分发

左滑删除

  • 背后的逻辑
    • 1布局的绘制
      • onMeasure
      • onLayout
    • 2 事件的分发
      • 都不处理
      • 爸爸拦截
        • 不吃
      • 事件分发的结论
  • 完整代码的实现
    • 效果图
    • 代码

背后的逻辑

想要实现左滑删除,在现有控件不满足的情况下,肯定是要自定义View。
然后考虑需要实现的效果,里面肯定具有两个子控件,一个是显示内容,一个是显示按钮,所以毫无疑问要自定义控件需要继承ViewGroup(布局控件)。

1布局的绘制

布局绘制中,最重要的就是onMeasure方法和onLayout方法。一个onMeasure是用来测量本控件的大小,onLayout方法则是排列孩子控件在本控件的位置。
左滑删除具体分析:
1.刚开始应该是这样(屏幕外侧的东西用户是看不到的)
android (实现左滑删除)自定义控件+事件分发_第1张图片

2.左移动的时候
android (实现左滑删除)自定义控件+事件分发_第2张图片

首先,我们自定义控件肯定是放在别的控件里面的,而且通常这种都是放在RecyclerView里面的。
所以我们就这样假定,这个是个item布局,item布局完整的样式如下所示。
很简单外面一个CardView,里面一个SwipeView也就是我们的自定义控件,然后SwipeView里面有两个直接的子控件,一个用来文本,一个用来放按钮。



        

        
            
            
        
        
                
                
            
        
    

那么对于自定义控件逻辑很清楚了
1.刚绘制的时候,应该是第一个子控件占满该控件,然后按钮排列在第一个控件的右边。
2.当手势有左滑的时候,整个控件的内容左移
3.当手势右滑的时候,整个控件的内容右移

onMeasure

onMeasure是用来确定本控件该有多大的。这个方法是父控件要放置子控件的时候,父控件会问你要多大的地方。所以onMeasure里面的两个参数是本控件的父控件给的,按照上面的布局,CardView是自定义控件的父控件。而且很明显,CardView知道自己多宽,但是不知道自己多高的。所以其实我们着重处理的是高度,宽度其实听老爸的就行了。

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)
        measureChild(getChildAt(0),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)

        //测量第二个  第二个孩子其实高度已经确定了,所以高度穿进去用 EXACTLY 和老爸的height,宽度呢就还是自己来所以把爷爷的宽度要求传递进去老爸其实不关心你的宽度
        measureChild(getChildAt(1),widthMeasureSpec, MeasureSpec.makeMeasureSpec(resultHeight,MeasureSpec.EXACTLY))//测量孩子的高度和宽度




    }

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是兄弟。
android (实现左滑删除)自定义控件+事件分发_第3张图片

都不处理

这几个布局对事件都不做任何处理,那么当我手指在红色区域的位置按下(down事件)。
那么其实有A、B、C都有这个红色区域,D根本没资格参与,他的区域和这个红色区域无关的。
为了好理解,我们把A、B、C替换成 爷爷,爸爸,孩子,把事件替换成苹果。

android (实现左滑删除)自定义控件+事件分发_第4张图片对于最后一个节点进行的顺序是这样的:
拿到事件:进来 dispatchTouchEvent
思考拦截:进来 onInterceptTouchEvent
思考结果: onInterceptTouchEvent -> 返回值 拦截 true 不拦截 false
思考是否自己吃: 进来 onTouchEvent
结果自己吃不吃 :onTouchEvent -> 返回值 吃 true 不吃 false
还不还回去 :dispatchTouchEvent-> 返回值 不还给老爸 true 还回去给老爸 false

android (实现左滑删除)自定义控件+事件分发_第5张图片

爸爸拦截

不吃

android (实现左滑删除)自定义控件+事件分发_第6张图片

android (实现左滑删除)自定义控件+事件分发_第7张图片

事件分发的结论

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事件给孩子,告诉孩子别搞了,整个完整的事件爸爸接手了。

完整代码的实现

效果图

android (实现左滑删除)自定义控件+事件分发_第8张图片

代码

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)
        measureChild(getChildAt(0),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)

        //测量第二个  第二个孩子其实高度已经确定了,所以高度穿进去用 EXACTLY 和老爸的height,宽度呢就还是自己来所以把爷爷的宽度要求传递进去老爸其实不关心你的宽度
        measureChild(getChildAt(1),widthMeasureSpec, MeasureSpec.makeMeasureSpec(resultHeight,MeasureSpec.EXACTLY))//测量孩子的高度和宽度

        


    }


    //拿到了这个事件    我是否是否消费这个事件  如果不消费的话就会还给父级 让父取处理
    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)
    }
}

你可能感兴趣的:(android)