手势捕获和模拟手势输入的实现(GestureCatchView Android )

1,656 阅读5分钟

最近有点闲工夫,介绍一下手势的捕获和模拟输入的实现过程。当时是为了写一个自动化的任务应用,想利用Android无障碍手势输入来实现自动化,于是就有了这个组件。

效果图

1.gif

2.gif

实现原理

其实Android自带库已经为我们提供了类似的功能可以看这个GestureOverlayView,捕获的方式实际上与该控件是类似的。

创建

  1. 首先创建一个自定义的View继承自FrameLayout
class GestureCatchView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr){
    private val pathColor: Int = Color.BLACK
    private val pathWidth: Int = 20
    private val fadeDelay: Long = 0
    private var timestemp: Long = 0   //开始记录手势的时间点(用来得到开始时间到手指触摸的时间)
    private var curGestureItem: GestureItem? = null //当前的手势

    //收集到的手势信息
    private var collecting = false    //开始收集手势的标识符,true:将收集到的手势添加到集合中去。
    private val gestureInfoList: ArrayList<GestureInfo> = ArrayList()
    
    //需要显示的手势集合
    private val gestureItemList: ArrayList<GestureItem> = ArrayList()}


  1. 在初始化器上设置各项自定义的参数,需要注意的是想要在ViewGroup执行draw,必须设置setWillNotDraw(false)
init {
        setWillNotDraw(false)
        //属性设置省略
    }
  1. 定义每个手势的信息类,用来独立执行绘制和消失渐变动画
 inner class GestureItem {

        private val path = Path()
        private val paint = Paint().apply {
            strokeWidth = 20f
            color = pathColor
            style = Paint.Style.STROKE
            strokeJoin = Paint.Join.ROUND  //拐角圆形
            strokeCap = Paint.Cap.ROUND    //开始和结尾加入圆形
            isAntiAlias = true
        }

        //渐变动画器
        private val animator = ValueAnimator.ofFloat(1f, 0f).apply {
            interpolator = AccelerateInterpolator() //实现先慢后快的效果
            duration = 1500
            addUpdateListener {
                paint.alpha = (pathColor.alpha * it.animatedValue as Float).toInt()
                paint.strokeWidth = pathWidth * it.animatedValue as Float
                invalidate()
            }
        }

        private var lastX = 0f
        private var lastY = 0f
        private val points = arrayListOf<GesturePoint>()  //记录手势的每个点
        private val delayTime: Long =                     //开始到第一个点被创建的延时时间
            if (!collecting || timestemp == 0L) 0 else System.currentTimeMillis() - timestemp


}

手势的捕获

onTouchEvent获取MotionEvent并且将获取到的点绘制到视图中去。


 override fun onTouchEvent(event: MotionEvent): Boolean {
    super.onTouchEvent(event)
    processEvent(event)
    return true
 }

将拿到的MotionEvent进行处理

private var curGestureItem: GestureItem? = null //当前的手势
private fun processEvent(event: MotionEvent) {

         when (event.action) {
            MotionEvent.ACTION_DOWN -> {
                //当按下后被认为是一个手势的开始,创建一个手势Item
                val newGestureItem = GestureItem()
                //添加到需要被绘制的手势集合中
                gestureItemList.add(newGestureItem)
                //开始画第一个点
                newGestureItem.startPath(event)
                curGestureItem = newGestureItem
                invalidate()
            }

            MotionEvent.ACTION_MOVE -> {
                val item = curGestureItem
                //为当前手势每次移动手指捕捉到的点添加到Item中去
                item?.run {
                    addToPath(event)
                    invalidate()
                }
            }
            MotionEvent.ACTION_UP -> {
                //当手指抬起后被认为是手势的结束,当然还有其他的情况暂时不作考虑
                curGestureItem?.run {
                    endPath(event)
                    invalidate()
                }
            }
        }
    }

手势的绘制

inner class GestureItem{
        //.....省略
        
        fun startPath(event: MotionEvent) {
            val x = event.x
            val y = event.y

            path.moveTo(x, y)
            path.lineTo(x, y)

            lastX = x
            lastY = y
            //添加到集合中
            points.add(GesturePoint(x, y, event.eventTime))
        }
}

每次移动手指将获取的点添加到需要集合点中去

       fun addToPath(event: MotionEvent) {
            val x = event.x
            val y = event.y

            //取上一个点为控制点
            val controlX = lastX
            val controlY = lastY
            //上一个点与当前点的中点为结束点
            val endX = (controlX + x) / 2
            val endY = (controlY + y) / 2
            //使用二次贝塞尔曲线构成光滑曲线
            path.quadTo(lastX, lastY, endX, endY)

            lastX = x
            lastY = y
            //添加到集合中
            points.add(GesturePoint(x, y, event.eventTime))

        }

完成一个手势

        fun endPath(event: MotionEvent) {

            points.add(GesturePoint(x, y, event.eventTime))

            //开始消失动画
            animator.startDelay = fadeDelay
            animator.start()

            val curTime = System.currentTimeMillis()
            //创建一个GestureInfo,
            //它包含了这个Gesture,颜色延迟时间、总的手势时间,收集到的点。
            val gestureInfo = GestureInfo(
                gesture = Gesture().apply {
                addStroke(GestureStroke(ArrayList(points)))
            },
                pathColor = pathColor,
                delayTime = delayTime,
                duration = curTime - timestemp - delayTime,
                points = ArrayList(points)
            )

            if (collecting)
                gestureInfoList.add(gestureInfo)

            timestemp = curTime
            points.clear()

        }

最后是把Path画到Canvas中去

 override fun dispatchDraw(canvas: Canvas) {
        super.dispatchDraw(canvas)
        for (item in gestureItemList) {
            item.draw(canvas)
        }
    }

最后可以看到这样子的效果, ezgif-2-9bac1ab8ce6c.gif

手势的导入和模拟

最后讲一下如何将收集到的手势按照输入时那样模拟出来。
我们可以知道每个MotionEvent都有一个eventTime,它来自于SystemClock.uptimeMillis为参照时间。

然后转换成GesturePoint(event.x, event.y, event.eventTime),我们可以将这些收集到的点转换成MotionEvent,再执行到processEvent中,就可以实现模拟输入的效果了

    fun loadGestureInfoWithAnim(list: ArrayList<GestureInfo>) {
        list.forEach { fakeMotionEvent.addAll(it.points) }
        fakeMotionEventIndex = 0
        post(fakeMotionEventRunnable)
    }

每个点都记录着执行的间隔,把这些点依次再执行一次

    private val fakeMotionEvent = arrayListOf<GesturePoint>()
    private var fakeMotionEventIndex = -1
    private val fakeMotionEventRunnable = object : Runnable {
        override fun run() {
            if (fakeMotionEventIndex >= 0) {
                val point = fakeMotionEvent[fakeMotionEventIndex]
                val action = when (fakeMotionEventIndex) {
                    //第一个点是按下动作
                    0 -> MotionEvent.ACTION_DOWN
                    in 1 until fakeMotionEvent.size -> MotionEvent.ACTION_MOVE
                    //最后的点是抬起动作
                    else -> MotionEvent.ACTION_UP
                }
                val event = MotionEvent.obtain(point.timestamp, point.timestamp, action, x, y, 0)
                //模拟执行MotionEvent
                processEvent(event)


                fakeMotionEventIndex++
                if (fakeMotionEventIndex == fakeMotionEvent.size) {
                    fakeMotionEventIndex = -1
                    return
                }

                val nextPoint = fakeMotionEvent[fakeMotionEventIndex]
                val delayTime = nextPoint.timestamp - event.eventTime
                //延迟执行下一个点
                postDelayed(this, delayTime)
            }
        }

    }

补充

可能会问到为什么不直接保存MotionEvent呢?这是因为我们收到的每一个MotionEvent实际上大多是情况下是同一个对象,假如是试着将它保存起来,那么最后你将会得到一系列MotionEvent.action = ACTION_UP的集合,也就是最后抬手拿到的那个事件。

可以看下MotionEvent的源码:
它的构造函数是私有的

    private MotionEvent() {
    }

只能通过静态方法obtain来获取MotionEvent

    static public MotionEvent obtain(long downTime, long eventTime, int action,
            float x, float y, int metaState) {
        return obtain(downTime, eventTime, action, x, y, 1.0f, 1.0f,
                metaState, 1.0f, 1.0f, 0, 0);
    }

最后会执行到这个方法,可以看到我们拿到的MotionEvent大多数都是来自于gRecyclerTop,而不是被new出来的

    static private MotionEvent obtain() {
        final MotionEvent ev;
        synchronized (gRecyclerLock) {
            ev = gRecyclerTop;
            if (ev == null) {
                return new MotionEvent();
            }
            gRecyclerTop = ev.mNext;
            gRecyclerUsed -= 1;
        }
        ev.mNext = null;
        ev.prepareForReuse();
        return ev;
    }

当一个MotionEvent被消费掉时,最后会执行recycle,然后又会被赋值成gRecyclerTop

    public final void recycle() {
        super.recycle();

        synchronized (gRecyclerLock) {
            if (gRecyclerUsed < MAX_RECYCLED) {
                gRecyclerUsed++;
                mNext = gRecyclerTop;
                gRecyclerTop = this;
            }
        }
    }

最后附上项目的地址啊

GestureCatchView

朋友们如果觉得不错可以给个小星星支持一下呗。 有啥建议或者问题都欢迎一起讨论。