前言

上一篇文章自定义了一个左滑删去的RecyclerView,把view事情分发三个函数dispatchTouchEvent、onInterceptTouchEvent、onTouchEvent实践运用了一下,一些原理经过呈现的bug仍是挺能加深印象,而且后面还在优化上用上了TouchSlop、VelocityTracker以及GestureDetector,可是真不配那个一个控件搞定安卓自定义view,所以我把上篇博客标题改了,而且期望在接下来的时刻里,经过几个自定义view较全面的去学习自定义view的相关知识,话不多说,下面开端1

需求

上篇文章经过RecyclerView去完成了一个左滑的作用,后面突发奇想,既然能经过列表去完成item的左滑,那能不能经过item自己去完成左滑呢?这样我们把item内容写在自定义的layout里边就能够完成左滑了,听起来挺方便,于是就动手做了,少说多做总仍是好的。

有了第一篇的内容,item的左滑仍是简略多了,首要便是让item跟随滑动,右边主动增加一个删去按钮就够了吧,开端我是这么想的,并总结了三点中心思想:

  • 一个容器,左右两部分,左面外部导入,右边删去框主动增加
  • 在 View 右边追加一个删去框 ,需要在 View 内阻拦事情,依据 x 轴滑动距离滑动
  • 在 ConstraintLayout 内部增加一个删去框,左面对其 parent 右边

这儿取巧了一下,承继的 ConstraintLayout,这样让增加的删去框对齐 ConstraintLayout的右边就行了。

运转作用

自定义view实战(2):列表内左滑删除Item

编写代码

代码不多,就直接上代码了,注释写的很具体,后面再提下呈现的首要问题:

import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Color
import android.os.Build
import android.util.AttributeSet
import android.util.TypedValue
import android.view.Gravity
import android.view.MotionEvent
import android.view.View
import android.widget.Scroller
import android.widget.TextView
import androidx.annotation.RequiresApi
import androidx.constraintlayout.widget.ConstraintLayout
import kotlin.math.abs
/**
 * 左划删去控件
 * 能在控件完成左滑吗?怎么传入自定义的布局?
 * 思路:
 * 1、一个容器,左右两部分,左面外部导入,右边删去框 x 增加层级
 * 2、在 View 右边追加一个删去款 x 需要在 View 内阻拦事情
 * 3、在 ConstraintLayout 内部增加一个删去框,左面对其 parent 右边
 *
 * @author silence
 * @date 2022-09-27
 */
class LeftDeleteItemLayout : ConstraintLayout {
    private val mDeleteView: View?
    var mDeleteClickListener: OnClickListener? = null
    //流通滑动
    private var mScroller = Scroller(context)
    //前次事情的横坐标
    private var mLastX = -1f
    //操控控件完毕的runnable
    private val stopMoveRunnable: Runnable = Runnable { stopMove() }
    constructor(context: Context) : this(context, null, 0)
    constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0)
    constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) :
            super(context, attrs, defStyleAttr)
    @RequiresApi(Build.VERSION_CODES.LOLLIPOP)
    constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int, defStyleRes: Int) :
            super(context, attrs, defStyleAttr, defStyleRes)
    init {
        //kotlin的初始化函数
        mDeleteView = makeDeleteView(context)
        addView(mDeleteView)
    }
    //创立删去框,设置好位置对齐本身最右边
    private fun makeDeleteView(context: Context): View {
        val deleteView = TextView(context)
        //给当时控件一个id,用于删去控件束缚
        this.id = generateViewId()
        //设置布局参数
        deleteView.layoutParams = LayoutParams(
            dp2px(context, 100f), 0
        ).apply {
            //设置束缚条件
            leftToRight = id
            topToTop = id
            bottomToBottom = id
        }
        //设置其他参数
        deleteView.text = "删去"
        deleteView.gravity = Gravity.CENTER
        deleteView.setTextColor(Color.WHITE)
        deleteView.textSize = sp2px(context,18f).toFloat()
        deleteView.setBackgroundColor(Color.RED)
        //设置点击回调
        deleteView.setOnClickListener(mDeleteClickListener)
        return deleteView
    }
    //阻拦事情
    override fun onInterceptTouchEvent(event: MotionEvent?): Boolean {
        event?.let {
            when(event.action) {
                //down事情记载x,不阻拦,当move的时分才会用到
                MotionEvent.ACTION_DOWN -> mLastX = event.x
                //阻拦本控件内的移动事情
                MotionEvent.ACTION_MOVE -> return true
            }
        }
        return super.onInterceptTouchEvent(event)
    }
    //处理事情
    @SuppressLint("ClickableViewAccessibility")
    override fun onTouchEvent(event: MotionEvent?): Boolean {
        event?.let {
            when(event.action) {
                MotionEvent.ACTION_MOVE -> moveItem(event)
                MotionEvent.ACTION_UP -> stopMove()
            }
        }
        return super.onTouchEvent(event)
    }
    private fun moveItem(e: MotionEvent) {
        //Log.e("TAG", "moveItem: mLastX=$mLastX")
        //假如没有收到down事情,不应该移动
        if (mLastX == -1f) return
        val dx = mLastX - e.x
        //更新点击的横坐标
        mLastX = e.x
        //检查mItem移动后应该在[-deleteLength, 0]内
        val deleteWidth = mDeleteView!!.width
        if ((scrollX + dx) <= deleteWidth && (scrollX + dx) >= 0) {
            //触发移动
            scrollBy(dx.toInt(), 0)
        }
        //假如一段时刻没有移动时刻,mLastX还没被stopMove重置为-1,那便是移动到其他地方了
        //设置200毫秒没有新事情就触发stopMove
        removeCallbacks(stopMoveRunnable)
        postDelayed(stopMoveRunnable, 200)
    }
    private fun stopMove() {
        //假如移动过半了,应该断定左滑成功
        val deleteWidth = mDeleteView!!.width
        if (abs(scrollX) >= deleteWidth / 2f) {
            //触发移动至完全展开
            mScroller.startScroll(scrollX, 0, deleteWidth - scrollX, 0)
        }else {
            //假如移动没过半应该康复状况,则康复到原来状况
            mScroller.startScroll(scrollX, 0, - scrollX, 0)
        }
        invalidate()
        //清除状况
        mLastX = -1f
    }
    //流通地滑动
    override fun computeScroll() {
        if (mScroller.computeScrollOffset()) {
            scrollTo(mScroller.currX, mScroller.currY)
            postInvalidate()
        }
    }
    //单位转化
    @Suppress("SameParameterValue")
    private fun dp2px(context: Context, dpVal: Float): Int {
        return TypedValue.applyDimension(
            TypedValue.COMPLEX_UNIT_DIP, dpVal, context.resources
                .displayMetrics
        ).toInt()
    }
    @Suppress("SameParameterValue")
    private fun sp2px(context: Context, spVal: Float): Int {
        val fontScale = context.resources.displayMetrics.scaledDensity
        return (spVal * fontScale + 0.5f).toInt()
    }

首要问题

动态生成TextView

这个首要便是经过代码生成一个TextView,不是很难,提一下。

将TextView对齐到当时容器右端

这儿使用ConstraintLayout取巧做的仍是不错的,由于假如要自己去完成一个在屏幕外的对齐,至少要在onMeasure中取得宽度,再去onLayout里边摆放到右侧屏幕外。

这儿也有一些问题,首先是设置动态生成的TextView参数,然后是设置ConstraintLayout内的束缚条件,由于束缚标记有必要要用到id,还得为当时控件生成一个id,最后便是做一个回调接口了。

滑动出界问题

还有一个没有意料到的问题是当滑动超越当时view的规模时,ACTION_MOVE和ACTION_UP都无法接收到,这就无法知道移动是否完毕了。这儿由于我们的自定义view是一个viewgroup,所以无法消耗ACTION_DOWN事情,所以后续的事情序列并不会交到当时的item上,这就麻烦了,所以这个需求本质上便是不合理的,可是仍是要解决问题吧!

这儿我经过View类的postDelayed,推迟运转一个runnable去中止滑动,当每次滑动的时分又去中止这个runnable。整个逻辑运转起来便是,滑动没有出界,移动的时分先移除推迟的中止逻辑,再发送推迟的中止逻辑,直到ACTION_UP触发中止,若滑动出界了,没有去移除推迟的中止逻辑,就会在一端时刻后主动触发中止。

有点绕,可是仍是挺简略的,里边的原理也简略讲一下。实践上View的postDelayed会经过主线程的handler去推迟执行,假如有了解handler机制,能够知道handler并不只是能够发送message,相同也能够发送runnable,类似移除message,相同也能够移除runnable。

滑动开端断定

另一个意料之外的问题是当滑动从其他item移动到当时item的时分,即便没有收到ACTION_DOWN事情,也会触发滑动,这个很不契合逻辑。我这就在stopMove里边将mLastX改为了-1,初始值也是-1,假如在moveItem中值是-1,就说明没有被ACTION_DOWN事情设定mLastX,即按下的时分并不在当时item,应当放弃滑动。

后续订正

onTouchEvent有误
    //处理事情
    @SuppressLint("ClickableViewAccessibility")
    override fun onTouchEvent(event: MotionEvent): Boolean {
        when(event.action) {
            MotionEvent.ACTION_DOWN -> return true
            MotionEvent.ACTION_MOVE -> moveItem(event)
            MotionEvent.ACTION_UP -> stopMove()
        }
        return super.onTouchEvent(event)
    }

增加对ACTION_DOWN的阻拦,由于假如ACTION_DOWN没在view处有被处理的话,会被丢掉,假如被view阻拦了的话,move事情又不会经过onInterceptTouchEvent函数。真不知道当时写的时分是怎么运转经过的。。。