Android-自定义View-仿某米的触摸屏测试
携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第 1 天,点击查看活动详情
背景
因项目需求变更,公司内的工厂测试程序重写,之前的触摸屏测试已不符合项目需求,遂对比了某米和某为的触摸屏测试效果,个人觉得某米的效果不错,虽然最后没有被采纳,但是这不妨碍我们实现一下某米的触摸屏测试。可能因机型不同,打开某米的触摸屏测试的方式也不尽相同,读者请自行百度相应机型的方式。
某米的触摸屏测试如上图所示,我们简单分析一下:
屏幕四周、垂直居中和水平居中有绘制单元格,触摸后会重绘颜色。
屏幕两个对角线有类似于管道的图案,此图案重绘只能从绘制 X 号的地方开始,一直到管道对端的 X 号地方为止,如果期间手指触摸超出管道的范围即失败。
手指触摸在单元格与管道内的区域滑动时,屏幕会显示滑动轨迹,如果超出区域,则轨迹消失。
所有单元格与两条管道重绘完毕则测试完成。
绘制单元格
思路如下:以左上角的单元格为起点,计算出所有单元格的坐标保存起来,最后在 onDraw
方法中遍历单元格组,根据坐标进行绘制。
首先定义单元格的基准宽高与最终宽高变量:
1 2 3 4 5 private var itemWidthBasic = 90 private var itemHeightBasic = 80 private var itemWidth = -1F private var itemHeight = -1F
其次定义自定义 View 的宽高变量:
1 2 private var viewWidth: Int = -1 private var viewHeight: Int = -1
最后定义单元格在宽高方向上的数量变量:
1 2 private var widthCount = -1 private var heightCount = -1
定义绘制单元格的画笔:
1 2 3 4 5 6 7 private val boxPaint by lazy { Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.GRAY style = Paint.Style.STROKE strokeWidth = 2F } }
定义 TouchRectF 实体,对 RectF 包装一层,增加 isReDrawable
变量,单元格被触摸重绘后标记为 True:
1 2 3 4 5 6 data class TouchRectF (val rectF: RectF, var isReDrawable: Boolean = false ) { fun reset () { isReDrawable = false } }
定义保存单元格坐标的容器,其中包含屏幕上下左右以及垂直与水平居中的坐标容器:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 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 的宽高:
1 2 3 4 5 6 7 8 override fun onLayout (changed: Boolean , left: Int , top: Int , right: Int , bottom: Int ) { super .onLayout(changed, left, top, right, bottom) viewWidth = width viewHeight = height computeRectF() }
下图显示了所有单元格所在的范围:
在 computeRectF
方法中计算单元格的宽高、数量及坐标:
首先以单元格的基准宽高计算单元格宽高方向上的数量
其次以单元格宽高方向上的数量计算单元格的最终宽高
清除之前计算的结果
根据上面单元格范围示意图:
计算并保存左侧单元格的坐标,不包含头和尾,去掉与顶部和底部重叠的单元格
计算并保存顶部单元格的坐标
计算并保存右侧单元格的坐标,不包含头和尾,去掉与顶部和底部重叠的单元格
计算并保存底部单元格的坐标
计算并保存水平居中单元格的坐标,不包含头和尾,去掉与左侧和右侧重叠的单元格
计算并保存垂直居中单元格的坐标,不包含头和尾,去掉与顶部和底部重叠的单元格,且去掉与水平居中重叠的单元格
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 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
中绘制单元格:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 override fun onDraw (canvas: Canvas ) { 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) }
首先做参数校验与画布清空
然后绘制水平方向的单元格,在 drawHorizontalBox
方法中遍历水平方向的单元格坐标容器,再调用 drawBox
方法传入坐标绘制单元格
最后绘制垂直方向的单元格,在 drawVerticalBox
方法中遍历垂直方向的单元格坐标容器,再调用 drawBox
方法传入坐标绘制单元格
在 drawBox
方法中绘制单元格
效果如下:
绘制交叉管道
上面绘制单元格比较简单一些,现在要绘制的两个管道相对复杂一些,本文为了简单,没有完全仿照某米触摸屏测试中管道的 UI 效果。
思路:通过 Path 连接对角两个单元格的顶点组成管道,由于 Path 闭合后,单元格的两个顶点会连接成直线,这里两个顶点的连接使用二阶贝赛尔曲线绘制一个 View 显示范围之外的弧线,这样看起来管道没有起止点,且 Path 也可以闭合,同时也方便判断触摸点是否在管道内。
定义管道 Path 和 Region:
1 2 3 4 5 6 7 8 9 10 11 private val positiveCrossPath = TouchPath()private val positiveCrossRegion = Region()private val reverseCrossPath = TouchPath()private val reverseCrossRegion = Region()
在 computeRectF
方法中计算管道 Path 路径:
重置 Path 路径
计算正向管道 Path,以左下角单元格为起点,右上角单元格为终点绘制 Path。
计算反向管道 Path,以左上角单元格为起点,右下角单元格为终点绘制 Path。
下面代码中的注释说明的更多一些。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 private fun computeRectF () { positiveCrossPath.path.reset() reverseCrossPath.path.reset() val lbRectF = bottomRectFList.first().rectF val rtRectF = topRectFList.last().rectF with(positiveCrossPath.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 ) close() } val positiveCrossRectF = RectF() positiveCrossPath.path.computeBounds(positiveCrossRectF, true ) positiveCrossRegion.setPath(positiveCrossPath.path, positiveCrossRectF.toRegion()) val ltRectF = topRectFList.first().rectF val rbRectF = bottomRectFList.last().rectF with(reverseCrossPath.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 ) close() } val reverseCrossRectF = RectF() reverseCrossPath.path.computeBounds(reverseCrossRectF, true ) reverseCrossRegion.setPath(reverseCrossPath.path, reverseCrossRectF.toRegion()) }
接下来在 onDraw
方法中绘制管道:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 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) }
效果如下:
重绘单元格
单元格与管道已经绘制好了,下面我们先开始重绘单元格。
大体思路:在手指触摸屏幕的时候判断当前触摸的屏幕坐标是否在单元格内,是的话则重绘,否则不重绘。
首先定义重绘单元格的画笔:
1 2 3 4 5 6 private val fillPaint by lazy { Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.GREEN style = Paint.Style.FILL } }
因为单元格与单元格、单元格与管道之间有重叠的部分,突出显示重叠部分哪块单元格没有被重绘,重绘单元格时改变单元格边框的颜色,所以需要定义重绘单元格的画笔:
1 2 3 4 5 6 7 private val redrawBoxPaint by lazy { Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.YELLOW style = Paint.Style.STROKE strokeWidth = 3F } }
我们重写 onTouchEvent
方法,在此方法中处理触摸事件:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 override fun onTouchEvent (event: MotionEvent ) : Boolean { val x = event.x val y = event.y when (event.actionMasked) { MotionEvent.ACTION_DOWN -> { findReDrawableBox(x, y) 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
方法来重绘单元格:
1 2 3 4 5 6 7 8 9 10 11 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:
1 private val linePath = Path()
定义轨迹线画笔:
1 2 3 4 5 6 7 8 9 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
方法增加对滑动重绘单元格和绘制轨迹线的支持:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 override fun onTouchEvent (event: MotionEvent ) : Boolean { val x = event.x val y = event.y when (event.actionMasked) { MotionEvent.ACTION_DOWN -> { 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 { linePath.reset() } invalidate() } MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { linePath.reset() 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
方格,增加轨迹线的绘制:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 override fun onDraw (canvas: Canvas ) { drawTrackLine(canvas) } private fun drawTrackLine (canvas: Canvas ) { if (linePath.isEmpty) { return } canvas.drawPath(linePath, linePaint) }
效果如下:
重绘交叉管道
在某米的触摸屏测试中,笔者发现有以下几个条件需要注意:
如果滑动期间超出管道的范围认为无效。
只能从管道一端开始触摸,即从管道中间触摸视为无效。
如果从管道一端开始,不是通过管道到达另一端认为无效,即开始时是从管道一端开始,期间通过沿单元格滑动到达另一端。
以上几个问题中,第一个问题上面绘制轨迹线时已经解决,下面我们解决其他几个问题。
解决思路:
问题2:判断轨迹线的起点坐标是否在四个顶点单元格区域内。
问题3:判断轨迹线上所有点的坐标是否在管道区域内。
首先定义 PathMeasure 变量,用于获取轨迹线 Path 上各点的坐标:
1 private val linePathMeasure = PathMeasure()
在 onTouchEvent
方法中的 ACTION_MOVE
分支中增加 findReDrawableCross
重绘管道逻辑:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 override fun onTouchEvent (event: MotionEvent ) : Boolean { when (event.actionMasked) { MotionEvent.ACTION_MOVE -> { if (isInTouchableRegion(x, y)) { findReDrawableBox(x, y) findReDrawableCross() } } } return true }
findReDrawableCross
方法代码较多且稍微复杂一些,下面我把代码拆开逐步分析。
轨迹线路径测量及校验
首先校验轨迹线 Path 是否为空,为空则返回。
把轨迹线 Path 设置给路径测量器。
获取轨迹线 Path 的起点与终点的坐标并校验坐标合法性。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 private fun findReDrawableCross () { if (linePath.isEmpty) { return } linePathMeasure.setPath(linePath, false ) val startPoint = FloatArray(2 ) val endPoint = FloatArray(2 ) 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 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 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))) ) { 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 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 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))) ) { 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 } }
重绘交叉管道的效果如下:
测试完成
最后我们还剩下触摸屏测试完成的判断以及对外提供测试完成的回调,并且测试完成后不再绘制轨迹线。
定义测试完成的回调:
1 2 3 4 interface TouchPassListener { fun onTouchPass () }
定义是否测试完成变量与测试完成回调变量:
1 2 3 private var isPassed = false private var mTouchPassListener: TouchPassListener? = null
新增 isTouchPass
方法,在此方法中判断所有单元格和管道是否都被标记为可重绘的:
1 2 3 4 5 6 7 8 9 10 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
方法即可:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 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() }
最后效果如下: