Android-自定義View-仿某米的觸摸屏測試

語言: CN / TW / HK

攜手創作,共同成長!這是我參與「掘金日新計劃 · 8 月更文挑戰」的第 1 天,點擊查看活動詳情


背景

因項目需求變更,公司內的工廠測試程序重寫,之前的觸摸屏測試已不符合項目需求,遂對比了某米和某為的觸摸屏測試效果,個人覺得某米的效果不錯,雖然最後沒有被採納,但是這不妨礙我們實現一下某米的觸摸屏測試。可能因機型不同,打開某米的觸摸屏測試的方式也不盡相同,讀者請自行百度相應機型的方式。

mi

某米的觸摸屏測試如上圖所示,我們簡單分析一下:

  • 屏幕四周、垂直居中和水平居中有繪製單元格,觸摸後會重繪顏色。
  • 屏幕兩個對角線有類似於管道的圖案,此圖案重繪只能從繪製 X 號的地方開始,一直到管道對端的 X 號地方為止,如果期間手指觸摸超出管道的範圍即失敗。
  • 手指觸摸在單元格與管道內的區域滑動時,屏幕會顯示滑動軌跡,如果超出區域,則軌跡消失。
  • 所有單元格與兩條管道重繪完畢則測試完成。

繪製單元格

思路如下:以左上角的單元格為起點,計算出所有單元格的座標保存起來,最後在 onDraw 方法中遍歷單元格組,根據座標進行繪製。

首先定義單元格的基準寬高與最終寬高變量:

```kotlin private var itemWidthBasic = 90 private var itemHeightBasic = 80

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

其次定義自定義 View 的寬高變量:

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

最後定義單元格在寬高方向上的數量變量:

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

定義繪製單元格的畫筆:

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

定義 TouchRectF 實體,對 RectF 包裝一層,增加 isReDrawable 變量,單元格被觸摸重繪後標記為 True:

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

fun reset() {
    isReDrawable = false
}

} ```

定義保存單元格座標的容器,其中包含屏幕上下左右以及垂直與水平居中的座標容器:

```kotlin // 屏幕左側單元格的座標容器 private val leftRectFList = mutableListOf()

// 屏幕頂部單元格的座標容器 private val topRectFList = mutableListOf()

// 屏幕右側單元格的座標容器 private val rightRectFList = mutableListOf()

// 屏幕底部單元格的座標容器 private val bottomRectFList = mutableListOf()

// 屏幕水平居中單元格的座標容器 private val centerHorizontalRectFList = mutableListOf()

// 屏幕垂直居中單元格的座標容器 private val centerVerticalRectFList = mutableListOf() ```

選擇在 onLayout 方法中計算所有單元格的座標並獲取 View 的寬高:

```kotlin 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()

} ```

下圖顯示了所有單元格所在的範圍:

touch-view-scope.png

computeRectF 方法中計算單元格的寬高、數量及座標:

  1. 首先以單元格的基準寬高計算單元格寬高方向上的數量
  2. 其次以單元格寬高方向上的數量計算單元格的最終寬高
  3. 清除之前計算的結果

根據上面單元格範圍示意圖:

  • 計算並保存左側單元格的座標,不包含頭和尾,去掉與頂部和底部重疊的單元格
  • 計算並保存頂部單元格的座標
  • 計算並保存右側單元格的座標,不包含頭和尾,去掉與頂部和底部重疊的單元格
  • 計算並保存底部單元格的座標
  • 計算並保存水平居中單元格的座標,不包含頭和尾,去掉與左側和右側重疊的單元格
  • 計算並保存垂直居中單元格的座標,不包含頭和尾,去掉與頂部和底部重疊的單元格,且去掉與水平居中重疊的單元格

```kotlin 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 中繪製單元格:

```kotlin 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 方法中繪製單元格

效果如下:

touch-view-rectf.png

繪製交叉管道

上面繪製單元格比較簡單一些,現在要繪製的兩個管道相對複雜一些,本文為了簡單,沒有完全仿照某米觸摸屏測試中管道的 UI 效果。

思路:通過 Path 連接對角兩個單元格的頂點組成管道,由於 Path 閉合後,單元格的兩個頂點會連接成直線,這裏兩個頂點的連接使用二階貝賽爾曲線繪製一個 View 顯示範圍之外的弧線,這樣看起來管道沒有起止點,且 Path 也可以閉合,同時也方便判斷觸摸點是否在管道內。

定義管道 Path 和 Region:

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

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

computeRectF 方法中計算管道 Path 路徑:

  • 重置 Path 路徑
  • 計算正向管道 Path,以左下角單元格為起點,右上角單元格為終點繪製 Path。
  • 計算反向管道 Path,以左上角單元格為起點,右下角單元格為終點繪製 Path。
  • 下面代碼中的註釋説明的更多一些。

```kotlin 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 方法中繪製管道:

```kotlin 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) } ```

效果如下:

touch-view.png

重繪單元格

單元格與管道已經繪製好了,下面我們先開始重繪單元格。

大體思路:在手指觸摸屏幕的時候判斷當前觸摸的屏幕座標是否在單元格內,是的話則重繪,否則不重繪。

首先定義重繪單元格的畫筆:

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

因為單元格與單元格、單元格與管道之間有重疊的部分,突出顯示重疊部分哪塊單元格沒有被重繪,重繪單元格時改變單元格邊框的顏色,所以需要定義重繪單元格的畫筆:

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

我們重寫 onTouchEvent 方法,在此方法中處理觸摸事件:

```kotlin 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 方法來重繪單元格:

kotlin // 重構 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:

kotlin private val linePath = Path()

定義軌跡線畫筆:

kotlin 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 方法增加對滑動重繪單元格和繪製軌跡線的支持:

```kotlin 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 方格,增加軌跡線的繪製:

```kotlin override fun onDraw(canvas: Canvas) {

// 省略繪製單元格和管道代碼

// 繪製軌跡線
drawTrackLine(canvas)

}

private fun drawTrackLine(canvas: Canvas) { // 判斷軌跡線 Path 是否為空 if (linePath.isEmpty) { return }

// 繪製軌跡線
canvas.drawPath(linePath, linePaint)

} ```

效果如下:

touch-view-line.png

重繪交叉管道

在某米的觸摸屏測試中,筆者發現有以下幾個條件需要注意:

  1. 如果滑動期間超出管道的範圍認為無效。
  2. 只能從管道一端開始觸摸,即從管道中間觸摸視為無效。
  3. 如果從管道一端開始,不是通過管道到達另一端認為無效,即開始時是從管道一端開始,期間通過沿單元格滑動到達另一端。

以上幾個問題中,第一個問題上面繪製軌跡線時已經解決,下面我們解決其他幾個問題。

解決思路:

  • 問題2:判斷軌跡線的起點座標是否在四個頂點單元格區域內。
  • 問題3:判斷軌跡線上所有點的座標是否在管道區域內。

首先定義 PathMeasure 變量,用於獲取軌跡線 Path 上各點的座標:

kotlin private val linePathMeasure = PathMeasure()

onTouchEvent 方法中的 ACTION_MOVE 分支中增加 findReDrawableCross 重繪管道邏輯:

```kotlin 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 的起點與終點的座標並校驗座標合法性。

```kotlin 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. 標記正向管道可重繪。

```kotlin 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. 標記反向管道可重繪。

```kotlin 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 } } ```

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

touch-view-cross.png

測試完成

最後我們還剩下觸摸屏測試完成的判斷以及對外提供測試完成的回調,並且測試完成後不再繪製軌跡線。

定義測試完成的回調:

```kotlin interface TouchPassListener {

fun onTouchPass()

} ```

定義是否測試完成變量與測試完成回調變量:

```kotlin private var isPassed = false

private var mTouchPassListener: TouchPassListener? = null ```

新增 isTouchPass 方法,在此方法中判斷所有單元格和管道是否都被標記為可重繪的:

kotlin 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 方法即可:

```kotlin 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.png