剖析擦玻璃在flutter中的实现原理

avatar
程序员

前言

最近在开发一款基于Android平台的《学单词》flutter应用,其中有一个模块是通过趣味游戏的方式进行单词的学习,里面有一个刮玻璃的游戏环节,今天,就和大家分享如何在flutter中实现刮刮乐的效果。

效果

在讨论技术实现之前,先看一下实现效果:

demo.gif

笔者之前完全没有接触过flutter,但是看了视觉稿依旧没在怕的(默念:方法总比困难多方法总比困难多)

从效果图可以看出,关键交互就是手势识别擦除 和 擦除达到整个画布的80%之后展示整个底图 这两布。本文就重点讲解这两部分的实现原理。

手势识别擦除

手势识别擦除这一部分对于有flutter基础的开发来说比较简单,但是对于没有基础的同学来说[手动狗头], 还是要一本正经的分析一下,主要分为以下几步:

  1. 初始化,把视觉给到的玻璃图片绘制成canvas
  2. 实现海绵擦移动的效果
  3. 实现移动擦除的效果

初始化,把玻璃图片绘制成canvas

由于要在图片上面进行刮除,因此不能把玻璃蒙层直接通过图片的格式展示出来,这里使用的flutterCustomPaint 组件进行绘制。

首先通过ImageProvider实例提供的load方法加载该图片;获取到二进制图片数据后,使用ui.instantiateImageCodec来对图片进行解码,接着通过流监听器获取到最后的绘制对象 ui.Image。总之最后就是要把不管什么格式的的图片,最终转成在flutter上保存图片的一种格式对象ui.Image。 下面是伪代码展示:

imageProvider.load(key, (Uint8List bytes) async {
  return ui.instantiateImageCodec(bytes);
}).addListener(ImageStreamListener((ImageInfo image, _) {
  // 返回image.image
});

接下来就是绘制操作了,在Flutter中提供了一个CustomPaint组件,可以结合画笔CustomPainter来实现自定义图形绘制。CustomPainter中定义了一个虚函数paint,其中有一个参数是canvas,内部封装了一些基本绘制的API,这里我们就调用canvas.drawImageRect把上面拿到的图片对象绘制出来:

  // 初始化刮层
void paint(Canvas canvas, Size areaSize) {
  // ui.Image image
  final imageSize = Size(image!.width.toDouble(), image!.height.toDouble());
  // 玻璃蒙层盖住整个底图区域(areaSize)
  final sizes = applyBoxFit(BoxFit.cover, imageSize, areaSize);
  final inputSubrect =
      Alignment.center.inscribe(sizes.source, Rect.fromLTWH(0.0, 0.0, imageSize.width, imageSize.height));
  final outputSubrect =
      Alignment.center.inscribe(sizes.destination, Rect.fromLTRB(0.0, 0.0, areaSize.width, areaSize.height));
  canvas.drawImageRect(image!, inputSubrect, outputSubrect, Paint());
}

实现效果如下:

image.png

实现海绵擦移动的效果

加下来,就是要实现海绵擦移动的效果,这里直接使用GestureDetector手势检测组件记录移动的点的坐标

GestureDetector(
  behavior: HitTestBehavior.opaque,
  onPanStart: (details) {
   setState(() {
    _lastTouchPosition = details.localPosition;
   });
  },
  onPanUpdate: (details) {
   setState(() {
    _lastTouchPosition = details.localPosition;
   });
  },
  onPanEnd: (details) {
   setState(() {
    _lastTouchPosition = null;
   });
  },
)

拿到点的坐标之后,通过实时改变海绵擦的位置实现移动的效果


Positioned(
  left: localPosition.dx,
  top: localPosition.dy,
  child: NoneWidget(
    child: Image.asset(
      'images/scratch_grass/rag.png',
      height: 140,
      width: 140,
    ),
    none: !moving || !isStart || isFinished,
  ),
)

实现效果如下:

demo(4) (1).gif

实现移动擦除的效果

话不多说,我们继续把擦除效果实现出来。和实现海绵擦移动的效果不同(只需要保存一个最新的点的位置坐标),实现移动擦除的效果需要绘制。整条绘制路径是由一个一个短线的绘制组成,而一条线的绘制至少需要两个点,因此我们需要使用一个数组来收集所有经过的点的集合。

GestureDetector(
  behavior: HitTestBehavior.opaque,
  onPanStart: (details) {
   setState(() {
    _lastTouchPosition = details.localPosition;
+   points.add(point);
   });
  },
  onPanUpdate: (details) {
   setState(() {
    _lastTouchPosition = details.localPosition;
+   points.add(point);
   });
  },
  onPanEnd: (details) {
   setState(() {
    _lastTouchPosition = null;
+    points.add(null);
   });
  },
)

接着遍历该集合,头尾相连,绘制透明线条。其中brushSize是我们定义的笔触的大小。

double brushSize = 48;
for (int i = 1; i < points.length; i++) {
  ScratchPoint? p1 = points[i - 1];
  ScratchPoint? p2 = points[i];
  if (p1 == null || p2 == null) continue;
  canvas.drawLine(
    p1.position!,
    p2.position!,
    Paint()
      ..strokeCap = StrokeCap.round
      ..color = Colors.transparent
      ..strokeWidth = brushSize
      ..blendMode = BlendMode.src
      ..style = PaintingStyle.stroke,
  );
}

实现效果如下(这里为了减少干扰,只展示擦除的效果,把上面海绵擦移动的效果去掉了):

demo(3).gif

看样子有模有样了

但是讲个鬼故事,看进度条

计算擦除占比

完成了基础的绘制效果之后,由于擦除达到整个画布的80%之后需要展示整个底图。因此,如何计算擦除占比多少?

比较容易想到的是先计算出整个画布的面积大小作为分母,那擦除的区域的面积如何计算呢?

计算擦除区域的面积

如上面所描述,移动擦除的效果就是通过一条一条线(或者说是矩形)的绘制所组合而成。其中线的长度就是两个点的距离,线的高度就是我们定义的画笔的高度。那么能不能算出矩形的面积呢?

如果用户直直的画了一条线看起来计算不是问题,但是,重复的面积如何进行计算呢?例如下面几种情况:

这里一共画了四条线组成一个米字形状,重叠的区域大家可以在纸上画画,有多少块重叠区域。

这里一共画了三个圆形,重叠区域参考五环。

而且实际场景中绘制的方式随机,需要对每种场景下重叠区域的计算方式需要提供理论上的支撑。

因此只能另辟蹊径...

逆向思维,计算整块画布中有多少个点在擦除区域内

我们正常开始思考的是计算擦除区域的面积,但是由于这样计算要排除掉那些重复擦除的区域,需要设计一套可以完美计算出各种情况下重叠区域的面积,但是这种方式基本没法计算出准确的值。(当然,有方案的朋友欢迎来评论区讨论,一起学习一下)

1. 生成整块画布的点

这时候,只能换一种思路。我们知道构成图像的基本单位是像素,直观来讲,一张图像就是由横竖 m×n 个像素点表示出来的。运用到我们这里,我们可以把待刮除区域中,生成均匀m×n个点。在擦除前,计算出来所有的点的坐标。

这里可以根据需要设置需要给画布生成100×100点或者200×200个点,我这里的accuracy设置的是30

  List<Offset> _calculateCheckpoints(Size size) {
    final xOffset = size.width / accuracy;
    final yOffset = size.height / accuracy;

    final points = <Offset>[];
    for (var x = 0; x < accuracy; x++) {
      for (var y = 0; y < accuracy; y++) {
        final point = Offset(
          x * xOffset,
          y * yOffset,
        );
        points.add(point);
      }
    }

    return points;
  }
2. 打标记

这时候,擦除占比就不再是直接计算擦除区域的的面积了,我们遍历画布中全部的点,有多少个点是在擦除区域面积内的,然后把在擦除面积内的点打上标记。最后,计算打上标记的点的数量除以全局的点的总数就是擦除占比。

至于重复擦除的区域,我们只需要做到每次并不是遍历全局的点,而是遍历剩下没有被擦除的点;者直接重复打上标记,计算也不会有问题。

// reached保存已经被擦除的点
final reached = <Offset>{};
// checkpoints表示的没有被擦除的点,初始值为整个画布的点
for (final checkpoint in checkpoints) {
  // 判断是否在擦除区域

  if (在)
    reached.add(checkpoint);
  }
}

// 更新待擦除的点集合
checkpoints = checkpoints.difference(reached);
// 计算出最后的占比
progress = ((totalCheckpoints - checkpoints.length) / totalCheckpoints) * 100;

3. 是否在擦除区域内

那如何计算点是否在在区域内呢?

上面绘制擦除效果的时候,我们使用的一个数组来保存坐标集合。每一个点,可以看作一个个圆心,圆心的直径就是设置的笔触大小。有了圆心坐标和直径的数据就可以确定一个圆了,这时候,一边收集新生成的点,一边以此为圆心,遍历全局的点有多少在这个圆内。

判断点是否在圆内,只需要动用一下初中或者是小学的知识,为了避免大家一时想不起来(或者是不会),附上代码:

  bool _inCircle(Offset center, Offset point, double radius) {
    final dX = center.dx - point.dx;
    final dY = center.dy - point.dy;
    final multi = dX * dX + dY * dY;
    final distance = sqrt(multi).roundToDouble();
    return distance <= radius;
  }

至此,整个擦玻璃的效果就实现出来的,在到达设置的擦除阈值之后,直接把绘制组件移除,展示出最后的底图。

以为这样子就完美结束了?

踩坑环节

下面来正式介绍一下踩坑环节:

双指操作

demo(2) (1).gif

看到这个地方的时候,不得不佩服测试同学真的很优秀,怎么刮出了个矩形来?接着面积的计算就出错了,把整块区域都刮除完了,计算出来刮除占比只有60%,导致流程卡在这里,无法结束刮除环节。

这个现象的出现是因为由于测试同学使用了两根手指同时操作,双指操作下我们绘制路径变成了波浪形状,在非常密集的情况下整体呈现出来的刮除区域就是一个矩形。但是由于GestureDetector收集到的点只有波峰和波谷的点,因此面积计算占比就有问题。

在和交互的讨论下,决定把双指操作禁止掉,由于笔者没有找到flutter提供的识别双指操作的的组件。因此目前是通过判断收集到的连续两个坐标的距离大于某个值后,丢掉这个点,从而实现双指操作时只有一根手指生效的效果。

感兴趣的同学可以尝试不禁止双指操作的情况下,计算出矩形的面积,这里提供一个思路,可以尝试把连接波峰和波谷的线上均匀生成一些点作为圆心,像上面描述的一样计算有画布有多少个点在圆心上。

参考资料

github.com/vintage/scr… book.flutterchina.club/preface.htm…

结尾

朋友们,今天的文章到这里就结束了,如果你喜欢我 的文章的话别忘了点个赞再走哦~~