You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Jetpack Compose如何检测用户一笔绘制是否全程在指定矩形区域内

实现Android Compose 描摹绘制区域校验

需求说明

需要实现正方形描摹的一笔画校验逻辑,规则如下:

  • 用户必须一次性连贯完成绘制,中途手指抬起即算本笔结束
  • 绘制过程中手指移出目标正方形有效区域时,立即重置当前绘制路径,弹出Toast提示绘制失败
  • 全程在区域内完成绘制后,弹出Toast提示绘制成功
  • 现有代码已实现基础笔迹绘制、白色正方形目标框绘制,目标框通过drawRect()实现,用户笔迹通过drawPath()实现

效果示意图

现有代码

class DrawingActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MyDrawing()
        }
    }
}

@Composable
fun MyDrawing() {
    val actionIdle = 0
    val actionDown = 1
    val actionMove = 2
    val actionUp = 3
    // 路径、当前触点位置、触摸状态定义
    val path = remember { Path() }
    var motionEvent by remember { mutableStateOf(actionIdle) }
    var currentPosition by remember { mutableStateOf(Offset.Unspecified) }
    val canvasColor: Color by remember { mutableStateOf(Color.LightGray) }
    val drawModifier = Modifier
        .fillMaxWidth()
        .fillMaxHeight()
        .background(canvasColor)
        .clipToBounds()
        .pointerInput(Unit) {
            forEachGesture {
                awaitPointerEventScope {

                    val down: PointerInputChange = awaitFirstDown().also {
                        motionEvent = actionDown
                        currentPosition = it.position
                    }
                    do {
                        val event: PointerEvent = awaitPointerEvent()

                        event.changes
                            .forEachIndexed { index: Int, pointerInputChange: PointerInputChange ->
                                pointerInputChange.consumePositionChange()
                            }

                        motionEvent = actionMove
                        currentPosition = event.changes.first().position
                    } while (event.changes.any { it.pressed })

                    motionEvent = actionUp
                }
            }

        }


    Canvas(
        modifier = drawModifier
            .padding(20.dp)
            .size(500.dp)
    ) {
        val canvasWidth = size.width
        val canvasHeight = size.height
        val line = 1.5f
        val squareSize = canvasWidth/line

        drawRect(
            color = Color.White,
            topLeft = Offset(center.x - canvasWidth / 3, center.y - canvasHeight / 6),
            size = Size(width = squareSize.toFloat(), squareSize.toFloat()),
            style = Stroke(
                width = 50.dp.toPx()
            ),
        )

        when(motionEvent){
            actionDown->{
                path.moveTo(currentPosition.x,currentPosition.y)
            }
            actionMove->{
                if (currentPosition!= Offset.Unspecified){
                    path.lineTo(currentPosition.x,currentPosition.y)

                }
            }
            actionUp->{
                path.lineTo(currentPosition.x,currentPosition.y)
                motionEvent = actionIdle
            }
            else-> Unit
        }

       drawPath(
           color = Color.Cyan,
           path = path,
           style = Stroke(width = 5.dp.toPx(), join = StrokeJoin.Round)
       )

    }

}

实现步骤

1. 修复坐标系错位问题

原代码pointerInput加在全屏大小的modifier上,Canvas又叠加了padding和固定尺寸,会导致手势触点坐标和Canvas内部绘制坐标不一致。需要把pointerInput挪到Canvas的modifier链中,放在padding(20.dp).size(500.dp)之后,保证拿到的触点坐标和绘制坐标系完全对齐。

2. 抽离目标区域边界参数

不要在drawRect方法内写死区域计算逻辑,提前把正方形的边界参数定义成可复用变量,方便手势检测时直接调用。注意Stroke样式的矩形是沿路径居中描边,计算有效区域时要把线宽占的范围算进去。

3. 增加绘制合法性状态

新增状态标记当前这一笔是否始终在有效区域内,同时获取Context用于弹出Toast提示。

4. 全触点流程加区域校验

  • 按下事件:第一时间判断触点是否在有效区域内,不在则直接标记本笔无效,弹出失败提示
  • 移动事件:每次位置更新都做区域校验,一旦检测到触点越界,立即清空绘制路径、标记本笔无效,弹出失败提示
  • 抬起事件:如果本笔全程标记为合法,弹出绘制成功提示,重置状态准备下一次绘制

修改后核心实现代码

class DrawingActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MyDrawing()
        }
    }
}

@Composable
fun MyDrawing() {
    val context = LocalContext.current
    val actionIdle = 0
    val actionDown = 1
    val actionMove = 2
    val actionUp = 3
    val path = remember { Path() }
    var motionEvent by remember { mutableStateOf(actionIdle) }
    var currentPosition by remember { mutableStateOf(Offset.Unspecified) }
    val canvasColor: Color by remember { mutableStateOf(Color.LightGray) }
    // 标记当前绘制是否合法
    var isDrawValid by remember { mutableStateOf(true) }
    // 统一正方形配置参数,保证绘制和检测逻辑用同一套数值
    val squareStrokeWidth = 50.dp.toPx()
    val canvasSize = 500.dp
    val squareSizeRatio = 1.5f

    val baseModifier = Modifier
        .fillMaxWidth()
        .fillMaxHeight()
        .background(canvasColor)
        .clipToBounds()

    Canvas(
        modifier = baseModifier
            .padding(20.dp)
            .size(canvasSize)
            // 修复坐标系偏移:手势检测放在padding、size之后
            .pointerInput(Unit) {
                forEachGesture {
                    awaitPointerEventScope {
                        val canvasWidth = size.width
                        val canvasHeight = size.height
                        val squareSize = canvasWidth / squareSizeRatio
                        val squareTopLeftX = center.x - canvasWidth / 3
                        val squareTopLeftY = center.y - canvasHeight / 6
                        // 计算描边正方形的外边界(描边居中,外扩半倍线宽)
                        val halfStroke = squareStrokeWidth / 2
                        val validLeft = squareTopLeftX - halfStroke
                        val validTop = squareTopLeftY - halfStroke
                        val validRight = squareTopLeftX + squareSize + halfStroke
                        val validBottom = squareTopLeftY + squareSize + halfStroke
                        // 如需严格限制在描边区域绘制,再加内边界判断
                        val innerLeft = squareTopLeftX + halfStroke
                        val innerTop = squareTopLeftY + halfStroke
                        val innerRight = squareTopLeftX + squareSize - halfStroke
                        val innerBottom = squareTopLeftY + squareSize - halfStroke

                        val down: PointerInputChange = awaitFirstDown().also {
                            motionEvent = actionDown
                            currentPosition = it.position
                            isDrawValid = true
                            // 按下时首次校验
                            val x = it.position.x
                            val y = it.position.y
                            if (x !in validLeft..validRight || y !in validTop..validBottom) {
                                isDrawValid = false
                                path.reset()
                                Toast.makeText(context, "绘制失败,超出区域", Toast.LENGTH_SHORT).show()
                            }
                        }

                        do {
                            val event: PointerEvent = awaitPointerEvent()
                            event.changes.forEach { pointerInputChange ->
                                pointerInputChange.consumePositionChange()
                            }

                            motionEvent = actionMove
                            currentPosition = event.changes.first().position
                            // 移动过程实时校验
                            if (isDrawValid) {
                                val x = currentPosition.x
                                val y = currentPosition.y
                                if (x !in validLeft..validRight || y !in validTop..validBottom) {
                                    isDrawValid = false
                                    path.reset()
                                    Toast.makeText(context, "绘制失败,超出区域", Toast.LENGTH_SHORT).show()
                                }
                            }
                        } while (event.changes.any { it.pressed })

                        motionEvent = actionUp
                        // 抬起时判定结果
                        if (isDrawValid) {
                            Toast.makeText(context, "绘制成功", Toast.LENGTH_SHORT).show()
                        }
                        // 重置状态准备下一次绘制
                        path.reset()
                        motionEvent = actionIdle
                        currentPosition = Offset.Unspecified
                    }
                }
            }
    ) {
        val canvasWidth = size.width
        val canvasHeight = size.height
        val squareSize = canvasWidth / squareSizeRatio

        drawRect(
            color = Color.White,
            topLeft = Offset(center.x - canvasWidth / 3, center.y - canvasHeight / 6),
            size = Size(width = squareSize, squareSize),
            style = Stroke(
                width = squareStrokeWidth
            ),
        )

        // 仅合法状态下记录绘制路径
        if (isDrawValid) {
            when(motionEvent){
                actionDown->{
                    path.moveTo(currentPosition.x,currentPosition.y)
                }
                actionMove->{
                    if (currentPosition!= Offset.Unspecified){
                        path.lineTo(currentPosition.x,currentPosition.y)
                    }
                }
                actionUp->{
                    path.lineTo(currentPosition.x,currentPosition.y)
                }
                else-> Unit
            }
        }

       drawPath(
           color = Color.Cyan,
           path = path,
           style = Stroke(width = 5.dp.toPx(), join = StrokeJoin.Round)
       )
    }
}

补充:如果需要严格校验用户必须沿着白色正方形描边绘制,只要在判断条件里加上「触点不能落在内边界围成的空白区域内」即可,即判断条件改为(x in validLeft..validRight && y in validTop..validBottom) && (x !in innerLeft..innerRight || y !in innerTop..innerBottom)。

内容的提问来源于stack exchange,提问作者Mohamed Rimshad

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.26 13:06:22