Flutter 如何实现一个酷炫的拼图功能

avatar
Android, Flutter @guangzhou

摘要

我们先来看下效果图(这是动图哇):(底部附源码)

拼图.gif

实现这么一个拼图功能;主要可以拆分为3个部分:

  1. 拼图卡片的不规则裁剪;
  2. 卡片的拖动和目标位置校验;
  3. 正确或者错误结果的反馈;

1、拼图卡片的不规则裁剪

为了模拟现实生活中的拼图样式,拼图的卡片一般都是不规则的;那么首先,我们就得想一下要怎么来做图形的不规则裁剪;由于在 Android 上用 Xfermode 做过类似不规则的裁剪(Xfermode 中有一个模式就是通过两个图层叠加后取相交的图),所以第一个想法,就是 flutter 里面有没有类似的方法;

经过一番查找,发现 Paint 类有一个设置 blendMode 的方法,BlendMode 也有一种模式叫做 BlendMode.srcIn,一下子引起了我的注意;经过验证,果然跟 Android Xfermode 如出一辙(果然都是 Google 的亲儿子);

那么方法找到了,现在实现方式就可以是这样:让设计师提供拼图卡片的空图片;类似这样:

截图1.png

然后我跟原图一叠加裁剪,效果就出来了,一想就美滋滋。但是突然想到有个问题,图层直接叠加出来的效果是可以实现不规则的图片裁剪;但是设计师要求,是需要给这个不规则卡片描边,而且,成功和失败都要有个边缘发光的效果反馈(这个下面会说到)。

显然这种方式是有问题的;虽然我们看到的图形是对的,但是对于图片本身,它是一个矩形,只不过其他部分是透明而已;如果给这个图片绘制边缘,那其实就是个矩形了。

那么还有什么方式呢,我们能否把边缘的 path 画一遍,然后将画的这个 path 来跟图片做叠加?答案当然是 OK 的;那么现在问题就是怎么把 path 提取出来了;

pub 搜到一个 svg_path_parser (pub.dev/packages/sv…) ,就是将 SVG 里面的 path 解析为 flutter 里面的 Path,刚好设计师给的卡片图片就是 SVG,妙啊~

关键代码和步骤如下:

// borderPath 为 SVG 里面提取出来的 
// 如:M160,0 L160,75 C160,86.5979797 169.40202,96 181,96 C181.487961,96 181.972034,96.0166428 182.451684,96.0493917 C182.963713,96.0165423 183.479925,96 184,96 C197.254834,96 208,106.745166 208,120 C208,133.254834 197.254834,144 184,144 C183.479925,144 182.963713,143.983458 182.451863,143.95087 C181.972034,143.983357 181.487961,144 181,144 C169.40202,144 160,153.40202 160,165 L160,240 L125,240 C113.40202,240 104,230.59798 104,219 C104,218.512039 103.983357,218.027966 103.95087,217.548137 C103.983458,217.036287 104,216.520075 104,216 C104,202.745166 93.254834,192 80,192 C66.745166,192 56,202.745166 56,216 C56,216.520075 56.0165423,217.036287 56.0493917,217.548316 C56.0166428,218.027966 56,218.512039 56,219 C56,230.59798 46.5979797,240 35,240 L35,240 L4.54747351e-13,240 L4.54747351e-13,64 C4.50418687e-13,28.653776 28.653776,6.49299601e-15 64,0 L160,0 Z 
path = parseSvgPath(borderPath);


void _drawImageCard(Canvas canvas, Size size) {
  Paint paint = Paint();
  paint.style = PaintingStyle.fill;
  paint.isAntiAlias = true;
  canvas.saveLayer(Offset.zero & size, Paint());
  canvas.drawPath(path, paint);
  paint.blendMode =  BlendMode.srcIn;
  if (!active) {
    paint.colorFilter = ColorFilter.mode(Colors.grey, BlendMode.color);
  }

  canvas.drawImageRect(
    srcImage,
    Rect.fromLTWH(
      0,
      0,
      srcImage.width.toDouble(),
      srcImage.height.toDouble(),
    ),
    Rect.fromLTWH(left, top, 480, 480),
    paint,
  );
  canvas.restore();
}

  1. borderPath 为 SVG 提取出来的路径;path 为解析后的 flutter path;
  2. 先将 path 绘制上 canvas;
  3. 然后设置 blendMode 为 BlendMode.srcIn,可以简单理解为图层叠加后取重叠部分的图层;(具体各种 mode 代表什么可以去看下文档,这里不详细介绍);
  4. 最后再绘制上图片;这一步的重点就是怎么让裁剪到图片的正确位置,比如一张图分为四部分,那么怎么确定左上、左下、右上、右下的位置,也就是 Rect.fromLTWH(left, top, 480, 480) 这个的作用;

这样就完成了不规则图片的裁剪;另外,边缘 path 也有了,那么绘制白色边缘线就非常简单了;这里就不详细介绍了。有兴趣可以看源码。

那么第一步成功迈出了,我们已经可以将图片分解为各个小卡片了。那么接下来就是做拼接的那部分了;

2、卡片的拖动和目标位置校验

拼图拼接部分,主要就涉及到两个问题,一个就是拖动;另外一个就是拖动后给判断对不对,对的话就点亮;不对的话就提示;

flutter 对这块的支持做得还不错,这里主要用到 Draggable 和 DragTarget;

Draggable 从名字也可以看出来,主要是用于做 widget 的拖动;

DragTarget 则是用于接收 Draggable widget,将 Draggable widget 拖动到 DragTarget 后,就可以判断对与错;当然需要通过一些数据去关联起来;

下面我们对主要参数和方法做一下介绍,参数也比较多,没有全部介绍,如果有兴趣可以去看文档:

Draggable:

  • data:传入你的相关数据,主要用于和 DragTarget 做关联,最好都是唯一的数据;
  • feedback:拖动的时候,跟随在手指下方的 widget;
  • childWhenDragging:拖动的时候,widget 拖走后原来位置的占位 widget;
  • child:这个就是你想拖动的组件;
  • 拖动事件:onDragStarted、onDragUpdate、onDraggableCanceled、onDraggableCanceled、onDragCompleted;这几个不用过多介绍,主要是拖动时的一些回调;

DragTarget:

  • builder:主要 return 你的目标 widget;
  • onWillAccept:回调方法;当 Draggable 被拖到 DragTarget 上方的时候就会触发(主要是看手指的位置);这个方法返回 true 的话,就会触发 onAccept 方法;
  • onAccept:回调方法; onWillAccept 返回 true 的时候会触发;
  • onLeave:回调方法;手指离开 DragTarget widget 的时候会触发;
  • onMove:回调方法;手指在 DragTarget widget 上方移动的时候回调;

上面就是主要用到的两个类和一些主要参数和方法;理清后,逻辑也就不难了;

从上面效果图可以看到,卡片拖走后,原来的位置就有个虚线的占位效果;那么这里就涉及到虚线的绘制,但是 flutter 本身没有提供画虚线的方法;那我们需要自己撸了。

画虚线,这里的思路是首先要确定一下路径,再从路径上面打点,再将间隔的点连起来,就是虚线的效果了;那么主要分两步:

1、路径打点,也就是顺着 path 上面,按照固定的间距提取点坐标;

List<Offset> _getIconOffsets() {
  try {
    PathMetrics pms = path.computeMetrics();
    PathMetric pm = pms.elementAt(0);
    int len = pm.length ~/ 10;
    double start = pm.length % 10 / 2;
    List<Offset> iconOffsets = [];
    for (int i = 0; i <= len; i++) {
      Tangent? t = pm.getTangentForOffset(i.toDouble() * 10 + start);
      Offset point = t!.position;
      double x = point.dx;
      double y = point.dy;
      iconOffsets.add(Offset(x, y));
    }
    return iconOffsets;
  } catch (e) {
    return List.empty();
  }
}

2、给相邻两个点连线,然后空出一个点,再给下两个点连线;

void _drawBorder(Canvas canvas, Size size) {
  Paint paint = Paint()
    ..style = PaintingStyle.stroke
    ..strokeWidth = 4
    ..isAntiAlias = true
    ..color = Colors.black12;

  List<Offset> points = _getIconOffsets();
  for (int i = 0; i < points.length - 1; i++) {
    if (i % 2 == 0) {
      canvas.drawLine(points[i], points[i + 1], paint);
    }
  }
}

3、正确或者错误结果的反馈

最后一个部分,就是在拖动正确与错误后需要有个边缘发光的效果了;

边缘闪烁发光,说到底也就是 边缘路径+闪烁+发光 的组合;边缘路径我们上面已经解决过了,用 path 画一下就好;闪烁也比较简单,增加一个渐隐渐现的动画就 OK;那么剩下的就是发光效果。

发光效果,就是将带有颜色值的边缘路径去做模糊的效果;那么就有一种类似发散光的效果;找了一下,paint 也有类似的方式,其实就是设置一个 maskFilter;再用这个 paint 将 path 绘制出来;这样即可完成一个发光效果了;

paint.maskFilter = MaskFilter.blur(BlurStyle.outer, 25); 
paint.style = PaintingStyle.fill;

那么到此,几个主要的关键步骤都介绍完了;但是里面有涉及到比较多的细节没有详细介绍;有兴趣的话可以看下源码或者评论交流。最后再总结一下实现这么一个拼图效果需要用到的技术点。

总结

总结一下,其实做这个拼图主要涉及到以下几个技术点,掌握了,剩下就是一些细节的东西了。

  • 不规则图形绘制:提取 SVG 路径,再绘制 Path & 虚线;
  • 不规则图片裁剪:使用 BlendMode 做图层叠加裁剪;
  • 拖动逻辑:Draggable & DragTarget;
  • 发光边缘:MaskFilter;

源码:github.com/linkaipeng/…