刮刮卡效果的文章不少,大多数是基于覆盖层和内容层两张图片相交实现的,作者希望内容层可以是任何布局,而不局限于图片,于是决定自己写一个,效果如下:
思路
实现思路比较简单:
- 监听手势,生成触摸路径
- 根据触摸路径,挖空覆盖层
- 监听覆盖层被挖空的百分比,达到某个阈值后,清空覆盖层
监听手势生成路径
这个好解决,直接看示例代码:
private fun Modifier.scratchcardGesture(
path: Path,
): Modifier = pointerInput(path) {
detectDragGestures(
onDragStart = { offset ->
// 开始拖动
path.moveTo(offset.x, offset.y)
},
onDrag = { _, dragAmount ->
// 拖动中,dragAmount表示拖动的偏移量
path.relativeLineTo(dragAmount.x, dragAmount.y)
},
)
}
逻辑比较简单,代码已经注释,就不赘述了。
挖空覆盖层
挖空效果,官方文档中有类似案例,可以参考这里
核心代码如下:
Modifier
.graphicsLayer {
// 1
compositingStrategy = CompositingStrategy.Offscreen
}
.drawWithContent {
val dotSize = size.width / 8f
drawCircle(
Color.Black,
radius = dotSize,
center = Offset(
x = size.width - dotSize,
y = size.height - dotSize
),
// 2
blendMode = BlendMode.Clear
)
}
就是注释1和注释2处的设置,两处都要设置才可以:
即图中,红圈周围一圈透明的挖空效果。
如果不设置CompositingStrategy.Offscreen,则效果如下:
此时红圈周围一圈变成了黑色。
挖空百分比
当用户刮开一定的覆盖层之后,要清空覆盖层,显示底部完整内容,也就是要计算挖空面积占总面积的百分比。
由于触摸路径是不规则的,所以计算面积比较复杂,可以换个角度:计算挖空像素点占总像素点的百分比。
因此需要对覆盖层截图,获取像素点信息。如何截图呢?万能的官方文档:
val coroutineScope = rememberCoroutineScope()
val graphicsLayer = rememberGraphicsLayer()
Box(
modifier = Modifier
.drawWithContent {
// call record to capture the content in the graphics layer
graphicsLayer.record {
// draw the contents of the composable into the graphics layer
this@drawWithContent.drawContent()
}
// draw the graphics layer on the visible canvas
drawLayer(graphicsLayer)
}
.clickable {
coroutineScope.launch {
val bitmap = graphicsLayer.toImageBitmap()
// do something with the newly acquired bitmap
}
}
.background(Color.White)
) {
Text("Hello Android", fontSize = 26.sp)
}
上面是官网的代码,核心步骤如下:
rememberGraphicsLayer()创建GraphicsLayer- 在
drawWithContent中使用GraphicsLayer - 通过
GraphicsLayer.toImageBitmap()获取截图
获取的截图对象是ImageBitmap,它提供了读取像素点的方法:
fun readPixels(
buffer: IntArray,
startX: Int = 0,
startY: Int = 0,
width: Int = this.width,
height: Int = this.height,
bufferOffset: Int = 0,
stride: Int = width
)
默认情况下,只需传一个IntArray给它用来接收像素点即可,看一下计算代码:
private suspend fun calculateProgress(): Float = withContext(Dispatchers.Default) {
// 获取截图
val bitmap = graphicsLayer.toImageBitmap()
// 像素点总数量
val totalCount = bitmap.width * bitmap.height
if (totalCount > 0) {
IntArray(totalCount)
// 读取像素点
.also { bitmap.readPixels(it) }
// 计算透明像素点的数量
.fold(0) { acc, pixel -> if (pixel == 0) acc + 1 else acc }
// 计算挖空百分比
.let { it.toFloat() / totalCount }
} else {
0f
}
}
代码已经注释,就不赘述了。
注意:GraphicsLayer这种截图方式Compose 1.7.0开始才有。
问题
通过上面的分析,流程已经走通了,具体实现就不一一叙述了,说一说封装过程中遇到的问题或者细节。
问题一
和Compose大多数封装类似,定义一个状态类ScratchcardState,把路径Path保存在类中:
class ScratchcardState {
// 挖空路径
val path = Path()
// 手势开始拖动
fun onDragStart(value: Offset) {
// 修改路径
path.moveTo(value.x, value.y)
}
// 手势拖动中
fun onDragAmount(value: Offset) {
// 修改路径
path.relativeLineTo(value.x, value.y)
}
}
然后在draw中,读取path进行绘制:
Modifier.drawWithContent {
drawContent()
drawPath(
// 绘制path
path = state.path,
color = Color.Black,
style = Stroke(
width = thicknessPx,
cap = StrokeCap.Round,
join = StrokeJoin.Round,
),
blendMode = BlendMode.Clear,
)
}
这样写是没效果的,因为Path内部的变化不能被Compose跟踪重绘。
我们可以用MutableState保存一个变量redraw,在Path变化时修改它:
class ScratchcardState {
val path = Path()
// 触发重新绘制的变量
var redraw by mutableIntStateOf(0)
fun onDragAmount(value: Offset) {
path.relativeLineTo(value.x, value.y)
// 修改变量,触发重绘
redraw++
}
}
然后在draw中读取redraw:
drawWithContent {
// 读取redraw
state.redraw
// ...
}
这样redraw变化时,就能触发重绘了。
有的读者可能会有疑问,为什么不用mutableStateOf直接保存Path,再通过copy创建一个新的Path?
这里主要是为了性能考虑,因为手势拖动是一个触发比较频繁的操作,相对来说,使用Int自增要轻量些。
问题二
什么时候计算挖空百分比?理想情况下,应该是手指边拖动边计算,但是每次变化都触发计算比较耗性能,因为要遍历每一个像素点。
我们可以利用Flow.sample来实现间隔计算,看看简化代码:
fun startCalculate() {
if (_calculateJob == null) {
_calculateJob = coroutineScope.launch {
snapshotFlow { redraw }
// 限制最快每500毫秒执行一次
.sample(500)
.collect {
val progress = calculateProgress()
if (progress >= getClearThreshold()) {
// 如果挖空进度超过阈值,清空覆盖层
clear()
}
}
}
}
}
上一个问题中,我们定义了一个变量redraw,通过snapshotFlow { redraw }可以监听它的变化,
并通过Flow.sample限制最快每500毫秒执行一次计算。
这样子就可以边拖动边计算,且不会触发频繁计算。
问题三
在魅族手机上面,开启Aicy长按识屏后,如果长按一会儿再拖动,会导致draw没有重绘。
调试后发现redraw的值有变化,但就是不会触发draw重绘,于是尝试把redraw的读取提前到组合中,触发重组就正常了。
但是这样子又会导致性能浪费,正常情况下只要在draw中读取redraw重绘即可,不用重组。
如何解决这个问题?
- 首先定义一个是否强制重组的标识
forceRecomposition - 手势拖动时,发起一个延迟任务,例如延迟100毫秒后,把
forceRecomposition设置为true - 在
draw中取消上一步中的延迟任务 - 在组合中判断如果
forceRecomposition为true,直接在组合中读取redraw强制重组
如果第3步执行了,也就是重绘了,那么延迟任务会被取消,forceRecomposition不会被设置为true强制重组。
如果第3步没有执行,那么延迟任务结束之后,forceRecomposition会被设置为true,走的就是强制重组的流程。
看一下简化的代码:
class ScratchcardState {
// 强制重组任务
private var _forceRecompositionJob: Job? = null
// 强制重组标识
var forceRecomposition by mutableStateOf(false)
fun onDragStart(value: Offset) {
// ...
// 每次拖动之前,重置一下
cancelForceRecomposition()
}
fun onDragAmount(value: Offset) {
// 拖动时开启强制重组任务
startForceRecomposition()
// ...
}
// draw中调用此方法汇报绘制
fun reportDraw() {
// 取消强制重组任务
_forceRecompositionJob?.cancel()
}
private fun startForceRecomposition() {
if (_forceRecompositionJob == null) {
_forceRecompositionJob = coroutineScope.launch {
// 延迟之后,把强制重组标识设置为true
delay(48)
forceRecomposition = true
}
}
}
// 取消强制重组任务
private fun cancelForceRecomposition() {
_forceRecompositionJob?.cancel()
_forceRecompositionJob = null
forceRecomposition = false
}
}
再修改一下组合:
fun Modifier.scratchcard(
state: ScratchcardState,
): Modifier {
return composed {
// ...
if (state.forceRecomposition) {
// 如果是强制重组模式,在组合中直接读取redraw,触发重组
state.redraw
}
drawWithContent {
// 汇报绘制
state.reportDraw()
// ...
}
}
}
这样子大部分情况下,走的都是draw重绘,只有重绘超时后,才会切换为强制重组模式。
问题四
覆盖层是否被清空,应该要有一个变量来表示,如果被清空,就不显示覆盖层:
class ScratchcardState {
/** 是否已经清空覆盖层 */
var cleared by mutableStateOf(false)
}
当cleared为true时,不显示覆盖层。
假如已经刮开覆盖层显示内容状态,此时如果界面被回收并重新创建,会显示覆盖层,因为默认值是false,这种情况下我们应该要保存cleared变量:
@Composable
fun rememberScratchcardState(): ScratchcardState {
return rememberSaveable(saver = ScratchcardState.Saver) {
ScratchcardState()
}
}
class ScratchcardState(initialCleared: Boolean = false) {
var cleared by mutableStateOf(initialCleared)
companion object {
internal val Saver = listSaver(
// 保存
save = { listOf(it.cleared) },
// 还原
restore = { ScratchcardState(initialCleared = it[0]) },
)
}
}
代码比较简单,使用rememberSaveable并设置一个saver来定义如何保存和还原对象。
这样子,界面被回收重新创建时,就能还原正确的状态了。
写在最后
最后看一下作者封装好的库,如何使用:
@Composable
private fun ContentView() {
ScratchcardBox(
overlay = {
// 覆盖层
Box(Modifier.background(Color.Gray))
},
content = {
// 内容层
Image(
painter = painterResource(R.drawable.scratchcard_content),
contentDescription = null,
)
},
)
}
使用起来比较简单,当然了,还有更多的设置没有列出来。
完整代码可以看这里:compose-scratchcard
感谢你的阅读,欢迎一起交流学习。