实现思路
- 第一步:选择一个容器,并且这个容器可以覆盖全屏;
- 第二步:容器必须还能填充背景色(或前景色),并且可以重复的平铺;
- 第三步:将指定的本文内容绘制成一个图像,这个图像用于第二步中的平铺内容。
实现的难点在于第三步,如何绘制文本,这个内容我将在 _paintUnit 方法中详细给读者介绍。
import 'package:flutter/material.dart';
import 'dart:ui' as ui;
import 'dart:math';
class WaterMark extends StatefulWidget {
/// 文本内容
final String text;
/// 水印的透明度
final double opacity;
/// 水印对齐方式
final Alignment alignment;
/// 绘制本文的样式
final TextStyle textStyle;
const WaterMark({
super.key,
required this.text,
this.opacity = 0.8,
this.alignment = Alignment.topLeft,
this.textStyle = const TextStyle(fontSize: 12, color: Colors.black26),
});
@override
_WaterMarkState createState() => _WaterMarkState();
}
class _WaterMarkState extends State<WaterMark> {
late final Future _future;
@override
void initState() {
_future = Future(_generateImageAsset);
super.initState();
}
Future<MemoryImage> _generateImageAsset() async {
/// 创建一个 Canvas 进行离屏绘制
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder);
/// 绘制单元水印并获取其大小
final size = _paintUnit(
canvas: canvas,
text: widget.text,
textStyle: widget.textStyle,
);
/// 完成图形操作的录制。
/// 返回一张图片,其中包含迄今为止记录的图形操作。调用此函数后,图片记录器和画布对象都无效,无法继续使用。
final picture = recorder.endRecording();
/// 根据此图片创建图像。
/// toImage(width, height) 中的 width * height 决定了图像的宽高。
/// 图片在0(左)、0(上)、宽度(右)、高度(下)范围内光栅化。超出这些界限的内容将被剪切。
final img = await picture.toImage(size.width.ceil(), size.height.ceil());
/// 将图像装换成指定类型的子节数组
final byteData = await img.toByteData(format: ui.ImageByteFormat.png);
/// 创建一个 Uint8List 字节编码的缓冲区
final pngBytes = byteData!.buffer.asUint8List();
/// 其实就是一个 ImageProvider<MemoryImage>
return MemoryImage(pngBytes);
}
Size _paintUnit({
required String text,
required Canvas canvas,
required TextStyle textStyle,
}) {
canvas.save();
/// 这里其实视同股哟有一个方法
TextPainter painter = TextPainter(
textDirection: TextDirection.ltr,
// textScaler: TextScaler.linear(
// MediaQueryData.fromView(WidgetsBinding.instance.platformDispatcher.views.first)
// .devicePixelRatio,
// ),
/// textScaler 可以根据用户的系统设置,会自动对文本进行放大或缩小,
/// 也可以设置成根据手机的设备像素比来设置(上方注释掉的部分)。
textScaler: MediaQueryData.fromView(WidgetsBinding.instance.platformDispatcher.views.first)
.textScaler,
);
painter.text = TextSpan(text: text, style: textStyle);
painter.layout();
/// width、height 表示文本的宽度和高度。
double width = painter.width;
double height = painter.height;
/// 取 width、height 最大的作为半径,生成一个 Size,并作为绘制的图像的大小。
/// 这个尺寸将作为 Canvas 画布的实际大小。
Size size = Size.fromRadius(max(width, height) / 2 * 1.2);
/// Canvas 坐标原点先移动、再旋转 45°,最后才是绘制文本内容。
/// 通过 Canvas 坐标原点的移动,从而使文本最终绘制在矩形的中心位置
/// 由于绘制区域的大小是一个 size 的正方形,所以旋转 45° 以后,此时文本应该是在对角线上,
/// 对角线的长度为 size.width * 1.414,所以要想让文本居中,
/// 就必须要先在 X 方向移动 (size.width * 1.414 - painter.width) / 2 的距离。然后再旋转 45°。
canvas.translate((size.width * 1.414 - painter.width) / 2, painter.height / 2);
canvas.rotate(pi / 4);
painter.paint(canvas, Offset.zero);
canvas.restore();
return size;
}
@override
Widget build(BuildContext context) {
return IgnorePointer(
child: FutureBuilder(
future: _future,
builder: (BuildContext context, AsyncSnapshot snapshot) {
return Container(
constraints: const BoxConstraints.expand(),
decoration: snapshot.connectionState != ConnectionState.done
? null
: BoxDecoration(
image: DecorationImage(
image: snapshot.data!,
opacity: widget.opacity,
repeat: ImageRepeat.repeat,
alignment: widget.alignment,
),
),
);
},
),
);
}
}
我们通过在最外层包裹 IgnorePointer Widget,从而实现将用户事件透传到水印组件的下面,从而使下方的 UI 界面可以响应用户事件。