用Flutter来玩一局激动人心的夏日大转盘吧!

2,345 阅读3分钟

我正在参加「初夏创意投稿大赛」详情请看:初夏创意投稿大赛

前言

夏天到了,大伙都想到了西瓜、海滩、旅游、空调。。

夏天,同样少不了当然就是游乐园啦!浪漫的摩天轮、刺激的过山车、惊悚的鬼屋、平淡却上头的打气球等等,当然还有激动人心的夏日大转盘了!大家能转到什么好东西呢?

接下来,就让我们用Flutter来做一个大转盘游戏吧!

先画个圆

一个转盘,也就是个大大滴圆。所以我们先定义一个圆再说

// 画笔
Paint paint = Paint()
  ..color = Colors.red
  ..strokeWidth = 1.0
  ..isAntiAlias = true
  ..style = PaintingStyle.fill;

// 定义一个Rect,指定扇形的面积
Rect rect = Rect.fromCircle(
  // 圆点
  center: Offset(
    size.width / 2,
    size.height / 2,
  ),
  // 半径
  radius: size.width / 2,
);

底色我们先用红色,PaintingStyle.fill填充,后面画扇形就覆盖了。

划出扇形

然后大转盘呢,是很多个扇形划分的,所以我们可以根据不同颜色的扇形来划分我们的刚刚的圆。

/// 定义的奖品数量
int selectSize;
/// 对应奖品的颜色
List<Color> colors;

@override
void paint(Canvas canvas, Size size) {
    double startAngles = 0;

    // 根据总扇形数划分各扇形对应结束角度
    List<double> angles = List.generate(
        selectSize, (index) => (2 * pi / selectSize) * (index + 1));

    for (int i = 0; i < selectSize; i++) {
      paint.color = colors[i];
      // - (pi / 2) 是为了圆形绘制起始点在头部,而不是右手边
      double acStartAngles = startAngles - (pi / 2);
      canvas.drawArc(rect, acStartAngles, angles[i] - startAngles, true, paint);
      startAngles = angles[i];
    }
}

canvas.drawArc即可绘制指定半径圆点的扇形啦,但要注意的是,圆的绘制起点,在正右方向,假如想要在头部,那就需要减去一个pi / 2。让我们看看效果

image.png

标上文字

文字的话,我们肯定是想标在每个扇形的中间位置,并且文字的方向随着扇形的方向改变而改变。这时就有人要问了,这扇形都已经画好了,我们才去标文字不是很麻烦吗,我们怎么知道每个文字应该在哪个坐标呀?文字的方向怎么调到跟扇形一致啊?

这时,我们就要知道,画布是可以旋转的,就比如说,你觉得你斜着写字写不好,那就把画布转过来,正着写,然后再转回去,那不就能够让文本在想要的角度上了嘛。

OK,一个一个文本来,我们在画扇形时已经得到了每个角度的结束位置。然后把每个画布旋转到起始角度和结束角度的中间

// 先保存位置
canvas.save();

// 记得 - (pi / 2) 跟上边的处理一样,保证起始标准一致
double acStartAngles = startAngles - (pi / 2);
double acTweenAngles = angles[i] - (pi / 2);
// + pi 的原因是 文本做了向左偏移到另一边的操作,为了文本方向是从外到里,偏移后旋转半圈,即一个pi
double roaAngle = acStartAngles / 2 + acTweenAngles / 2 + pi;

因为文字一般是左到右,即这里想要从外到里的效果,所以再加了个π来多转半圈。

然后转它!

// canvas移动到中间
canvas.translate(size.width / 2, size.height / 2);
// 旋转画布
canvas.rotate(roaAngle);

接着定义文本样式,然后绘制上去

// 定义文本的样式
TextSpan span = TextSpan(
  style: const TextStyle(
    color: Colors.white,
    fontSize: 24,
    fontWeight: FontWeight.bold,
    letterSpacing: -1.0,
    shadows: [
      Shadow(
        color: Color(0x80000000),
        offset: Offset(0, 2),
      ),
    ],
  ),
  text: contents[i],
);
// 文本的画笔
TextPainter tp = _getTextPainter(span, size, angles.first);
// 需要给定
tp.layout(minWidth: size.width / 4, maxWidth: size.width / 4);
tp.paint(canvas, Offset(-size.width / 2 + 20, 0 - (tp.height / 2)));

再把画布转回来,起始角度标至下一个。我们之前save过了,所以restore就能够转回save的位置。

canvas.restore();
startAngles = angles[i];

这时候假如文字很长,我们就会发现文本换行后压在了每个扇形的边上,不好看,所以再简单的写个文本自适应

TextPainter _getTextPainter(TextSpan span, Size size, double angle) {
  // 文本的画笔
  TextPainter tp = TextPainter(
    text: span,
    textAlign: TextAlign.center,
    textDirection: TextDirection.ltr,
    textWidthBasis: TextWidthBasis.longestLine,
  );
  tp.layout(minWidth: size.width / 4, maxWidth: size.width / 4);
  // 计算文本高度,超出自适应大小
  if (tp.height > maxHeight(size, angle)) {
    var temSpan = TextSpan(
      style: span.style!.copyWith(
        fontSize: span.style!.fontSize! - 1.0,
      ),
      text: span.text,
    );
    tp = _getTextPainter(temSpan, size, angle);
  }
  return tp;
}

看看效果

image.png

一个转盘就绘制完成啦,完整的代码如下:

class LuckyDrawPaint extends CustomPainter {
  LuckyDrawPaint({
    required this.contents,
    required this.selectSize,
    required this.colors,
  })  : assert(contents.length == selectSize && colors.length == selectSize),
        super();

  int selectSize;

  List<String> contents;

  List<Color> colors;

  @override
  void paint(Canvas canvas, Size size) {
    // 画笔
    Paint paint = Paint()
      ..color = Colors.red
      ..strokeWidth = 1.0
      ..isAntiAlias = true
      ..style = PaintingStyle.fill;

    // 定义一个Rect,指定扇形的面积
    Rect rect = Rect.fromCircle(
      center: Offset(
        size.width / 2,
        size.height / 2,
      ),
      radius: size.width / 2,
    );

    double startAngles = 0;

    // 根据总扇形数划分各扇形对应结束角度
    List<double> angles = List.generate(
        selectSize, (index) => (2 * pi / selectSize) * (index + 1));

    for (int i = 0; i < selectSize; i++) {
      paint.color = colors[i];
      // - (pi / 2) 是为了圆形绘制起始点在头部,而不是右手边
      double acStartAngles = startAngles - (pi / 2);
      canvas.drawArc(rect, acStartAngles, angles[i] - startAngles, true, paint);
      startAngles = angles[i];
    }

    startAngles = 0;
    for (int i = 0; i < contents.length; i++) {
      // 先保存位置
      canvas.save();

      // 记得 - (pi / 2) 跟上边的处理一样,保证起始标准一致
      double acStartAngles = startAngles - (pi / 2);
      double acTweenAngles = angles[i] - (pi / 2);
      // + pi 的原因是 文本做了向左偏移到另一边的操作,为了文本方向是从外到里,偏移后旋转半圈,即一个pi
      double roaAngle = acStartAngles / 2 + acTweenAngles / 2 + pi;

      // canvas移动到中间
      canvas.translate(size.width / 2, size.height / 2);
      // 旋转画布
      canvas.rotate(roaAngle);

      // 定义文本的样式
      TextSpan span = TextSpan(
        style: const TextStyle(
          color: Colors.white,
          fontSize: 24,
          fontWeight: FontWeight.bold,
          letterSpacing: -1.0,
          shadows: [
            Shadow(
              color: Color(0x80000000),
              offset: Offset(0, 2),
            ),
          ],
        ),
        text: contents[i],
      );
      // 文本的画笔
      TextPainter tp = _getTextPainter(span, size, angles.first);
      // 需要给定
      tp.layout(minWidth: size.width / 4, maxWidth: size.width / 4);
      tp.paint(canvas, Offset(-size.width / 2 + 20, 0 - (tp.height / 2)));

      // 转回来
      canvas.restore();
      startAngles = angles[i];
    }
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) => false;

  double maxHeight(Size size, double angle) {
    final double radius = size.width / 2;
    var maxHeight = radius * 2 * sin(angle / 2);
    maxHeight = maxHeight * 0.75;
    return maxHeight;
  }

  TextPainter _getTextPainter(TextSpan span, Size size, double angle) {
    // 文本的画笔
    TextPainter tp = TextPainter(
      text: span,
      textAlign: TextAlign.center,
      textDirection: TextDirection.ltr,
      textWidthBasis: TextWidthBasis.longestLine,
    );
    tp.layout(minWidth: size.width / 4, maxWidth: size.width / 4);
    // 计算文本高度,超出自适应大小
    if (tp.height > maxHeight(size, angle)) {
      var temSpan = TextSpan(
        style: span.style!.copyWith(
          fontSize: span.style!.fontSize! - 1.0,
        ),
        text: span.text,
      );
      tp = _getTextPainter(temSpan, size, angle);
    }
    return tp;
  }
}

让转盘转起来

既然是转盘,那肯定是要能转的。所以给它加个Transform.rotate,然后用动画控制它旋转。

AnimationController:

_angleController = AnimationController(
  vsync: this,
  duration: const Duration(milliseconds: 3000),
  upperBound: 1.0,
  lowerBound: 0.0,
);
_angleAnimation =
CurvedAnimation(parent: _angleController, curve: Curves.easeOutCirc)
  ..addListener(() {
    if (mounted) {
      setState(() {
        _angle = _angleAnimation.value * _circleTime;
      });
    }
  });

View:

// 轮盘
Transform.rotate(
  angle: _angle * (pi * 2) - _prizeResultPi,
  child: CustomPaint(
    size: Size.fromRadius(radius),
    painter: LuckyDrawPaint(
      selectSize: _luckyPrizesList.length,
      colors: _luckyPrizesList
          .map((e) => e.color)
          .toList(),
      contents: _luckyPrizesList
          .map((e) => e.content)
          .toList(),
    ),
  ),
),

然后结果用Random让它随机,再转到得到的结果上

/// 开始抽奖
void goDraw() async {
  var index = Random().nextInt(_luckyPrizesList.length);
  _prizeResult =
      (index / _luckyPrizesList.length) + _midTweenDouble;
  _angleController.forward(from: 0);
}

double get _midTweenDouble {
  if (_luckyPrizesList.isEmpty) {
    return 0;
  }
  double piTween = 1 / _luckyPrizesList.length;
  double midTween = piTween / 2;
  return midTween;
}

double get _prizeResultPi {
  return _prizeResult * pi * 2;
}

实际上是计算终点扇形的中间角度距离起始角度的距离,旋转动画之后停在那里,跟转不转其实没关系<.<,但重要的就是仪式感!

加个按钮,加个外边框,看起来好看点

// 外层白圈
Positioned.fill(
  child: Container(
    decoration: BoxDecoration(
      shape: BoxShape.circle,
      border: Border.all(
        color: Colors.white,
        width: 3,
        style: BorderStyle.solid,
      ),
    ),
  ),
),

// 轮盘按钮
Positioned(
  child: GestureDetector(
    onTap: goDraw,
    child: Container(
      width: 52,
      height: 52,
      alignment: Alignment.center,
      decoration: const BoxDecoration(
        color: Colors.black,
        shape: BoxShape.circle,
      ),
      child: const Text(
        'GO',
        style: TextStyle(
          fontSize: 20,
          color: Colors.white,
          fontWeight: FontWeight.bold,
        ),
      ),
    ),
  ),
),

再加个箭头

// 箭头
const Positioned(
  top: -3,
  child: TriangleRadius(
    size: Size(30, 30),
    color: Colors.black,
  ),
),

箭头就简单的画了个倒三角。

The End, 抽奖!

让我们抽一下这个夏天去哪浪!

1654066098699223_.gif

(微笑.jpg)

一个体验小Demo,可以直接访问玩玩~ LuckyDraw