Flutter实现闪电效果

3,713 阅读4分钟

我正在参加「创意开发 投稿大赛」详情请看:掘金创意开发大赛来了!

每当下雨打雷,那么就能在iOS的天气预报app中: 欣赏到雨滴砸落在小花伞上,溅到手上; 欣赏到乌云变化成不同的形状,匆匆从东边赶往西边; 欣赏到闪电变为一把利剑贯穿整个手机,忽而凶猛忽而迅捷。

闪电

叹为观止的闪电,好想拥有。脑中闪过,万能的flutter能否实现我的梦想。构思中...

1. 夜空

为了凸显闪电的明亮,或因乌云密布,需要一个暗色的背景。最好是灰蒙蒙的群山,上方是急骤又瞬变的雷云,突然一条火蛇划开夜空。没有素材,再好的构思也没用,翻遍网图,没有心满意足的。退而求其次吧,来一张万家灯火的城镇,被黑压压的乌云覆盖的也可以,忽然夺人心魂的闪电,打在对面楼顶,映入你的眼帘。

2. 闪电

闪电从苍穹任意位置出发,以不可判断的方向击向地面.闪亮从暗暗的到明亮亮的,细细的到粗粗的,慢慢地到快快的。

3. 点亮夜空

当闪电划过夜空时,慢慢地点亮夜空,并且在最后释放所有能量的时候,把整个夜空染成白色。

lightningMV.gif

大概就是这个意思了。

代码实现

采用三层构造,一、背景图,夜空。二、随闪电变化亮度层。三、闪电。

一、夜空

从网上找的素材darkSky.jpeg,放入到images文件夹中,并在pubspec.yaml中的assets添加- images/darkSky.jpeg.最后点击一下Pub get,如果是是在终端那么输入flutter pub get。此时就可以引入素材了。

SizedBox(
  width: windowSize.width,
  height: windowSize.height,
  child: Image.asset(
    "images/darkSky.jpeg",
    fit: BoxFit.fitHeight,
  ),
),

windowSize为整个手机屏幕的大小。背景图铺满整个屏幕,使效果看起来真实一些。

二、 亮度层

随着闪电划开夜空,在伸向地面的过程中,夜空慢慢地被点亮。

Container(
  color: backgroundColor(_animation.value),
  width: windowSize.width,
  height: windowSize.height,
),

_animation为闪电动画的值,为了与闪电同步。backgroundColor为不同的颜色。

Color backgroundColor(double value) {
  var whiteColors = [
    Colors.white10,
    Colors.white10,
    Colors.white10,
    Colors.white10,
    Colors.white10,
    Colors.white12,
    Colors.white12,
    Colors.white12,
    Colors.white12,
    Colors.white12,
    Colors.white24,
    Colors.white24,
    Colors.white24,
    Colors.white24,
    Colors.white24,
    Colors.white24,
    Colors.white24,
    Colors.white24,
    Colors.white70,
    Colors.white70,
  ];

  var index = (value * whiteColors.length).floor();

  return whiteColors[index];
}

颜色值采用的是一个数组,根据不同的时间点获取不同的值。前前后后反反复复调整了很多次,希望能达到真实效果。不过似乎还是差亿点点。(自行调整调整)

三、绘制闪电

重点来了。全网搜了搜,尽然没有现成的。好吧,自己造一个。看到一篇绘制闪电的文章,这个绘制的思路可以用,但不能满足要求。 Let's begin.

1. 动画

闪电和亮度层,都需要一个动画过程。那么通过SingleTickerProviderStateMixin创建一个动画,根据动画的值来绘制闪电的每一个时刻。

late AnimationController _controller;
late Animation<double> _animation;
static const int durationMilliseconds = 1000;

initState中初始化

_controller = AnimationController(
    vsync: this,
    duration: const Duration(milliseconds: durationMilliseconds));
_animation = Tween<double>(begin: 0.0, end: 1.0)
    .animate(CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic))
  ..addListener(() {
    setState(() {
      debugPrint("");
    });
  })
  ..addStatusListener((status) {
    if (AnimationStatus.completed == status) {
      setState(() {
        pointsList.clear();
      });
      _controller.reset();
    }
  });

此时就能使用_animation.value来控制动画的不同时刻了。 其中的curve可以控制闪电出现方式,尝试了Curves不同类型,发现Curves.easeOutCubic比较合适,更贴近现实一些。

2. 生成闪电的轨迹

绘制闪电的思路生成轨迹点,仅仅坐标点就可以,先不进行绘制。因为连接不同点的使用直线,效果不太自然。采用贝塞尔曲线更加的完美。

void drawLightning(
    double x1, double y1, double x2, double y2, double displace) {
  if (displace < curDetail) {
    pointsList.add(Offset(x1, y1));
    pointsList.add(Offset(x2, y2));
  } else {
    var midX = (x2 + x1) / 2;
    var midY = (y2 + y1) / 2;
    midX += (random.nextDouble() - 0.5) * displace;
    midY += (random.nextDouble() - 0.5) * displace;

    midX = midX.clamp(0, windowSize.width);
    midY = midY.clamp(0, windowSize.height);

    drawLightning(x1, y1, midX, midY, displace / 2);
    drawLightning(midX, midY, x2, y2, displace / 2);
  }
}

使用一个数组来保存闪电的所有坐标点。

var curDetail = 10.0;
var random = Random();
var pointsList = <Offset>[];

闪电的开始点,屏幕最上方开始:x轴上随机一点,Y轴上20内随机一点。

Offset _createStart() {
  return Offset(
      random.nextDouble() * windowSize.width, -random.nextDouble() * 20);
}

闪电的结束点,屏幕最下方结束:x轴上随机一点,Y轴上屏幕外20内随机一点。

Offset _createEnd() {
  return Offset(random.nextDouble() * windowSize.width,
      windowSize.height + random.nextDouble() * 20);
}

创建闪电坐标点,每一次调用的时候就会随机生成,每一次都不一样。

Future<List<Offset>> create() async {
  Offset start = _createStart();
  Offset end = _createEnd();
  drawLightning(start.dx, start.dy, end.dx, end.dy, 500);

  return pointsList;
}
3. 绘制闪电

自定义CustomPainter,传入pointsListanimationValue就可以开始绘制了。

// 点集合
final List<Offset> points;

final double animationValue;

final Paint _mainPaint = Paint();
final Path _linePath = Path();

LightningPainter(this.points, this.animationValue);

使用贝塞尔曲线将pointsList的点连接起来,并且生成_linePath路径,此时就是闪电的路径了。

void addBezierPathWithPoints(Path path, List<Offset> points) {
  for (int i = 0; i < points.length - 1; i++) {
    Offset current = points[i];
    Offset next = points[i + 1];

    if (i == 0) {
      path.moveTo(current.dx, current.dy);
      double ctrlX = current.dx + (next.dx - current.dx) / 2;
      double ctrlY = next.dy;
      path.quadraticBezierTo(ctrlX, ctrlY, next.dx, next.dy);
    } else if (i < points.length - 2) {
      double ctrl1X = current.dx + (next.dx - current.dx) / 2;
      double ctrl1Y = current.dy;

      double ctrl2X = ctrl1X;
      double ctrl2Y = next.dy;

      path.cubicTo(ctrl1X, ctrl1Y, ctrl2X, ctrl2Y, next.dx, next.dy);
    } else {
      path.moveTo(current.dx, current.dy);

      double ctrlX = current.dx + (next.dx - current.dx) / 2;
      double ctrlY = current.dy;

      path.quadraticBezierTo(ctrlX, ctrlY, next.dx, next.dy);
    }
  }
}

如果直接绘制上,那么就不能够实现闪电那种:从一个点出现闪电,然后沿着轨迹慢慢地展开。那么需要用到PathpathMetric来实时获取当前的子path了。刚从“岛上码农”那学的

var pathMetric = _linePath.computeMetrics();
for (var metric in pathMetric) {
  var subPath = metric.extractPath(0.0, metric.length * animationValue);
  for (var i = 0; i < whiteColors.length; i++) {
    canvas.drawPath(
        subPath,
        _mainPaint
          ..color = whiteColors[i]
          ..style = PaintingStyle.stroke
          ..strokeJoin = StrokeJoin.round
          ..strokeWidth = strokeWidths[i] * animationValue);
  }
  break;
}

根据animationValue不同,绘制不同的path,使得闪电更加贴近真实。

var whiteColors = [
  const Color(0x05FFFFFF),
  Colors.white12,
  Colors.white30,
  Colors.white,
];
var strokeWidths = [60.0, 40.0, 20.0, 10.0];

闪电的颜色和宽度,如果仅仅一条,那么就少了一些光晕,也就是被闪电点亮的空气。在闪电周围再加上一些浅色的闪电,一起展现出更完美的闪电。 还有一点闪电的宽度,开始时窄一点,到最后宽一点,也比较接近现实。


完整代码上传到lightning.dart了。

参考文章:

  1. krazydad.com/bestiary/be…
  2. juejin.cn/post/712279…