Jetpack Compose - 刮刮卡效果

1,518 阅读7分钟

刮刮卡效果的文章不少,大多数是基于覆盖层和内容层两张图片相交实现的,作者希望内容层可以是任何布局,而不局限于图片,于是决定自己写一个,效果如下:

sample.gif

思路

实现思路比较简单:

  1. 监听手势,生成触摸路径
  2. 根据触摸路径,挖空覆盖层
  3. 监听覆盖层被挖空的百分比,达到某个阈值后,清空覆盖层

监听手势生成路径

这个好解决,直接看示例代码:

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处的设置,两处都要设置才可以:

image.png

即图中,红圈周围一圈透明的挖空效果。

如果不设置CompositingStrategy.Offscreen,则效果如下:

image.png

此时红圈周围一圈变成了黑色。

挖空百分比

当用户刮开一定的覆盖层之后,要清空覆盖层,显示底部完整内容,也就是要计算挖空面积占总面积的百分比。

由于触摸路径是不规则的,所以计算面积比较复杂,可以换个角度:计算挖空像素点占总像素点的百分比

因此需要对覆盖层截图,获取像素点信息。如何截图呢?万能的官方文档:

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重绘即可,不用重组。

如何解决这个问题?

  1. 首先定义一个是否强制重组的标识forceRecomposition
  2. 手势拖动时,发起一个延迟任务,例如延迟100毫秒后,把forceRecomposition设置为true
  3. draw中取消上一步中的延迟任务
  4. 在组合中判断如果forceRecompositiontrue,直接在组合中读取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)
}

clearedtrue时,不显示覆盖层。

假如已经刮开覆盖层显示内容状态,此时如果界面被回收并重新创建,会显示覆盖层,因为默认值是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

感谢你的阅读,欢迎一起交流学习。