跳至主要內容

自定义View | 仿某米的触摸屏测试

guodongAndroid大约 17 分钟Androidandroidview

背景

因项目需求变更,公司内的工厂测试程序重写,之前的触摸屏测试已不符合项目需求,遂对比了某米和某为的触摸屏测试效果,个人觉得某米的效果不错,虽然最后没有被采纳,但是这不妨碍我们实现一下某米的触摸屏测试。可能因机型不同,打开某米的触摸屏测试的方式也不尽相同,读者请自行百度相应机型的方式。

mi

某米的触摸屏测试如上图所示,我们简单分析一下:

  • 屏幕四周、垂直居中和水平居中有绘制单元格,触摸后会重绘颜色。
  • 屏幕两个对角线有类似于管道的图案,此图案重绘只能从绘制 X 号的地方开始,一直到管道对端的 X 号地方为止,如果期间手指触摸超出管道的范围即失败。
  • 手指触摸在单元格与管道内的区域滑动时,屏幕会显示滑动轨迹,如果超出区域,则轨迹消失。
  • 所有单元格与两条管道重绘完毕则测试完成。

绘制单元格

思路如下:以左上角的单元格为起点,计算出所有单元格的坐标保存起来,最后在 onDraw 方法中遍历单元格组,根据坐标进行绘制。

首先定义单元格的基准宽高与最终宽高变量:

private var itemWidthBasic = 90
private var itemHeightBasic = 80

private var itemWidth = -1F
private var itemHeight = -1F

其次定义自定义 View 的宽高变量:

private var viewWidth: Int = -1
private var viewHeight: Int = -1

最后定义单元格在宽高方向上的数量变量:

private var widthCount = -1
private var heightCount = -1

定义绘制单元格的画笔:

private val boxPaint by lazy {
    Paint(Paint.ANTI_ALIAS_FLAG).apply {
        color = Color.GRAY
        style = Paint.Style.STROKE
        strokeWidth = 2F
    }
}

定义 TouchRectF 实体,对 RectF 包装一层,增加 isReDrawable 变量,单元格被触摸重绘后标记为 True:

data class TouchRectF(val rectF: RectF, var isReDrawable: Boolean = false) {

    fun reset() {
        isReDrawable = false
    }
}

定义保存单元格坐标的容器,其中包含屏幕上下左右以及垂直与水平居中的坐标容器:

// 屏幕左侧单元格的坐标容器
private val leftRectFList = mutableListOf<TouchRectF>()

// 屏幕顶部单元格的坐标容器
private val topRectFList = mutableListOf<TouchRectF>()

// 屏幕右侧单元格的坐标容器
private val rightRectFList = mutableListOf<TouchRectF>()

// 屏幕底部单元格的坐标容器
private val bottomRectFList = mutableListOf<TouchRectF>()

// 屏幕水平居中单元格的坐标容器
private val centerHorizontalRectFList = mutableListOf<TouchRectF>()

// 屏幕垂直居中单元格的坐标容器
private val centerVerticalRectFList = mutableListOf<TouchRectF>()

选择在 onLayout 方法中计算所有单元格的坐标并获取 View 的宽高:

override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
    super.onLayout(changed, left, top, right, bottom)
    // 保存 View 的宽高
    viewWidth = width
    viewHeight = height

    computeRectF()
}

下图显示了所有单元格所在的范围:

scope

computeRectF 方法中计算单元格的宽高、数量及坐标:

  1. 首先以单元格的基准宽高计算单元格宽高方向上的数量
  2. 其次以单元格宽高方向上的数量计算单元格的最终宽高
  3. 清除之前计算的结果

根据上面单元格范围示意图:

  • 计算并保存左侧单元格的坐标,不包含头和尾,去掉与顶部和底部重叠的单元格
  • 计算并保存顶部单元格的坐标
  • 计算并保存右侧单元格的坐标,不包含头和尾,去掉与顶部和底部重叠的单元格
  • 计算并保存底部单元格的坐标
  • 计算并保存水平居中单元格的坐标,不包含头和尾,去掉与左侧和右侧重叠的单元格
  • 计算并保存垂直居中单元格的坐标,不包含头和尾,去掉与顶部和底部重叠的单元格,且去掉与水平居中重叠的单元格
private fun computeRectF() {
    // 以单元格的基准宽高计算单元格宽高方向上的数量
    widthCount = viewWidth / itemWidthBasic
    heightCount = viewHeight / itemHeightBasic

    // 以单元格宽高方向上的数量再计算单元格的最终宽高
    itemWidth = viewWidth.toFloat() / widthCount
    itemHeight = viewHeight.toFloat() / heightCount

    // 清空之前计算的结果
    leftRectFList.clear()
    topRectFList.clear()
    rightRectFList.clear()
    bottomRectFList.clear()
    centerHorizontalRectFList.clear()
    centerVerticalRectFList.clear()

    // 计算并保存屏幕左侧单元格的坐标, 不包含头和尾, 去掉与顶部和底部重叠的单元格
    for (i in 1 until heightCount - 1) {
        val rectF = RectF(0F, itemHeight * i, itemWidth, itemHeight * (i + 1))
        leftRectFList.add(TouchRectF(rectF))
    }

    // 计算并保存屏幕顶部单元格的坐标
    for (i in 0 until widthCount) {
        val rectF = RectF(itemWidth * i, 0F, itemWidth * (i + 1), itemHeight)
        topRectFList.add(TouchRectF(rectF))
    }

    // 计算并保存屏幕右侧单元格的坐标, 不包含头和尾, 去掉与顶部和底部重叠的单元格
    for (i in 1 until heightCount - 1) {
        val rectF = RectF(
            viewWidth - itemWidth,
            itemHeight * i,
            viewWidth.toFloat(),
            itemHeight * (i + 1)
        )
        rightRectFList.add(TouchRectF(rectF))
    }

    // 计算并保存屏幕底部单元格的坐标
    for (i in 0 until widthCount) {
        val rectF = RectF(
            itemWidth * i,
            viewHeight - itemHeight,
            itemWidth * (i + 1),
            viewHeight.toFloat()
        )
        bottomRectFList.add(TouchRectF(rectF))
    }

    // 计算并保存屏幕水平居中单元格的坐标, 不包含头和尾, 去掉与左侧和右侧重叠的单元格
    val centerHIndex = heightCount / 2
    for (i in 1 until widthCount - 1) {
        val rectF = RectF(
            itemWidth * i,
            itemHeight * centerHIndex,
            itemWidth * (i + 1),
            itemHeight * (centerHIndex + 1)
        )
        centerHorizontalRectFList.add(TouchRectF(rectF))
    }

    // 计算并保存屏幕垂直居中单元格的坐标, 不包含头和尾, 去掉与顶部和底部重叠的单元格, 且去掉与水平居中重叠的单元格
    val centerVIndex = widthCount / 2
    val skipIndex: Int = centerHIndex

    for (i in 1 until heightCount - 1) {
        // 跳过与横轴交叉的部分
        if (i == skipIndex) {
            continue
        }

        val rectF = RectF(
            itemWidth * centerVIndex,
            itemHeight * i,
            itemWidth * (centerVIndex + 1),
            itemHeight * (i + 1)
        )
        centerVerticalRectFList.add(TouchRectF(rectF))
    }
}

接下来在 onDraw 中绘制单元格:

override fun onDraw(canvas: Canvas) {
    // 单元格数量为 -1 时返回
    if (widthCount == -1 || heightCount == -1) {
        return
    }

    // 清空画布
    canvas.drawColor(Color.BLACK, PorterDuff.Mode.CLEAR)
    canvas.drawColor(Color.WHITE)

    // 绘制水平方向的单元格
    drawHorizontalBox(canvas)

    // 绘制垂直方向的单元格
    drawVerticalBox(canvas)
}

private fun drawHorizontalBox(canvas: Canvas) {
    for (rectF in topRectFList) {
        drawBox(rectF, canvas)
    }

    for (rectF in centerHorizontalRectFList) {
        drawBox(rectF, canvas)
    }

    for (rectF in bottomRectFList) {
        drawBox(rectF, canvas)
    }
}

private fun drawVerticalBox(canvas: Canvas) {
    for (rectF in leftRectFList) {
        drawBox(rectF, canvas)
    }

    for (rectF in centerVerticalRectFList) {
        drawBox(rectF, canvas)
    }

    for (rectF in rightRectFList) {
        drawBox(rectF, canvas)
    }
}

private fun drawBox(rectF: TouchRectF, canvas: Canvas) {
    canvas.drawRect(rectF.rectF, boxPaint)
}
  1. 首先做参数校验与画布清空
  2. 然后绘制水平方向的单元格,在 drawHorizontalBox 方法中遍历水平方向的单元格坐标容器,再调用 drawBox 方法传入坐标绘制单元格
  3. 最后绘制垂直方向的单元格,在 drawVerticalBox 方法中遍历垂直方向的单元格坐标容器,再调用 drawBox 方法传入坐标绘制单元格
  4. drawBox 方法中绘制单元格

效果如下:

box

绘制交叉管道

上面绘制单元格比较简单一些,现在要绘制的两个管道相对复杂一些,本文为了简单,没有完全仿照某米触摸屏测试中管道的 UI 效果。

思路:通过 Path 连接对角两个单元格的顶点组成管道,由于 Path 闭合后,单元格的两个顶点会连接成直线,这里两个顶点的连接使用二阶贝赛尔曲线绘制一个 View 显示范围之外的弧线,这样看起来管道没有起止点,且 Path 也可以闭合,同时也方便判断触摸点是否在管道内。

定义管道 Path 和 Region:

/**
 * /
 */
private val positiveCrossPath = TouchPath()
private val positiveCrossRegion = Region()

/**
 * \
 */
private val reverseCrossPath = TouchPath()
private val reverseCrossRegion = Region()

computeRectF 方法中计算管道 Path 路径:

  • 重置 Path 路径
  • 计算正向管道 Path,以左下角单元格为起点,右上角单元格为终点绘制 Path。
  • 计算反向管道 Path,以左上角单元格为起点,右下角单元格为终点绘制 Path。
  • 下面代码中的注释说明的更多一些。
private fun computeRectF() {
    
    // 省略计算单元格的代码
    
    // 重置 Path
    positiveCrossPath.path.reset()
    reverseCrossPath.path.reset()

    // PositiveCross
    
    // 获取左下角单元格坐标
    val lbRectF = bottomRectFList.first().rectF
    
    // 获取右上角单元格坐标
    val rtRectF = topRectFList.last().rectF
    
    with(positiveCrossPath.path) {
        // 移动 Path 至左下角单元格的左上角顶点
        moveTo(lbRectF.left, lbRectF.top)
        
        // 连接直线至右上角单元格的左上角顶点
        lineTo(rtRectF.left, rtRectF.top)
        
        // 以右上角单元格的右上角顶点坐标为基准计算屏幕外一点为控制点, 右上角单元格的右下角顶点为结束点绘制二阶贝赛尔曲线
        quadTo(
            rtRectF.right + itemWidth,
            rtRectF.top - itemHeight,
            rtRectF.right,
            rtRectF.bottom
        )
        
        // 连接直线至左下角单元格的右下角顶点
        lineTo(lbRectF.right, lbRectF.bottom)
        
        // 以左下角单元格的左下角顶点坐标为基准计算屏幕外一点为控制点, 左下角单元格的左上角顶点为结束点绘制二阶贝赛尔曲线
        quadTo(
            lbRectF.left - itemWidth,
            lbRectF.bottom + itemHeight,
            lbRectF.left,
            lbRectF.top
        )
        
        // 闭合 Path
        close()
    }

    // 计算正向管道 Path 区域
    val positiveCrossRectF = RectF()
    positiveCrossPath.path.computeBounds(positiveCrossRectF, true)
    positiveCrossRegion.setPath(positiveCrossPath.path, positiveCrossRectF.toRegion())

    // ReverseCross
    
    // 获取左上角单元格坐标
    val ltRectF = topRectFList.first().rectF
    
    // 获取右下角单元格坐标
    val rbRectF = bottomRectFList.last().rectF
    
    with(reverseCrossPath.path) {
        // 移动 Path 至左上角单元格的右上角顶点
        moveTo(ltRectF.right, ltRectF.top)
        
        // 连接直线只右下角单元格的右上角顶点
        lineTo(rbRectF.right, rbRectF.top)
        
        // 以右下角单元格的右下角顶点坐标为基准计算屏幕外一点为控制点, 右下角单元格的左下角顶点为结束点绘制二阶贝赛尔曲线
        quadTo(
            rbRectF.right + itemWidth,
            rbRectF.bottom + itemHeight,
            rbRectF.left,
            rbRectF.bottom
        )
        
        // 连接直线至左上角单元格的左下角顶点
        lineTo(ltRectF.left, ltRectF.bottom)
        
        // 以左上角单元格的左下角顶点坐标为基准计算屏幕外一点为控制点, 左上角单元格的右上角顶点为结束点绘制二阶贝赛尔曲线
        quadTo(
            ltRectF.left - itemWidth,
            ltRectF.top - itemHeight,
            ltRectF.right,
            ltRectF.top
        )
        
        // 闭合 Path
        close()
    }

    // 计算反向管道 Path 区域
    val reverseCrossRectF = RectF()
    reverseCrossPath.path.computeBounds(reverseCrossRectF, true)
    reverseCrossRegion.setPath(reverseCrossPath.path, reverseCrossRectF.toRegion())
}

接下来在 onDraw 方法中绘制管道:

override fun onDraw(canvas: Canvas) {
    // 省略绘制单元格代码

    drawPositiveCross(canvas)
    drawReverseCross(canvas)
}

private fun drawReverseCross(canvas: Canvas) {
    canvas.drawPath(reverseCrossPath.path, boxPaint)
}

private fun drawPositiveCross(canvas: Canvas) {
    canvas.drawPath(positiveCrossPath.path, boxPaint)
}

效果如下:

pipe

重绘单元格

单元格与管道已经绘制好了,下面我们先开始重绘单元格。

大体思路:在手指触摸屏幕的时候判断当前触摸的屏幕坐标是否在单元格内,是的话则重绘,否则不重绘。

首先定义重绘单元格的画笔:

private val fillPaint by lazy {
    Paint(Paint.ANTI_ALIAS_FLAG).apply {
        color = Color.GREEN
        style = Paint.Style.FILL
    }
}

因为单元格与单元格、单元格与管道之间有重叠的部分,突出显示重叠部分哪块单元格没有被重绘,重绘单元格时改变单元格边框的颜色,所以需要定义重绘单元格的画笔:

private val redrawBoxPaint by lazy {
    Paint(Paint.ANTI_ALIAS_FLAG).apply {
        color = Color.YELLOW
        style = Paint.Style.STROKE
        strokeWidth = 3F
    }
}

我们重写 onTouchEvent 方法,在此方法中处理触摸事件:

override fun onTouchEvent(event: MotionEvent): Boolean {
    val x = event.x
    val y = event.y
    when (event.actionMasked) {
        MotionEvent.ACTION_DOWN -> {
            // 根据当前坐标查找可重绘的单元格
            findReDrawableBox(x, y)
            
            // 重绘 View
            invalidate()
        }
    }
    return true
}

// 查找可重绘的单元格
private fun findReDrawableBox(x: Float, y: Float) {
    val touchRectF = (leftRectFList.find { it.rectF.contains(x, y) }
        ?: topRectFList.find { it.rectF.contains(x, y) }
        ?: rightRectFList.find { it.rectF.contains(x, y) }
        ?: bottomRectFList.find { it.rectF.contains(x, y) }
        ?: centerHorizontalRectFList.find { it.rectF.contains(x, y) }
        ?: centerVerticalRectFList.find { it.rectF.contains(x, y) })

    if (touchRectF != null) {
        // 标记可重绘的单元格
        markBoxReDrawable(touchRectF)
    }
}

// 标记可重绘的单元格
private fun markBoxReDrawable(rectF: TouchRectF) {
    if (!rectF.isReDrawable) {
        rectF.isReDrawable = true
    }
}

onTouchEvent 方法中,我们监听 ACTION_DOWN 事件,根据当前触摸屏幕的坐标查找可重绘的单元格,如果查找到匹配的单元格且此单元格目前还没有被重绘,则标记此单元格为可重绘的。

接下来,我们重构绘制单元格的 drawBox 方法来重绘单元格:

// 重构 drawBox 方法,增加重绘代码
private fun drawBox(rectF: TouchRectF, canvas: Canvas) {
    // 判断当前单元格是否已经标记为可重绘
    if (rectF.isReDrawable) {
        // 重绘单元格
        canvas.drawRect(rectF.rectF, redrawBoxPaint)
        canvas.drawRect(rectF.rectF, fillPaint)
    } else {
        canvas.drawRect(rectF.rectF, boxPaint)
    }
}

绘制轨迹线

一个个方格点击不太现实,因此增加手指滑动重绘单元格,同时增加手指滑动轨迹线绘制。

定义轨迹线 Path:

private val linePath = Path()

定义轨迹线画笔:

private val linePaint by lazy {
    Paint(Paint.ANTI_ALIAS_FLAG).apply {
        color = Color.BLUE
        style = Paint.Style.STROKE
        strokeWidth = 8F
        strokeJoin = Paint.Join.ROUND
        strokeCap = Paint.Cap.ROUND
    }
}

接下来,需要修改 onTouchEvent 方法增加对滑动重绘单元格和绘制轨迹线的支持:

override fun onTouchEvent(event: MotionEvent): Boolean {
    val x = event.x
    val y = event.y
    when (event.actionMasked) {
        MotionEvent.ACTION_DOWN -> {
            // 清空轨迹线 Path
            linePath.reset()
            
            // 移动轨迹线起点至点击坐标
            linePath.moveTo(x, y)
            
            // 根据当前坐标查找可重绘的单元格
            findReDrawableBox(x, y)
        }
        MotionEvent.ACTION_MOVE -> {
            // 判断当前坐标是否在单元格和管道区域内
            if (isInTouchableRegion(x, y)) {
                if (linePath.isEmpty) {
                    // 如果被重置了,先移动起点至当前坐标
                    linePath.moveTo(x, y)
                } else {
                    // 没有被重置,连接直线至当前坐标
                    linePath.lineTo(x, y)
                }

                // 根据当前坐标查找可重绘的单元格
                findReDrawableBox(x, y)
            } else {
                // 清空轨迹线 Path
                linePath.reset()
            }
            
            // 重绘View
            invalidate()
        }
        MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
            // 清空轨迹线 Path
            linePath.reset()
            
            // 重绘View
            invalidate()
        }
    }
    return true
}

// 判断当前坐标是否在单元格和管道区域内
private fun isInTouchableRegion(x: Float, y: Float): Boolean {
    return leftRectFList.any { it.rectF.contains(x, y) } ||
    		topRectFList.any { it.rectF.contains(x, y) } ||
    		rightRectFList.any { it.rectF.contains(x, y) } ||
    		bottomRectFList.any { it.rectF.contains(x, y) } ||
    		centerHorizontalRectFList.any { it.rectF.contains(x, y) } ||
    		centerVerticalRectFList.any { it.rectF.contains(x, y) } ||
    		positiveCrossRegion.contains(x.toInt(), y.toInt()) ||
    		reverseCrossRegion.contains(x.toInt(), y.toInt())

首先在 ACTION_DOWN 中清空轨迹线 Path,并移动轨迹线起点至当前坐标。

然后在 ACTION_MOVE 中先判断当前坐标是否在单元格和管道区域内,如果不在区域内,不绘制轨迹线,则重置轨迹线 Path;否则再判断轨迹线 Path 是否为空,为空认为已经被重置,先移动轨迹线起点至当前坐标,否则认为没有被重置,连接直线至当前坐标;最后重绘 View。

接下来修改 onDraw 方格,增加轨迹线的绘制:

override fun onDraw(canvas: Canvas) {
    
    // 省略绘制单元格和管道代码
    
    // 绘制轨迹线
    drawTrackLine(canvas)
}

private fun drawTrackLine(canvas: Canvas) {
    // 判断轨迹线 Path 是否为空
    if (linePath.isEmpty) {
        return
    }

    // 绘制轨迹线
    canvas.drawPath(linePath, linePaint)
}

效果如下:

line

重绘交叉管道

在某米的触摸屏测试中,笔者发现有以下几个条件需要注意:

  1. 如果滑动期间超出管道的范围认为无效。
  2. 只能从管道一端开始触摸,即从管道中间触摸视为无效。
  3. 如果从管道一端开始,不是通过管道到达另一端认为无效,即开始时是从管道一端开始,期间通过沿单元格滑动到达另一端。

以上几个问题中,第一个问题上面绘制轨迹线时已经解决,下面我们解决其他几个问题。

解决思路:

  • 问题2:判断轨迹线的起点坐标是否在四个顶点单元格区域内。
  • 问题3:判断轨迹线上所有点的坐标是否在管道区域内。

首先定义 PathMeasure 变量,用于获取轨迹线 Path 上各点的坐标:

private val linePathMeasure = PathMeasure()

onTouchEvent 方法中的 ACTION_MOVE 分支中增加 findReDrawableCross 重绘管道逻辑:

override fun onTouchEvent(event: MotionEvent): Boolean {
    when (event.actionMasked) {
        // 省略 ACTION_DOWN
        MotionEvent.ACTION_MOVE -> {
            // 判断当前坐标是否在单元格和管道区域内
            if (isInTouchableRegion(x, y)) {
                // 省略之前代码

                // 根据当前坐标查找可重绘的单元格
                findReDrawableBox(x, y)

                // 新增重绘管道逻辑
                findReDrawableCross()
            }
        }
        // 省略 ACTION_UP、ACTION_CANCEL
    }
    return true
}

findReDrawableCross 方法代码较多且稍微复杂一些,下面我把代码拆开逐步分析。

轨迹线路径测量及校验

  1. 首先校验轨迹线 Path 是否为空,为空则返回。
  2. 把轨迹线 Path 设置给路径测量器。
  3. 获取轨迹线 Path 的起点与终点的坐标并校验坐标合法性。
private fun findReDrawableCross() {
    // 轨迹线 Path 为空返回
    if (linePath.isEmpty) {
        return
    }

    // 把轨迹线 Path 设置给路径测量器
    linePathMeasure.setPath(linePath, false)

    // 定义起点与终点坐标数组
    val startPoint = FloatArray(2)
    val endPoint = FloatArray(2)
    
    // 获取 Path 长度
    val linePathLength = linePathMeasure.length

    // 计算起点与终点坐标
    linePathMeasure.getPosTan(0F, startPoint, null)
    linePathMeasure.getPosTan(linePathLength, endPoint, null)

    // 校验起点坐标
    val startX = startPoint[0]
    val startY = startPoint[1]
    if (startX == 0F || startY == 0F) {
        return
    }

    // 校验终点坐标
    val endX = endPoint[0]
    val endY = endPoint[1]
    if (endX == 0F || endY == 0F) {
        return
    }
}

重绘正向管道

  1. 获取正向管道两端的单元格。
  2. 判断轨迹线的起点与终点坐标是否都在管道两端的单元格区域内。
  3. 遍历轨迹线,判断轨迹线上点的坐标是否在管道区域内。
  4. 标记正向管道可重绘。
private fun findReDrawableCross() {
    // 省略校验
    
    // 获取正向管道两端的单元格
    val lbRectF = bottomRectFList.first().rectF
    val rtRectF = topRectFList.last().rectF
    
    // 判断轨迹线的起点与终点坐标是否都在管道两端的单元格区域内
    if (((lbRectF.contains(startX, startY) && rtRectF.contains(endX, endY)) ||
         (lbRectF.contains(endX, endY) && rtRectF.contains(startX, startY)))
       ) {
        // 定义 mark 变量为 true
        var mark = true
        
        // 遍历轨迹线
        for (i in 1 until linePathLength.toInt()) {
            
            // 获取轨迹线上点的坐标
            val point = FloatArray(2)
            val posTan = linePathMeasure.getPosTan(i.toFloat(), point, null)
            if (!posTan) {
                mark = false
                break
            }

            // 坐标校验
            val x = point[0]
            val y = point[1]
            if (x == 0F || y == 0F) {
                mark = false
                break
            }

            // 判断轨迹线上点的坐标是否在管道区域内
            if (!positiveCrossRegion.contains(x.toInt(), y.toInt())) {
                mark = false
                break
            }
        }

        if (mark) {
            // 标记正向管道可重绘
            markPositiveCrossReDrawable()
        }
    }
}

// 标记正向管道可重绘
private fun markPositiveCrossReDrawable() {
    if (!positiveCrossPath.isReDrawable) {
        positiveCrossPath.isReDrawable = true
    }
}

重绘反向管道

反向管道的重绘逻辑与正向管道相同:

  1. 获取反向管道两端的单元格。
  2. 判断轨迹线的起点与终点坐标是否都在管道两端的单元格区域内。
  3. 遍历轨迹线,判断轨迹线上点的坐标是否在管道区域内。
  4. 标记反向管道可重绘。
private fun findReDrawableCross() {
    // 省略校验
    
    // 获取反向管道两端的单元格
    val ltRectF = topRectFList.first().rectF
    val rbRectF = bottomRectFList.last().rectF
    
    // 判断轨迹线的起点与终点坐标是否都在管道两端的单元格区域内
    if (((ltRectF.contains(startX, startY) && rbRectF.contains(endX, endY)) ||
         (ltRectF.contains(endX, endY) && rbRectF.contains(startX, startY)))
       ) {
        // 定义 mark 变量为 true
        var mark = true
        
        // 遍历轨迹线
        for (i in 1 until linePathLength.toInt()) {
            
            // 获取轨迹线上点的坐标
            val point = FloatArray(2)
            val posTan = linePathMeasure.getPosTan(i.toFloat(), point, null)
            if (!posTan) {
                mark = false
                break
            }

            // 坐标校验
            val x = point[0]
            val y = point[1]
            if (x == 0F || y == 0F) {
                mark = false
                break
            }

            // 判断轨迹线上点的坐标是否在管道区域内
            if (!reverseCrossRegion.contains(x.toInt(), y.toInt())) {
                mark = false
                break
            }
        }

        if (mark) {
            // 标记反向管道可重绘
            markReverseCrossReDrawable()
        }
    }
}

// 标记反向管道可重绘
private fun markReverseCrossReDrawable() {
    if (!reverseCrossPath.isReDrawable) {
        reverseCrossPath.isReDrawable = true
    }
}

重绘交叉管道的效果如下:

cross

测试完成

最后我们还剩下触摸屏测试完成的判断以及对外提供测试完成的回调,并且测试完成后不再绘制轨迹线。

定义测试完成的回调:

interface TouchPassListener {

    fun onTouchPass()
}

定义是否测试完成变量与测试完成回调变量:

private var isPassed = false

private var mTouchPassListener: TouchPassListener? = null

新增 isTouchPass 方法,在此方法中判断所有单元格和管道是否都被标记为可重绘的:

private fun isTouchPass(): Boolean {
    return leftRectFList.all { it.isReDrawable } &&
    		topRectFList.all { it.isReDrawable } &&
    		rightRectFList.all { it.isReDrawable } &&
    		bottomRectFList.all { it.isReDrawable } &&
    		centerHorizontalRectFList.all { it.isReDrawable } &&
    		centerVerticalRectFList.all { it.isReDrawable } &&
    		positiveCrossPath.isReDrawable &&
    		reverseCrossPath.isReDrawable
}

然后在标记单元格和管道为可重绘的方法中调用 isTouchPass 方法即可:

private fun markBoxReDrawable(rectF: TouchRectF) {
    if (!rectF.isReDrawable) {
        rectF.isReDrawable = true

        if (isTouchPass()) {
            touchPass()
        }
    }
}

private fun markPositiveCrossReDrawable() {
    if (!positiveCrossPath.isReDrawable) {
        positiveCrossPath.isReDrawable = true

        if (isTouchPass()) {
            touchPass()
        }
    }
}

private fun markReverseCrossReDrawable() {
    if (!reverseCrossPath.isReDrawable) {
        reverseCrossPath.isReDrawable = true

        if (isTouchPass()) {
            touchPass()
        }
    }
}

private fun touchPass() {
    isPassed = true
    mTouchPassListener?.onTouchPass()
}

最后效果如下:

touch-view-complete