前言
最近在开发一款基于Android平台的《学单词》flutter应用,其中有一个模块是通过趣味游戏的方式进行单词的学习,里面有一个刮玻璃的游戏环节,今天,就和大家分享如何在flutter中实现刮刮乐的效果。
效果
在讨论技术实现之前,先看一下实现效果:
笔者之前完全没有接触过flutter,但是看了视觉稿依旧没在怕的(默念:方法总比困难多方法总比困难多)
从效果图可以看出,关键交互就是手势识别擦除 和 擦除达到整个画布的80%之后展示整个底图 这两布。本文就重点讲解这两部分的实现原理。
手势识别擦除
手势识别擦除这一部分对于有flutter基础的开发来说比较简单,但是对于没有基础的同学来说[手动狗头], 还是要一本正经的分析一下,主要分为以下几步:
- 初始化,把视觉给到的玻璃图片绘制成canvas
- 实现海绵擦移动的效果
- 实现移动擦除的效果
初始化,把玻璃图片绘制成canvas
由于要在图片上面进行刮除,因此不能把玻璃蒙层直接通过图片的格式展示出来,这里使用的flutter的CustomPaint 组件进行绘制。
首先通过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());
}
实现效果如下:
实现海绵擦移动的效果
加下来,就是要实现海绵擦移动的效果,这里直接使用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,
),
)
实现效果如下:
实现移动擦除的效果
话不多说,我们继续把擦除效果实现出来。和实现海绵擦移动的效果不同(只需要保存一个最新的点的位置坐标),实现移动擦除的效果需要绘制。整条绘制路径是由一个一个短线的绘制组成,而一条线的绘制至少需要两个点,因此我们需要使用一个数组来收集所有经过的点的集合。
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,
);
}
实现效果如下(这里为了减少干扰,只展示擦除的效果,把上面海绵擦移动的效果去掉了):
看样子有模有样了
但是讲个鬼故事,看进度条
计算擦除占比
完成了基础的绘制效果之后,由于擦除达到整个画布的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;
}
至此,整个擦玻璃的效果就实现出来的,在到达设置的擦除阈值之后,直接把绘制组件移除,展示出最后的底图。
以为这样子就完美结束了?
踩坑环节
下面来正式介绍一下踩坑环节:
双指操作
看到这个地方的时候,不得不佩服测试同学真的很优秀,怎么刮出了个矩形来?接着面积的计算就出错了,把整块区域都刮除完了,计算出来刮除占比只有60%,导致流程卡在这里,无法结束刮除环节。
这个现象的出现是因为由于测试同学使用了两根手指同时操作,双指操作下我们绘制路径变成了波浪形状,在非常密集的情况下整体呈现出来的刮除区域就是一个矩形。但是由于GestureDetector收集到的点只有波峰和波谷的点,因此面积计算占比就有问题。
在和交互的讨论下,决定把双指操作禁止掉,由于笔者没有找到flutter提供的识别双指操作的的组件。因此目前是通过判断收集到的连续两个坐标的距离大于某个值后,丢掉这个点,从而实现双指操作时只有一根手指生效的效果。
感兴趣的同学可以尝试不禁止双指操作的情况下,计算出矩形的面积,这里提供一个思路,可以尝试把连接波峰和波谷的线上均匀生成一些点作为圆心,像上面描述的一样计算有画布有多少个点在圆心上。
参考资料
github.com/vintage/scr… book.flutterchina.club/preface.htm…
结尾
朋友们,今天的文章到这里就结束了,如果你喜欢我 的文章的话别忘了点个赞再走哦~~