Flutter 入门与实战(七十七):这个中秋,岛上码农用代码画诗带你感受海边浪漫月夜

3,053 阅读9分钟

我正在参加中秋创意投稿大赛,详情请看:中秋创意投稿大赛

前言

掘金的活动都参加好几次了,6月份和8月份的日更“逼得”码农们天天加班码字,到了9月份,来了个中秋创意投稿大赛,不要求日更,也不要求篇数,结果更上头了。上班路上都在想着怎么搞得更有创意些,差点撞电线杆——怎么搞才更有创意呢?总不能大家都搞画饼和嫦娥奔月吧……作为一个“有文化的”岛上码农,咱们还是来点和海有关的吧。 最终的效果见下图,使用 Flutter 的 Canvas实现,源码已上传至Gitee:中秋创意源码。实现了如下效果:

  • 月亮从海平面逐步上升;
  • 潮水涌动的效果;
  • 一群大雁振翅往南飞;
  • 满天星光闪烁的效果;
  • 随着月亮的高度上升,天空越来月亮(需要仔细看图)。

最终效果

海上升明月 天涯共此时

海上生明月,天涯共此时。情人怨遥夜,竟夕起相思。 灭烛怜光满,披衣觉露滋。不堪盈手赠,还寝梦佳期。 —— 张九龄 《望月怀古》

中秋时分,站在海边望着升起的明月,对于身处异乡的人来说,难免会想起远在家乡的亲人。那么我们在 Flutter 中怎么实现“海上升明月”这种效果呢? 要绘图,肯定需要使用到 Canvas,关于 Canvas绘图具体细节我们后面的篇章会详细介绍,这里只简单介绍一下。Flutter提供了一个 CustomPaint 组件供我们自定义绘图,其中 CustomPaint 有三个重要的参数:

CustomPaint(
  child: childWidget(),
  foregroundPainter: foregroundPainter(),
  painter: backgroundPainter(),
)
  • childCustomPaint的子组件;
  • painterforegroundPainter:都是 CustomPainter 类,用于定义 canvas 绘制的内容。区别在于,首先是执行 painter 的绘制指令。然后是在背景上渲染 child 子组件。最后,foregroundPainter 的内容会绘制在 child 上一层。

CustomPainter提供了一个paint绘图方法供我们绘制图形,该方法携带canvassize两个参数,其中 canvas 是画布,size 是画布大小。canvas 提供了很多绘制图形的方法,比如绘制路径、矩形、圆形和线条等等。这里我们拿 canvas 先画月亮吧,我们单独抽出一个paintMooen 方法,需要提供三个参数:

  • canvas:Canvas画布。
  • center:月亮的中心点。
  • radius:月亮的半径,即月亮的大小。

方法如下,我们在月亮上绘制了一层月亮大一点点的半透明的圆,使得月亮看起来在发光。

void paintMooen(Canvas canvas, Offset center, double raidus) {
    var mooenPaint = Paint()..color = Colors.yellow[100]!;
    mooenPaint.strokeWidth = 2.0;
    canvas.drawCircle(
      center,
      raidus,
      mooenPaint,
    );

    var lightPaint = Paint()..color = Colors.yellow[100]!.withAlpha(30);
    lightPaint.strokeWidth = 2.0;
    canvas.drawCircle(
      center,
      raidus + 3,
      lightPaint,
    );
  }

接下来是绘制大海,这里我们先简单的将背景色弄成稍微亮一点 ,然后用黑色来表示大海。绘制背景色和大海使用下面的代码,其中 seaLevel 是自定义 CustomPainter 类的成员属性,用于控制海平面的高度:

canvas.drawColor(Color(0xFF252525), BlendMode.color);
void paintSea(Canvas canvas, Size size) {
  int seaColor = 0xFF020408;
  var seaPaint = Paint()..color = Color(seaColor);
  seaPaint.strokeWidth = 2.0;

  Path seaPath = Path();
  seaPath.moveTo(0, seaLevel);

  seaPath.lineTo(size.width, seaLevel);
  seaPath.lineTo(size.width, size.height);
  seaPath.lineTo(0, size.height);
  seaPath.lineTo(0, seaLevel);
  canvas.drawPath(seaPath, seaPaint);
}

当然,我们还得写上一句诗,这里需要用到文本绘制:

void paintPoet(Canvas canvas, String poet, Size size) {
    var style = TextStyle(
      fontWeight: FontWeight.w300,
      fontSize: 26.0,
      color: Colors.yellow[100],
    );

    final ParagraphBuilder paragraphBuilder = ParagraphBuilder(
      ParagraphStyle(
        fontSize: style.fontSize,
        fontFamily: style.fontFamily,
        fontStyle: style.fontStyle,
        fontWeight: style.fontWeight,
        textAlign: TextAlign.center,
      ),
    )
      ..pushStyle(style.getTextStyle())
      ..addText(poet);
    final Paragraph paragraph = paragraphBuilder.build()
      ..layout(ParagraphConstraints(width: size.width));
    canvas.drawParagraph(paragraph, Offset(0, 100));
  }

对应的paint 方法如下,其中moonCenterY是我们自定义CustomPainter类的成员属性,用于控制月亮的位置:

@override
void paint(Canvas canvas, Size size) {
  canvas.drawColor(Color(0xFF252525), BlendMode.color);
  var center = size / 2;
  paintMooen(canvas, Offset(center.width, moonCenterY), 90);
  paintSea(canvas, size);
  paintPoet(canvas, '海上升明月 天涯共此时', size);
}

绘制完的效果如下图:

月亮绘制效果

嗯,没有什么感觉啊,至少让月亮升起来吧。我们在Flutter 入门与实战(七十五):模拟红绿灯来看GetX的定向刷新讲到的定时器可以派上用场了,我们可以在状态管理中控制月亮的中心位置,使用定时器更改中心位置让它逐渐上升不就可以了?状态管理代码如下,其中有三个变量:

  • seaLevel:海平面高度;
  • finalPosition:月亮上升后的最大高度位置;
  • _mooenCenterY:月亮的中心点Y 坐标,通过定时器让这个变量逐步减小,就可以让月亮升起来。
class MoonStep1Controller extends GetxController {
  final double seaLevel = Get.height - 180.0;
  final double finalPosition = 300;
  late double _moonCenterY;
  get moonCenterY => _moonCenterY;
  late Timer _downcountTimer;

  @override
  void onInit() {
    _startPosition = seaLevel;
    super.onInit();
  }

  @override
  void onReady() {
    _downcountTimer = Timer.periodic(Duration(milliseconds: 40), repaint);
    super.onReady();
  }

  void repaint(Timer timer) {
    bool needUpdate = false;
    if (_moonCenterY > finalPosition) {
      _moonCenterY -= 1;
      needUpdate = true;
    } else {
      timer.cancel();
    }

    if (needUpdate) {
      update();
    }
  }

  @override
  void onClose() {
    _downcountTimer.cancel();
    super.onClose();
  }
}

有了状态管理器后,我们把 CustomPaint 组件使用GetBuilder包裹起来,就可以实现动效了。

@override
Widget build(BuildContext context) {
  return GetBuilder<MoonStep1Controller>(
    init: controller,
    builder: (store) => CustomPaint(
      child: null,
      foregroundPainter: MoonStep1Painter(
        mooenCenterY: store.mooenCenterY,
        seaLevel: store.seaLevel,
      ),
    ),
  );
}

看看效果怎么样?虽然月亮升起来了,不过还差点意思,这海水太假了,继续!

月亮升起

春江潮水连海平 海上明月共潮生

春江潮水连海平,海上明月共潮生。 滟滟随波千万里,何处春江无月明! —— 张若虚 《春江花月夜》节选

嗯,搞潮水这个有点难度了,我们要造出潮水涌动的效果出来!怎么弄呢?我们观察海水的时候,是一层一层的浪往前涌或往后退,从而有了潮起潮落的感觉。因此,可以用多层绘图,每一层此起彼伏,节奏不一样就应该有潮水涌动的效果了。这里我们使用三层,前中后三层,用定时器来控制浪花起伏的幅度不一样,就会有涌动的效果。先看静态绘制图形(为了区分,使用不同的颜色来绘制)。 海浪示意图 这里用到了Path 路径的一个 arcToPoint 方法,其实就是两点用弧线连起来,然后有一个 clockwise 参数控制弧线的绘制方向是顺时针还是逆时针,默认是顺时针。我们将屏幕从宽度方向分成偶数份,然后就可以按照奇偶数来控制弧线是顺时针还是逆时针绘制,就可以得到这种浪花效果了。

弧线绘制原理

Path backPath = Path();
backPath.moveTo(0, seaLevel);
int backCount = 6;
for (var i = 0; i < backCount + 1; ++i) {
  if (i % 2 == 0) {
    backPath.arcToPoint(
        Offset(size.width / backCount + i * size.width / backCount,
            seaLevel + waveY),
        radius: Radius.circular(waveRadius));
  } else {
    backPath.arcToPoint(
        Offset(size.width / backCount + i * size.width / backCount,
            seaLevel + waveY),
        radius: Radius.circular(waveRadius),
        clockwise: false);
  }
}

有了这个基础,我们就可以通过定时器来控制浪花的高度实现涌动效果了。这里我们用了两个变量控制浪花,一个是 _waveY,及浪花的高度,通过定时器加减使得值在一定范围波动,实现浪花高低不同的涌动效果。另一个是_waveRadius,控制弧线的半径,更改弧度后会有一种横向移动的效果。

class MoonStep2Controller extends GetxController {
  // 省略月亮上升控制部分属性
  late double _waveY;
  get waveY => _waveY;
  double _waveRadius = 200.0;
  get waveRadius => _waveRadius;

  int _waveMoveCount = 0;
  final int waveMoveStep = 20;
  bool moveForward = true;
  bool first = true;
  late Timer _downcountTimer;

  @override
  void onInit() {
    _mooenCenterY = seaLevel;
    _waveY = 0;
    super.onInit();
  }

  @override
  void onReady() {
    _downcountTimer = Timer.periodic(Duration(milliseconds: 40), repaint);
    super.onReady();
  }

  void repaint(Timer timer) {
    bool needUpdate = false;
    if (_mooenCenterY > finalPosition) {
      _mooenCenterY -= 1;
      needUpdate = true;
    } else {
      timer.cancel();
    }

    _waveMoveCount++;
    int maxStep = first ? waveMoveStep : waveMoveStep * 2;
    if (moveForward) {
      _waveY += 0.5;
    } else {
      _waveY -= 0.5;
    }
    if (_waveMoveCount > maxStep) {
      _waveMoveCount = 0;
      first = false;
      moveForward = !moveForward;
    }

    if (needUpdate) {
      update();
    }
  }
}

之后就是更改绘制海面的方法,代码如下:

void paintSea(Canvas canvas, Size size) {
  var seaPaint = Paint()..color = Colors.blue;
  seaPaint.strokeWidth = 2.0;
  Path backPath = Path();
  backPath.moveTo(0, seaLevel);
  int backCount = 6;
  double waveRadius = 200.0;
  for (var i = 0; i < backCount + 1; ++i) {
    if (i % 2 == 0) {
      backPath.arcToPoint(
          Offset(size.width / backCount + i * size.width / backCount,
              seaLevel + waveY),
          radius: Radius.circular(waveRadius));
    } else {
      backPath.arcToPoint(
          Offset(size.width / backCount + i * size.width / backCount,
              seaLevel + waveY),
          radius: Radius.circular(waveRadius),
          clockwise: false);
    }
  }
  backPath.lineTo(size.width, size.height);
  backPath.lineTo(0, size.height);
  canvas.drawPath(backPath, seaPaint);

  seaPaint..color = Colors.green;
  Path middlePath = Path();
  middlePath.moveTo(size.width, seaLevel);
  int middleWaveCount = 4;
  for (var i = 1; i < middleWaveCount + 1; ++i) {
    if (i % 2 == 0) {
      middlePath.arcToPoint(
          Offset(size.width - i * size.width / middleWaveCount,
              seaLevel - waveY),
          radius: Radius.circular(waveRadius),
          clockwise: false);
    } else {
      middlePath.arcToPoint(
          Offset(size.width - i * size.width / middleWaveCount,
              seaLevel - waveY),
          radius: Radius.circular(waveRadius),
          clockwise: true);
    }
  }
  middlePath.lineTo(0, size.height);
  middlePath.lineTo(size.width, size.height);

  canvas.drawPath(middlePath, seaPaint);

  Path frontPath = Path();
  seaPaint..color = Colors.red;
  frontPath.moveTo(0, seaLevel);
  int frondCount = 8;
  for (var i = 0; i < frondCount + 1; ++i) {
    if (i % 2 == 0) {
      frontPath.arcToPoint(
          Offset(size.width / frondCount + i * size.width / frondCount,
              seaLevel + waveY + 10),
          radius: Radius.circular(waveRadius),
          clockwise: false);
    } else {
      frontPath.arcToPoint(
          Offset(size.width / frondCount + i * size.width / frondCount,
              seaLevel + waveY + 10),
          radius: Radius.circular(waveRadius),
          clockwise: true);
    }
  }
  frontPath.lineTo(size.width, size.height);
  frontPath.lineTo(0, size.height);
  canvas.drawPath(frontPath, seaPaint);
}

现在来看看效果怎么样?潮水涌动效果出来了!不过还是稍显单调了点。

海浪涌动效果

月明星稀 乌鹊南飞

月明星稀,乌鹊南飞。 绕树三匝,何枝可依? —— 曹操 《短歌行》

中秋时分,皎皎明月,怎么能没有星星做伴呢?而且,说不准,我们还能够看到往南飞的大雁呢?先来看怎么绘制星星和大雁。

绘制星星

星星实际上我们可以用四条像下面的弧线来绘制。然后再调整弧线半径就可以实现绘制星星的效果了。

绘制星星示意图

Path starPath = Path();
starPath.moveTo(centerX, centerY - offset);
starPath.arcToPoint(Offset(centerX - offset, centerY),
    radius: Radius.circular(radius));
starPath.arcToPoint(Offset(centerX, centerY + offset),
    radius: Radius.circular(radius));
starPath.arcToPoint(Offset(centerX + offset, centerY),
    radius: Radius.circular(radius));
starPath.arcToPoint(Offset(centerX, centerY - offset),
    radius: Radius.circular(radius));
canvas.drawPath(starPath, starPaint);

绘制大雁

绘制大雁其实也是四条弧线,左右各一对翅膀,调整弧线的半径后就可以调出大雁的样子。这里我们左右翅膀分别用两条闭合的弧线完成绘制。

绘制大雁示意图

Path leftPath = Path();

leftPath.moveTo(center.dx - wingSize, center.dy - wingHeight);
leftPath
  ..arcToPoint(Offset(center.dx, center.dy),
      radius: Radius.circular(radius), clockwise: true);
leftPath.arcToPoint(Offset(center.dx - wingSize, center.dy - wingHeight),
    radius: Radius.circular(radius - wingRadiusDiff), clockwise: false);
canvas.drawPath(leftPath, birdPaint);
Path rightPath = Path();
rightPath.moveTo(center.dx + wingSize, center.dy - wingHeight);
rightPath
  ..arcToPoint(Offset(center.dx, center.dy),
      radius: Radius.circular(radius), clockwise: false);
rightPath.arcToPoint(Offset(center.dx + wingSize, center.dy - wingHeight),
    radius: Radius.circular(radius - wingRadiusDiff), clockwise: true);
canvas.drawPath(rightPath, birdPaint);

当然。大雁需要成群,星光需要满天才行,这个好办,我们用循环位置就行。

一群大雁往南飞

一群大雁我们控制不同大雁的 x,y坐标,并且飞在前面的大雁尺寸小一点,这样会更真实一些,然后可以通过修改翅膀绘制的坐标点的不同来实现大雁振翅飞翔的效果。我们就画5只大雁吧。循环里面的的radius代表翅膀的弧度,这里根据大雁的大小调整一点弧度使得翅膀挥舞过程中更真实。wingHeight控制翅膀的高度,wingSize控制翅膀的宽度,通过高度的不同实现翅膀挥舞的效果,宽度不同会显得大雁的大小不同。birdFlyDistance代表大雁飞动的距离,由于我们是竖屏,纵向方向上值更大,因此纵向的值乘以了2,使得纵向速度更快。这些参数都是通过状态管理的定时器控制的,

 void paint(Canvas canvas, Size size) {
  //...
  for (var i = 0; i < 5; ++i) {
    double step = (5.0 - i) * 15;
    paintBird(
        canvas,
        Offset(birdFlyDistance + step,
            seaLevel + 100 - birdFlyDistance * 2 - step),
        wingHeight,
        radius: 20.0 + 2 * i,
        wingSize: 22.0 + 2 * i);
  }
   //...
 }


星光熠熠

绘制星星不能像大雁那样排成一排,我们需要随机位置和随机大小。并且为可以通过随机控制部分星星的透明度来实现闪烁的效果,最终达到满天繁星、星光熠熠的效果。由于每次都会重绘,为了保证星星的位置不变,星星的位置和大小要在初始化的时候就设置好,每次绘制只是随机闪烁而已。

class SeaMooenController extends GetxController {
  //...
	late List<Offset> _starPositions;
  get starPositions => _starPositions;
  late List<double> _starSizes;
  get starSizes => _starSizes;
  late List<bool> _blinkIndexes;
  get blinkIndexes => _blinkIndexes;
  
  @override
  void onInit() {
    // ...
    // 星星绘制的起点在海平面上一点
    var starStartPosition = seaLevel - 20.0;
    // 随机星星的位置
    _starPositions = List.generate(
      starCount,
      (index) => Offset(
        4.0 + Random().nextInt(Get.width.toInt() - 4),
        starStartPosition - Random().nextInt(starStartPosition.toInt()),
      ),
    );
    // 随机星星的的大小
    _starSizes =
        List.generate(starCount, (index) => Random().nextInt(10) / 3.0);
    _blinkIndexes = List.generate(
        starCount, (index) => Random().nextInt(starCount) % 3 == 0);
    super.onInit();
  }
  
  @override
  void onReady() {
    _downcountTimer = Timer.periodic(Duration(milliseconds: 40), repaint);
    super.onReady();
  }

  void repaint(Timer timer) {
    //...
    _waveMoveCount++;
    _birdFlyDistance += 0.5;
    int maxStep = first ? waveMoveStep : waveMoveStep * 2;

    if (moveForward) {
      _waveY += 0.5;
      _wingHeight -= 0.3;
    } else {
      _waveY -= 0.5;
      _wingHeight += 0.3;
    }
    if (_waveMoveCount > maxStep) {
      _waveMoveCount = 0;
      first = false;
      moveForward = !moveForward;
    }
		
    // 星星随机闪烁
    _blinkIndexes = List.generate(
        starCount, (index) => Random().nextInt(starCount) % 2 == 0);

    //...
  }

}


月明星稀

月亮在海面上升的时候,我们应该把天空逐步调亮,这样会有一种月光照亮天空的感觉,这个很简单,我们根据月亮的高度更改背景色的透明度就好了,刚开始透明度高显得更黑,后面透明度下降,就会显得偏白,从而感觉更亮了。

anvas.drawColor(
  Color(0xFF252525)
      .withAlpha(250 - (moonCenterY / size.height * 255).toInt()),

最终效果

最终实现的效果和开头的动图一样,在海边听着潮水涌动的哗哗声,看着圆盘似的月亮在海面缓缓升起,星光时明时暗,一群大雁展翅飞翔,掠过月亮……如果还有个Ta陪伴,是不是超级浪漫?

最终效果

但愿人长久 千里共婵娟

人有悲欢离合,月有阴晴圆缺,此事古难全。 但愿人长久,千里共婵娟。 —— 苏轼 《水调歌头·明月几时有》节选

中秋节即将到来,祝愿大家阖家团圆,幸福健康 —— 但愿人长久,千里共婵娟。

但愿人长久,千里共婵娟

我是岛上码农,微信公众号同名,这是Flutter 入门与实战的专栏文章,提供体系化的 Flutter 学习文章。对应源码请看这里:Flutter 入门与实战专栏源码。如有问题可以加本人微信交流,微信号:island-coder

👍🏻:觉得有收获请点个赞鼓励一下!

🌟:收藏文章,方便回看哦!

💬:评论交流,互相进步!