给 compose draw 绘制的非规则图形添加点击监听

955 阅读8分钟

我报名参加金石计划1期挑战——瓜分10万奖池,这是我的第4篇文章,点击查看活动详情

前言

导言

在之前的两篇文章中,我们从实例出发,以实践的方式简单介绍了 compose 自定义绘制(如何自己绘制想要的控件)、为自定义绘制增加动画(让控件动起来)。

在这篇文章中,我们依然从实例出发,介绍怎么为自定义绘制图案添加触摸监听,特别是一些"奇形怪状"的异形图案应该怎么判断触摸坐标。

项目背景

在我的魔改车钥匙系列文章的最后一篇 《魔改车钥匙实现远程控车:(4)基于compose和经典蓝牙编写一个控制APP》 中,我们使用 compose 编写了一个简单的控制 APP,但是正如我在文章中说的,当前APP界面十分简陋,日后有时间我会再优化这个界面。

其实当时我对于界面已经有了想法,只是因为想要快速实现功能,所以没有去纠结 UI,现在正好闲下来了,可以去实现我预想的 UI 效果了。

我预想中的 UI 效果大致是一个"拟真"的遥控器界面,类似于这样:

s1.png

相信各位看到这种样式,第一反应就是使用基础 composable 实现不了吧?

没错,确实,所以我们又得使用自定义绘制了,但是问题来了,绘制简单,怎么监听点击事件呢?

这就是本文需要探讨的问题。

实现

基础知识

在开始探讨之前,我们需要知道一些基础知识。

在 compose 中是怎么监听触摸事件的(单击、双击、长按、滑动等等)?

注意:其实基于 声明式编程 的 compose 并不存在 监听 这一概念,常用的点击事件也是通过匿名函数回调的,但是这里为了方便叙述,我措辞使用的依然是 监听

在 compose 中,几乎所有 composable 的点击事件都是通过给 Modifier 添加 clickable 修饰来实现的。不过你也可以说,不对啊, Button 这一类 composable 不是给 Modifier 添加修饰啊,是它自己就提供了一个点击回调啊。

哈哈,其实如果你看过源码就会发现,即使是 Button 等等这些自带点击回调的 composable 最终都是靠给 Modifier 添加 clickable 来实现点击回调的。

例如:

Text(text = "我是一个可以点击的文字", Modifier.clickable { 
    // 被点击了
})

如果是需要更多的触摸事件,则可以使用:

Text(text = "我是一个可以点击的文字", Modifier.pointerInput(Unit) {
    // 这里可以接收更多事件
})

怎么给自定义绘制内容添加监听呢?

上一节简单介绍了怎么给 composable 添加监听,那么问题来了,怎么给我们自己绘制的内容添加呢?

答案是,一样的,但是也不完全一样。

为什么这么说呢,先看一个例子,假设有这么三个方格,每个方格都是一个按钮:

Canvas(
    modifier = Modifier
        .fillMaxSize()
) {
    drawRect(Color.Black, topLeft = Offset(0f, 0f), size = Size(50f, 50f))

    drawRect(Color.Red, topLeft = Offset(60f, 0f), size = Size(50f, 50f))

    drawRect(Color.Blue, topLeft = Offset(120f, 0f), size = Size(50f, 50f))
}

d1.png

怎么监听按钮点击?

要知道,绘制的作用域是 DrawScope 而不是 Compose 了。

并且,在 DrawScope 中也没有提供任何触摸相关的 API。如果想要监听触摸事件,我们只能在 compose 作用域中的 Modifier 中添加。

你可能会说,那还不简单,那我们直接给提供了 DrawScope 的 composable 加上监听不就得了?就像这样

Canvas(
    modifier = Modifier
        .fillMaxSize()
        .clickable {
            Log.i("el", "Test: click!")
        }
) {
    drawRect(Color.Black, topLeft = Offset(0f, 0f), size = Size(50f, 50f))

    drawRect(Color.Red, topLeft = Offset(60f, 0f), size = Size(50f, 50f))

    drawRect(Color.Blue, topLeft = Offset(120f, 0f), size = Size(50f, 50f))
}

你再看看,这代码对吗?如果你想监听的是这个 Canvas 整体,那确实没问题的。

但是,如果你想分别监听其中的不同部分,例如上面例子中的三个方格,显然这是不对的。

那么,我们要怎么分别监听呢?

监听方案

其实,这个也不难解决,只要我们能够拿到手指触摸的坐标点,再把这个坐标点和我们绘制的内容坐标点进行比对,如果点在按钮1区域内,则认为点击的是按钮1;如果坐标点在按钮2区域内则认为点击的是按钮2;以此类推。

所以,首先我们需要获取到点击的坐标点,接下来我们就只以单击这个触摸事件为例了,其他事件的处理方法也是一样的,只是换个 API 而已。

为了获取单击的坐标点,我们不能直接添加 clickable 修饰了,而是需要使用 pointerInput ,例如:

Modifier
    .pointerInput(Unit) {
        detectTapGestures(
            onTap = { offset: Offset ->
                Log.i("el", "click at $offset")
            }
        )
    }

这样,我们就拿到了点击的坐标点 offset

d2.png

有了坐标点,那么我们怎么去判断这个坐标点属于哪个按钮的呢?

直接计算

首当其冲的,我们能想到的当然是直接计算了。

毕竟这个图都是我们自己画,难道我们自己还不知道它的区域范围吗?

例如,上面的三个方块的例子,我们可以这样来判断点击的是哪个方块:

Canvas(
    modifier = Modifier
        .fillMaxSize()
        .pointerInput(Unit) {
            detectTapGestures(
                onTap = { offset: Offset ->
                    if (offset.x in 0f..50f && offset.y in 0f..50f) Log.i("el", "Test: click black block")
                    if (offset.x in 60f..110f && offset.y in 0f..50f) Log.i("el", "Test: click red block")
                    if (offset.x in 120f..170f && offset.y in 0f..50f) Log.i("el", "Test: click blue block")
                }
            )
        }
) {
    drawRect(Color.Black, topLeft = Offset(0f, 0f), size = Size(50f, 50f))

    drawRect(Color.Red, topLeft = Offset(60f, 0f), size = Size(50f, 50f))

    drawRect(Color.Blue, topLeft = Offset(120f, 0f), size = Size(50f, 50f))
}

d3.gif

如果是上面说的三个方块,那确实非常好算;那要是换成我开头说的那个模拟遥控器界面呢?好像也能算,不过是圆形和扇形嘛,也还有公式可以算;那,我换成一个完全无规则的图形呢?

比如一个地图,如果我现在绘制出了一个地图,然后需要监听地图中每一个区块的点击事件,该怎么算?地图的边界是完全没有任何规律的。

所以,直接计算只适合于简单的基础图形,并不适合我们这里的使用场景。

将可触摸范围缩小到方便计算的基础图形

既然图形太复杂,不好计算,那么我们就放弃一点用户体验,不要这么复杂的点击区域判断了。

我们只判断图形中的某个方便计算的区域不就行了?

例如:

d4.png

在这个例子中,我们只判断触摸点坐标是否在黄色矩形内,只有在黄色矩形内我们才认为它是点击了相应的按钮,否则我们就不予响应。

咋一看,好像挺不错的呢。

确实,如果不怕被测试追着你问为什么这个按键有时候点击会无响应的话,你可以试试这样干,哈哈哈。

使用前端方案

关于这个问题,我咨询了前端大佬 kirainmoe (什么?我为什么问搞前端的不问搞安卓的?因为我不认识搞安卓的啊……),前端大佬给出的答复是,对于这种情况,前端通常会多绘制一个离屏 canvas ,然后给需要监听的地方按照 id 给涂上相应的颜色,在点击显示的 canvas 时,用这个坐标在上色后的离屏 canvas 中获取颜色值,然后对比颜色和 id 确定点击的是哪个区域。

直接说可能不好理解,我画个图你们就好理解了。

d5.png

如图所示,左边是我们实际显示的 canvas ,右边是和左边一模一样的离屏 canvas ,离屏 canvas 仅用于计算,实际不会绘制,也不会显示出来。

在这个界面中,我们认为这个大猩猩是一个按键,我们需要分别监听不同的大猩猩,如果直接计算显然是无法计算的,因为这是个不规则的图形。

但是我们可以给离屏 canvas 上的不同大猩猩填充不同的颜色,并且建立对应关系,即: 红色-按钮1、绿色-按钮2、蓝色-按钮3。

当我们点击实际显示的大猩猩,即左边 canvas 上的大猩猩时,会拿到一个坐标值,我们使用这个坐标值从离屏 canvas ,即右边的 canvas 中相应的坐标点获取颜色,如果颜色是红色则认为点击的是按钮1;绿色认为点击的是按钮2;蓝色认为点击的是按钮3;其他颜色则认为点击的不是有效区域。

怎么一说,是不是觉得这方法真的太巧妙了,完全免去了我们自己计算点是否在相应区域内的问题。

然而……不幸的是,在 compose 中,我们无法拿到坐标点的颜色值,除非我们把这个 canvas 转成 bitmap……显然,这样做的性能代价会很高。

所以这个方法虽然十分巧妙,但是却不适合我们使用。

如果你执意想试试的话,这里有一篇文章教你怎么将 composable 转为 bitmap :

Jetpack Composable 🚀 to Bitmap Image 🌆

使用 Region

虽然上一节中的前端常用方法在安卓中无法使用,但是它的思路十分值得学习。

那么,在安卓中有没有类似的东西可以实现呢?

你还真别说,确实有,那就是 Region 。

Region,直译过来就是区域的意思,它是一个用单个或多个矩形(Rect)通过一定方式组合在一起形成任意图案的区域范围的类。

我们可以通过 Path 创建 Region ,这就意味着,我们可以完全复制一份我们绘制的内容生成 Region。

虽然 Region 不是 compose 工具包的内容之一,用来创建 Region 的 Path 也不是 compose 中的 Path,但是 compose 的 Path 可以通过扩展函数 path.asAndroidPath() 简单的转换为安卓绘制中的 Path。

并且,最最重要的是,Region 有一个方法 contains(int x, int y) 可以用来判断参数中指定的坐标值是否在该 Region 范围内!

因为这里我们的使用场景仅需要通过我们绘制的 path 生成 Region ,所以我们就不过多介绍关于 Region 的内容,如果有感兴趣的可以阅读文末附上的参考资料中其他大佬的文章学习。

我们先举个简单的例子:

val region = Region()

Column(Modifier.fillMaxSize()) {
    Canvas(
        modifier = Modifier
            .fillMaxSize()
            .pointerInput(Unit) {
                detectTapGestures(
                    onTap = { offset: Offset ->
                        Log.i(
                            "test",
                            "点击坐标 $offset, 是否在图形区域内=${
                                region.contains(
                                    offset.x.toInt(),
                                    offset.y.toInt()
                                )
                            }"
                        )
                    }
                )
            }
    ) {
        val path = Path()
        path.addOval(Rect(10f, 10f, 160f, 320f))
        region.setPath(path.asAndroidPath(), Region(10, 10, 160, 320))
        drawPath(path, color = Color.Black)
    }
}

在这个例子中,我们使用 path 简单的创建了一个椭圆形,然后将这个 path 添加到 region 中。最后绘制出这个 path 。

并且在点击回调中,通过把点击的坐标传递给 region.contains() 用于判断是否点击的是这个图形的范围。

需要注意的是,region.setPath(path.asAndroidPath(), Region(10, 10, 160, 320)) 中,setPath 必须设置一个新的 Region ,此时添加进 region 中的 path 实际上只是新创建的这个 Region 与 path 的交集。

这里因为我们想要将整个 path 全部添加进去,所以我创建了一个一定是比 path 区域大的 Region 。

对了,Region(10, 10, 160, 320) 这个初始化 Region 的四个参数分别是矩形的四个点的坐标值,还记得吗?前面我们说过,Region 实际就是由一个或多个矩形组成的任意图形,这里的初始化就是用一个矩形来初始化。

上面的代码运行效果如下:

d6.gif

经过上面的测试,证明该方法是可行的!

不过上面只示范了一个图形的情况,可能会有人说,那很多个图形呢?

行,那我写一个多个图形的例子:

@Composable
fun Test() {

    var text by remember { mutableStateOf("") }

    val regionList = listOf(
        Region(),
        Region(),
        Region()
    )

    val path = Path()

    Box(Modifier.fillMaxSize()) {
        Canvas(
            modifier = Modifier
                .fillMaxSize()
                .pointerInput(Unit) {
                    detectTapGestures(
                        onTap = { offset: Offset ->
                            if (regionList[0].contains(offset.x.toInt(), offset.y.toInt())) {
                                Log.i("el", "Test: click Red")
                                text = "click Red Button"
                            }
                            if (regionList[1].contains(offset.x.toInt(), offset.y.toInt())) {
                                Log.i("el", "Test: click Green")
                                text = "click Green Button"
                            }
                            if (regionList[2].contains(offset.x.toInt(), offset.y.toInt())) {
                                Log.i("el", "Test: click Blue")
                                text = "click Blue Button"
                            }
                        }
                    )
                }
        ) {
            // draw button red
            path.addOval(Rect(10f, 10f, 160f, 320f))
            regionList[0].setPath(path.asAndroidPath(), Region(0, 0, size.width.toInt(), size.height.toInt()))  // 这里偷懒了,直接创建了一个布满整个画布的 Region ,其实无所谓,因为是和 path 取交集,但是各位在使用的时候一定要根据自己的需求写,不要学我偷懒,不然出错了我可不负责
            drawPath(path, color = Color.Red)

            // draw button green
            path.reset()
            path.addOval(Rect(10f, 330f, 160f, 650f))
            regionList[1].setPath(path.asAndroidPath(), Region(0, 0, size.width.toInt(), size.height.toInt()))
            drawPath(path, color = Color.Green)

            // draw button blue
            path.reset()
            path.addOval(Rect(10f, 660f, 160f, 980f))
            regionList[2].setPath(path.asAndroidPath(), Region(0, 0, size.width.toInt(), size.height.toInt()))
            drawPath(path, color = Color.Blue)
        }

        Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.Bottom, horizontalAlignment = Alignment.CenterHorizontally) {
            Text(text = text, fontSize = 22.sp)
        }
    }
}

运行效果如下:

d7.gif

最后,我们来实现文章开头提到的模拟遥控器效果(本来想偷懒不画了的,但是评论区有人想看,那我就补上吧)

@Composable
fun Test() {

    val okColor = Color(0xFF49A0F8)

    var text by remember { mutableStateOf("") }

    val regionList = listOf(
        Region(),
        Region(),
        Region(),
        Region(),
        Region()
    )

    val path = Path()
    val okPath = Path()  // 这个路径需要用来做运算,所以单独声明一个

    Box(Modifier.fillMaxSize()) {
        Canvas(
            modifier = Modifier
                .fillMaxSize()
                .background(Color.Black)
                .pointerInput(Unit) {
                    detectTapGestures(
                        onTap = { offset: Offset ->
                            if (regionList[0].contains(offset.x.toInt(), offset.y.toInt())) {
                                Log.i("el", "Test: click OK")
                                text = "click OK Button"
                            }
                            if (regionList[1].contains(offset.x.toInt(), offset.y.toInt())) {
                                Log.i("el", "Test: click Button1")
                                text = "click Button1 Button"
                            }
                            if (regionList[2].contains(offset.x.toInt(), offset.y.toInt())) {
                                Log.i("el", "Test: click Button2")
                                text = "click Button2 Button"
                            }
                            if (regionList[3].contains(offset.x.toInt(), offset.y.toInt())) {
                                Log.i("el", "Test: click Button3")
                                text = "click Button3 Button"
                            }
                            if (regionList[4].contains(offset.x.toInt(), offset.y.toInt())) {
                                Log.i("el", "Test: click Button4")
                                text = "click Button4 Button"
                            }
                        }
                    )
                }
        ) {
            // 绘制外廓圆(这个园不用点击事件,只是一个线段)
            path.reset()
            path.addOval(Rect(Offset(size.width/2, size.height/2), 500f))
            drawPath(path, Color.White)

            // 绘制最中间的 OK 按钮
            okPath.addOval(Rect(Offset(size.width/2, size.height/2), 200f))
            regionList[0].setPath(okPath.asAndroidPath(), Region(0, 0, size.width.toInt(), size.height.toInt()))  // 这里偷懒了,直接创建了一个不满整个画布的 Region ,其实无所谓,因为是和 path 取交集,但是各位在使用的时候一定要根据自己的需求写,不要学我偷懒,不然出错了我可不负责
            drawPath(okPath, color = okColor)

            // 绘制按钮1
            path.reset()
            path.addArc(Rect(Offset(size.width/2, size.height/2), 500f), 225f, 90f)
            path.lineTo(size.width/2, size.height/2)
            path.op(path, okPath, PathOperation.Difference)
            regionList[1].setPath(path.asAndroidPath(), Region(0, 0, size.width.toInt(), size.height.toInt()))
            drawPath(path, color = okColor, style = Stroke(width = 2f))

            // 绘制按钮2
            path.reset()
            path.addArc(Rect(Offset(size.width/2, size.height/2), 500f), 315f, 90f)
            path.lineTo(size.width/2, size.height/2)
            path.op(path, okPath, PathOperation.Difference)
            regionList[2].setPath(path.asAndroidPath(), Region(0, 0, size.width.toInt(), size.height.toInt()))
            drawPath(path, color = okColor, style = Stroke(width = 2f))

            // 绘制按钮3
            path.reset()
            path.addArc(Rect(Offset(size.width/2, size.height/2), 500f), 45f, 90f)
            path.lineTo(size.width/2, size.height/2)
            path.op(path, okPath, PathOperation.Difference)
            regionList[3].setPath(path.asAndroidPath(), Region(0, 0, size.width.toInt(), size.height.toInt()))
            drawPath(path, color = okColor, style = Stroke(width = 2f))

            // 绘制按钮4
            path.reset()
            path.addArc(Rect(Offset(size.width/2, size.height/2), 500f), 135f, 90f)
            path.lineTo(size.width/2, size.height/2)
            path.op(path, okPath, PathOperation.Difference)
            regionList[4].setPath(path.asAndroidPath(), Region(0, 0, size.width.toInt(), size.height.toInt()))
            drawPath(path, color = okColor, style = Stroke(width = 2f))

            // 绘制文字
            drawIntoCanvas {
                val paint = android.graphics.Paint().apply {
                    color = android.graphics.Color.WHITE
                    textSize = 24.sp.toPx()
                }

                // OK
                it.nativeCanvas.drawText(
                    "OK",
                    size.width/2-50,
                    size.height/2+25,
                    paint
                )

                paint.color = android.graphics.Color.BLACK

                // Button 1
                it.nativeCanvas.drawText(
                    "Button1",
                    size.width/2-110,
                    size.height/2-250,
                    paint
                )

                // Button 2
                it.nativeCanvas.drawText(
                    "Button2",
                    size.width/2+250,
                    size.height/2+20,
                    paint
                )

                // Button 3
                it.nativeCanvas.drawText(
                    "Button3",
                    size.width/2-110,
                    size.height/2+300,
                    paint
                )

                // Button 4
                it.nativeCanvas.drawText(
                    "Button4",
                    size.width/2-450,
                    size.height/2+20,
                    paint
                )
            }

        }

        Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.Bottom, horizontalAlignment = Alignment.CenterHorizontally) {
            Text(text = text, fontSize = 22.sp, color = Color.White)
        }
    }
}

运行效果:

d8.gif

总结

通过上面的分析探讨,我们可以知道,在 compose 中如果想要监听绘制内容的触摸事件,最好的方法还是使用 path 绘制,配合生成 Region 用于检测触摸的坐标点是否在指定范围之内。

当然,这只是我个人的想法,如果各位大佬还有更好的方案,希望能不吝赐教!