Flutter 自定义画笔案例

首先让我们来看下这张图

当UI做的设计图中有这么一个元素,我想大多数人第一反应就是叫UI切图,然后直接使用Image加载,我一开始也是这么做的,毕竟省时省力省心。

但是由于后面需要针对不同的状态设置不同的颜色,我不想写过多判断语句来切换图标(我目前的做法是实现一个枚举类,然后拓展该枚举,针对每个状态设置不同的颜色,然后直接通过枚举拿到对应状态的颜色传入)

图片分析

从图片上,我们可以看到主要由以下部分构成:

  1. 外层阴影:给图标提供立体效果。
  2. 绿色底层圆:用来描绘背景。
  3. 深绿色顶层圆:叠加在底层圆上,进一步增加层次感。
  4. 白色闪电图标:位于圆的中央,表示充电。

为什么使用画笔而不是直接使用图片

使用画笔绘制图形而非直接使用图片的好处包括:

  1. 可扩展性:矢量图形可以根据不同屏幕尺寸动态调整,而不会失真。
  2. 自定义性:使用画笔可以随意调整颜色、形状等属性,更加灵活。
  3. 性能优化:绘制图形往往比加载位图更高效,特别是在需要频繁重绘的场景中。

实现步骤

下面我们逐步实现这个效果,希望能让各位有所收获

1. 创建一个 ChargePainter

首先,我们需要创建一个 CustomPainter 的子类 ChargePainter。在这个类中,我们将定义颜色属性,并在 paint 方法中实现绘制逻辑。

class ChargePainter extends CustomPainter {
  // 底层颜色
  final Color bottomColor;
  // 顶层颜色
  final Color topColor;

  ChargePainter({
    this.bottomColor = const Color(0xFF1ACC2C),
    this.topColor = const Color(0xFF1FA22C),
  });

  @override
  void paint(Canvas canvas, Size size) {
    Paint paint = Paint();
    double circleSize = size.width / 2;

    // 代码的详细解释将放在后续步骤中
  }

  @override
  bool shouldRepaint(covariant ChargePainter oldDelegate) {
    return oldDelegate.bottomColor != bottomColor || oldDelegate.topColor != topColor;
  }
}

解释一下上面的代码,我们继承了一个CustomPainter,它给提供了两个方法,分别是 paintshouldRepaint,我们画笔实现的所有内容均在 paint里,而shouldRepaint是用来判断是否重绘画笔的,接下来让我们绘制一个带阴影的圆

2. 绘制外层阴影

paint 方法中,首先绘制一个带阴影的圆,这个圆位于底层,提供立体效果。

// 绘制第一个圆
paint.shader = const LinearGradient(
  begin: Alignment.topCenter,
  end: Alignment.bottomCenter,
  colors: [Color(0xFF494949), Color(0xFF494949)],
).createShader(Rect.fromCircle(
    center: Offset(size.width / 2, size.height / 2),
    radius: size.width / 2));
paint.style = PaintingStyle.fill;
paint.color = Colors.black.withOpacity(0.6);
canvas.drawCircle(
  Offset(size.width / 2, size.height / 2),
  circleSize,
  paint,
);

将上面代码编写完成后,你将会获得一个灰色的圆形,如下图:

image

3. 绘制底层绿色圆

接下来,我们绘制一个稍小一点的绿色圆,作为底层背景。

// 绘制第二个圆
paint.shader = null;
paint.color = bottomColor;
canvas.drawCircle(
  Offset(size.width / 2, size.height / 2),
  circleSize - 1.5298,
  paint,
);

完成上面代码编写,你将会得到一个比外层阴影圆小一些的一个圆形,如下图:

image

4. 绘制顶层深绿色圆

然后,绘制另一个更小的深绿色圆,进一步增加层次感。

// 绘制第三个圆
paint.color = topColor;
canvas.drawCircle(
  Offset(size.width / 2, size.height / 2),
  circleSize - 7.9043,
  paint,
);

效果如下:

image

5. 绘制中间椭圆形的光效

接下来,使用渐变绘制一个椭圆形的光效,增强立体感。

// 绘制椭圆形
paint.shader = LinearGradient(
  begin: Alignment.topCenter,
  end: Alignment.bottomCenter,
  colors: [Colors.white, Colors.white.withOpacity(0)],
).createShader(Rect.fromLTWH(0, 8.4668, size.width, 65.7849));
canvas.drawOval(
  Rect.fromCenter(
      center: Offset(size.width / 2, size.height / 2 + 2.5498),
      width: circleSize * 1.87,
      height: circleSize * 1.73),
  paint,
);

image

如上图,我们设置了一个椭圆形白色渐变的形状,这是一个很重要的效果,主要实现一个内阴影的效果实现,增强立体感。但是很明显,它的效果太白了,和设计的效果差距巨大。显然不是我们想要的效果,但实际上解决方案也很简单,但有个前提,每个圆的效果都需要使用同一个画笔。先前我做这个效果的时候,是每个圆都创建一个新的画笔,因此无法实现,不过当前文章使用的都是同一个画笔。

在发现画笔实现的效果和设计图实现的效果的区别后,我想到了PS中有一个叫图层混合的效果,我想设计图应该也是这么实现的,就查了下画笔是否有这个功能,很幸运的是,确实有这么一个功能,我们只需要在画出这个椭圆形后,添加下面这行代码到canvas.drawOval之前

paint.blendMode = BlendMode.overlay;

这段代码指定了绘制时使用的混合模式。以下是对这段代码的详细解释:

混合模式(BlendMode)

混合模式决定了在绘制图形时,如何将新绘制的内容与已有的内容进行混合。在 Flutter 中,BlendMode 枚举类提供了多种混合模式选项,BlendMode.overlay 是其中一种。

BlendMode.overlay 的工作原理

BlendMode.overlay 结合了 BlendMode.multiplyBlendMode.screen 的效果。具体来说,当底色比中性灰(50% 灰色)暗时,overlay 使用 BlendMode.multiply;当底色比中性灰亮时,overlay 使用 BlendMode.screen。这种效果通常用于创建高对比度和富有细节的图像。

在你的代码中,设置 paint.blendMode = BlendMode.overlay; 意味着在绘制椭圆形光效时,颜色将与底层颜色混合,产生亮部更亮、暗部更暗的效果,从而增强立体感和光泽效果。

实际效果

在绘制中,BlendMode.overlay 使得椭圆形光效部分的白色渐变与底层的绿色圆形混合。这种混合效果不会完全覆盖底色,而是根据底色的亮度调整新颜色的亮度,从而产生更加自然和生动的光效。

image

6. 绘制闪电符号

设置闪电尺寸

首先,我们设置闪电符号的尺寸,使其相对于最小圆的半径进行缩放。

要计算闪电符号路径中的各个点,我们需要根据实际的图形形状定义每个点的位置,并通过数学公式将其缩放和定位。以下是闪电路径的构造步骤和计算公式。

闪电符号的几何构造

我们将闪电符号视为由一系列点和线段组成的多边形。每个点的坐标可以通过比例缩放来确定。

闪电符号的比例数据

为了构建闪电路径,我们需要定义每个点的相对位置。假设闪电符号的原始尺寸高度为 H,宽度为 W。通过以下公式可以计算每个点的位置:

  1. 顶点 A:顶部点,位于中心上方

    • ( A_x = \frac{6.5847}{23.0308} \times lightningSize )
    • ( A_y = -lightningSize )
  2. 顶点 B:左下点

    • ( B_x = -\frac{11.0841}{23.0308} \times lightningSize )
    • ( B_y = \frac{3.8118}{23.0308} \times lightningSize )
  3. 顶点 C:右上点

    • ( C_x = -\frac{0.7198}{23.0308} \times lightningSize )
    • ( C_y = \frac{3.8118}{23.0308} \times lightningSize )
  4. 顶点 D:左上点

    • ( D_x = -\frac{5.8193}{23.0308} \times lightningSize )
    • ( D_y = \frac{24.3206}{23.0308} \times lightningSize )
  5. 顶点 E:右下点

    • ( E_x = \frac{11.6842}{23.0308} \times lightningSize )
    • ( E_y = -\frac{2.9255}{23.0308} \times lightningSize )
  6. 顶点 F:右中点

    • ( F_x = \frac{1.6576}{23.0308} \times lightningSize )
    • ( F_y = -\frac{2.9255}{23.0308} \times lightningSize )

闪电符号的路径计算公式

根据上述顶点的相对位置,我们可以计算出每个点的实际坐标,并绘制闪电路径。


// 绘制闪电路径
double lightningSize = (circleSize - 7.9043) / 1.5; // 使闪电比第二个圆小

Path path = Path();
path.moveTo(
  size.width / 2 + 6.5847 * lightningSize / 23.0308,
  size.height / 2 - 23.0308 * lightningSize / 23.0308,
);
path.lineTo(
  size.width / 2 - 11.0841 * lightningSize / 23.0308,
  size.height / 2 + 3.8118 * lightningSize / 23.0308,
);
path.lineTo(
  size.width / 2 - 0.7198 * lightningSize / 23.0308,
  size.height / 2 + 3.8118 * lightningSize / 23.0308,
);
path.lineTo(
  size.width / 2 - 5.8193 * lightningSize / 23.0308,
  size.height / 2 + 24.3206 * lightningSize / 23.0308,
);
path.lineTo(
  size.width / 2 + 11.6842 * lightningSize / 23.0308,
  size.height / 2 - 2.9255 * lightningSize / 23.0308,
);
path.lineTo(
  size.width / 2 + 1.6576 * lightningSize / 23.0308,
  size.height / 2 - 2.9255 * lightningSize / 23.0308,
);
path.close();


Paint lightningPaint = Paint()
  ..color = Colors.white.withOpacity(0.8)
  ..style = PaintingStyle.fill;

canvas.drawPath(path, lightningPaint);

总结

通过定义闪电符号的顶点比例,并将其转换为实际坐标,我们可以绘制出一个相对固定比例的闪电符号。这样的方法允许我们在不同大小的圆中绘制相同比例的闪电图标。上述公式通过比例缩放和位置调整,确保闪电符号在中心对称的位置。
最终效果如下:

image

完整代码

class ChargePainter extends CustomPainter {
  final Color bottomColor;
  final Color topColor;

  ChargePainter({
    this.bottomColor = const Color(0xFF1ACC2C),
    this.topColor = const Color(0xFF1FA22C),
  });

  @override
  void paint(Canvas canvas, Size size) {
    Paint paint = Paint();
    double circleSize = size.width / 2;

    // 绘制第一个圆
    paint.shader = const LinearGradient(
      begin: Alignment.topCenter,
      end: Alignment.bottomCenter,
      colors: [Color(0xFF494949), Color(0xFF494949)],
    ).createShader(Rect.fromCircle(
        center: Offset(size.width / 2, size.height / 2),
        radius: size.width / 2));
    paint.style = PaintingStyle.fill;
    paint.color = Colors.black.withOpacity(0.6);
    canvas.drawCircle(
      Offset(size.width / 2, size.height / 2),
      circleSize,
      paint,
    );

    // 绘制第二个圆
    paint.shader = null;
    paint.color = bottomColor;
    canvas.drawCircle(
      Offset(size.width / 2, size.height / 2),
      circleSize - 1.5298,
      paint,
    );

    // 绘制第三个圆
    paint.color = topColor;
    canvas.drawCircle(
      Offset(size.width / 2, size.height / 2),
      circleSize - 7.9043,
      paint,
    );

    // 绘制椭圆形
    paint.shader = LinearGradient(
      begin: Alignment.topCenter,
      end: Alignment.bottomCenter,
      colors: [Colors.white, Colors.white.withOpacity(0)],
    ).createShader(Rect.fromLTWH(0, 8.4668, size.width, 65.7849));
    paint.blendMode = BlendMode.overlay;
    canvas.drawOval(
      Rect.fromCenter(
          center: Offset(size.width / 2, size.height / 2 + 2.5498),
          width: circleSize * 1.87,
          height: circleSize * 1.73),
      paint,
    );

    // 绘制闪电路径
    double lightningSize = (circleSize - 7.9043) / 1.5; // 使闪电比第二个圆小

    Path path = Path();
    path.moveTo(
      size.width / 2 + 6.5847 * lightningSize / 23.0308,
      size.height / 2 - 23.0308 * lightningSize / 23.0308,
    );
    path.lineTo(
      size.width / 2 - 11.0841 * lightningSize / 23.0308,
      size.height / 2 + 3.8118 * lightningSize / 23.

0308,
    );
    path.lineTo(
      size.width / 2 - 0.7198 * lightningSize / 23.0308,
      size.height / 2 + 3.8118 * lightningSize / 23.0308,
    );
    path.lineTo(
      size.width / 2 - 5.8193 * lightningSize / 23.0308,
      size.height / 2 + 24.3206 * lightningSize / 23.0308,
    );
    path.lineTo(
      size.width / 2 + 11.6842 * lightningSize / 23.0308,
      size.height / 2 - 2.9255 * lightningSize / 23.0308,
    );
    path.lineTo(
      size.width / 2 + 1.6576 * lightningSize / 23.0308,
      size.height / 2 - 2.9255 * lightningSize / 23.0308,
    );
    path.close();

    Paint lightningPaint = Paint()
      ..color = Colors.white.withOpacity(0.8)
      ..style = PaintingStyle.fill;

    canvas.drawPath(path, lightningPaint);
  }

  @override
  bool shouldRepaint(covariant ChargePainter oldDelegate) {
    return oldDelegate.bottomColor != bottomColor || oldDelegate.topColor != topColor;
  }
}

通过这种方式,不仅提高了代码的可扩展性和性能,还使我们能够轻松适应不同的设计需求和状态变化。希望这篇教程能为大家的Flutter开发提供帮助。

愿君多采撷,莫负好时光。 技术若有涯,勤学步步芳。

posted @ 2024-08-01 14:31  在云端i  阅读(3)  评论(0编辑  收藏  举报