本节将通过实现一个水印组件来介绍一下如何绘制文本以及如何进行离屏渲染。
在实际场景中,大多数情况下水印是要铺满整个屏幕的,如果不需要铺满屏幕,通常直接用组件组合即可实现,本节我们主要讨论的是需要铺满屏幕的水印。
我们可以通过绘制一个“单元水印”,然后让它在整个水印组件的背景中重复即可实现我们期望的功能,因此我们可以直接使用 DecoratedBox ,它拥有背景图重复功能。重复的问题解决后,那么主要的问题便是如何绘制单元水印,为了灵活好扩展,我们定义一个水印画笔接口,这样一来我们可以预置一些常用的画笔实现来满足大多数场景,同时如果开发者有自定义需求的话也可以通过自定义画笔来实现。
下面是水印组件 WaterMark 的定义:
class WaterMark extends StatefulWidget {WaterMark({Key? key,this.repeat = ImageRepeat.repeat,required this.painter,}) : super(key: key);/// 单元水印画笔final WaterMarkPainter painter;/// 单元水印的重复方式final ImageRepeat repeat;@overrideState<WaterMark> createState() => _WaterMarkState();}
下面看一下 State 实现:
class _WaterMarkState extends State<WaterMark> {late Future<MemoryImage> _memoryImageFuture;@overridevoid initState() {// 缓存的是promise_memoryImageFuture = _getWaterMarkImage();super.initState();}@overrideWidget build(BuildContext context) {return SizedBox.expand( // 水印尽可能大child: FutureBuilder(future: _memoryImageFuture,builder: (BuildContext context, AsyncSnapshot snapshot) {if (snapshot.connectionState != ConnectionState.done) {// 如果单元水印还没有绘制好先返回一个空的Containerreturn Container();} else {// 如果单元水印已经绘制好,则渲染水印return DecoratedBox(decoration: BoxDecoration(image: DecorationImage(image: snapshot.data, // 背景图,即我们绘制的单元水印图片repeat: widget.repeat, // 指定重复方式alignment: Alignment.topLeft,),),);}},),);}@overridevoid didUpdateWidget(WaterMark oldWidget) {... //待实现}// 离屏绘制单元水印并将绘制结果转为图片缓存起来Future<MemoryImage> _getWaterMarkImage() async {... //待实现}@overridevoid dispose() {...// 待实现}}
我们通过 DecoratedBox 来实现背景图重复,同时我们在组件初始化时开始进行离屏绘制单元水印,并将结果缓存在 MemoryImage 中,因为离屏绘制是一个异步任务,所以直接缓存 Future 即可。这里需要注意,当组件重新build时,如果画笔配置发生变化,则我们需要重新绘制单元水印并缓存新的绘制结果:
@overridevoid didUpdateWidget(WaterMark oldWidget) {// 如果画笔发生了变化(类型或者配置)则重新绘制水印if (widget.painter.runtimeType != oldWidget.painter.runtimeType ||widget.painter.shouldRepaint(oldWidget.painter)) {//先释放之前的缓存_memoryImageFuture.then((value) => value.evict());//重新绘制并缓存_memoryImageFuture = _getWaterMarkImage();}super.didUpdateWidget(oldWidget);}
注意,在重新绘制单元水印之前要先将旧单元水印的缓存清理掉,清理缓存可以通过调用 MemoryImage 的 evict 方法。同时,当组件卸载时,我们也要释放缓存:
@overridevoid dispose() {//释放图片缓存_memoryImageFuture.then((value) => value.evict());super.dispose();}
接下来就需要重新绘制单元水印了,调用 _getWaterMarkImage()
方法即可,该方法的功能是离屏绘制单元水印并将绘制结果转为图片缓存起来,下面我们看一下它的实现。
离屏绘制的代码如下:
// 离屏绘制单元水印并将绘制结果保存为图片缓存起来Future<MemoryImage> _getWaterMarkImage() async {// 创建一个 Canvas 进行离屏绘制,细节和原理请查看本书后面14.5节。final recorder = ui.PictureRecorder();final canvas = Canvas(recorder);// 绘制单元水印并获取其大小final size = widget.painter.paintUnit(canvas,MediaQueryData.fromWindow(ui.window).devicePixelRatio,);final picture = recorder.endRecording();//将单元水印导为图片并缓存起来final img = await picture.toImage(size.width.ceil(), size.height.ceil());final byteData = await img.toByteData(format: ui.ImageByteFormat.png);final pngBytes = byteData!.buffer.asUint8List();return MemoryImage(pngBytes);}
我们通过手动创建了一个 Canvas 和一个 PictureRecorder 来实现离屏绘制,PictureRecorder 的功能先简单介绍一下,我们会在本书后面绘制原理相关章节详细介绍,简单来说:调用 Canvas API 后,实际上产生的是一系列绘制指令,这些绘制指令执行后才能获取绘制结果,而PictureRecorder 就是一个绘制指令记录器,它可以记录一段时间内所有绘制指令,我们可以通过调用 recorder.endRecording()
方法来获取记录的绘制指令,该方法返回一个 Picture 对象,它是绘制指令的载体,它有一个 toImage 方法,调用后会执行绘制指令获得绘制的像素结果(ui.Image 对象),之后我们就可以将像素结果转为 png 格式的数据并缓存在MemoryImage 中。
现在我们看一下如何绘制单元水印,我们先看一下水印画笔接口的定义:
/// 定义水印画笔abstract class WaterMarkPainter {/// 绘制"单元水印",完整的水印是由单元水印重复平铺组成,返回值为"单元水印"占用空间的大小。/// [devicePixelRatio]: 因为最终要将绘制内容保存为图片,所以在绘制时需要根据屏幕的/// DPR来放大,以防止失真Size paintUnit(Canvas canvas, double devicePixelRatio);/// 是否需要重绘bool shouldRepaint(covariant WaterMarkPainter oldPainter) => true;}
定义很简单,就两个函数:
paintUnit
在完成绘制单元水印任务的同时,最后得返回单元水印的大小信息,它在导为图片时要用到。下面我们实现一个文本水印画笔,它可以绘制一段文本,我们可以指定文本的样式和旋转角度。
/// 文本水印画笔class TextWaterMarkPainter extends WaterMarkPainter {TextWaterMarkPainter({Key? key,double? rotate,EdgeInsets? padding,TextStyle? textStyle,required this.text,}) : assert(rotate == null || rotate >= -90 && rotate <= 90),rotate = rotate ?? 0,padding = padding ?? const EdgeInsets.all(10.0),textStyle = textStyle ??TextStyle(color: Color.fromARGB(20, 0, 0, 0),fontSize: 14,);double rotate; // 文本旋转的度数,是角度不是弧度TextStyle textStyle; // 文本样式EdgeInsets padding; // 文本的 paddingString text; // 文本@overrideSize paintUnit(Canvas canvas,double devicePixelRatio) {// 1. 先绘制文本// 2. 应用旋转和padding}@overridebool shouldRepaint(TextWaterMarkPainter oldPainter) {...// 待实现}}
paintUnit 的绘制分两步:
文本的绘制三步:
具体代码如下:
import 'dart:ui' as ui;...@overrideSize paintUnit(Canvas canvas,double devicePixelRatio) {//根据屏幕 devicePixelRatio 对文本样式中长度相关的一些值乘以devicePixelRatiofinal _textStyle = _handleTextStyle(textStyle, devicePixelRatio);final _padding = padding * devicePixelRatio;//构建文本段落final builder = ui.ParagraphBuilder(_textStyle.getParagraphStyle(textDirection: textDirection,textAlign: TextAlign.start,textScaleFactor: devicePixelRatio,));//添加要绘制的文本及样式builder..pushStyle(_textStyle.getTextStyle()) // textStyle 为 ui.TextStyle..addText(text);//layout 后我们才能知道文本占用的空间ui.Paragraph paragraph = builder.build()..layout(ui.ParagraphConstraints(width: double.infinity));//文本占用的真实宽度final textWidth = paragraph.longestLine.ceilToDouble();//文本占用的真实高度final fontSize = paragraph.height;...//省略应用旋转和 padding 的相关代码//绘制文本canvas.drawParagraph(paragraph, Offset.zero);}TextStyle _handleTextStyle(double devicePixelRatio) {var style = textStyle;double _scale(attr) => attr == null ? 1.0 : devicePixelRatio;return style.apply(decorationThicknessFactor: _scale(style.decorationThickness),letterSpacingFactor: _scale(style.letterSpacing),wordSpacingFactor: _scale(style.wordSpacing),heightFactor: _scale(style.height),);}
可以看到绘制文本的过程还是比较复杂的,为此 Flutter 提供了一个专门用于绘制文本的画笔 TextPainter,我们用 TextPainter 改造上面代码:
//构建文本画笔TextPainter painter = TextPainter(textDirection: TextDirection.ltr,textScaleFactor: devicePixelRatio,);//添加文本和样式painter.text = TextSpan(text: text, style: _textStyle);//对文本进行布局painter.layout();//文本占用的真实宽度final textWidth = painter.width;//文本占用的真实高度final textHeight = painter.height;...//省略应用旋转和 padding 的相关代码// 绘制文本painter.paint(canvas, Offset.zero);
可以看到,代码实际上少不了多少,但是清晰了一些。
另外 TextPainter 在实战中还有一个用处就是我们想提前知道 Text 组件的宽高时,可以通过 TextPainter 来提前测量,比如:
Widget wTextPainterTest() {// 我们想提前知道 Text 组件的大小Text text = Text('flutter@wendux', style: TextStyle(fontSize: 18));// 使用 TextPainter 来测量TextPainter painter = TextPainter(textDirection: TextDirection.ltr);// 将 Text 组件文本和样式透传给TextPainterpainter.text = TextSpan(text: text.data,style:text.style);// 开始布局测量,调用 layout 后就能获取文本大小了painter.layout();// 自定义组件 AfterLayout 可以在布局结束后获取子组件的大小,我们用它来验证一下// TextPainter 测量的宽高是否正确return AfterLayout(callback: (RenderAfterLayout value) {// 输出日志print('text size(painter): ${painter.size}');print('text size(after layout): ${value.size}');},child: text,);}
运行后如图10-9:
从日志可以看到通过 TextPainter 测量的文本大小和实际占用是
应用旋转效果本身比较简单,但难的是文本旋转后它占用的空间大小会发生变化,所以我们得动态计算旋转后文本所占用空间的大小,假设沿顺时针方向旋转了了 rotate 角度,画出布局图10-10:
我们可以根据上面公式求出最终的宽度和高度,是不是感觉高中学的三角函数终于派上用场了!注意,上面的公式中并没有考虑padding,padding 的处理比较简单,不赘述,看代码:
@overrideSize paintUnit(Canvas canvas, double devicePixelRatio) {... // 省略//文本占用的真实宽度final textWidth = painter.width;//文本占用的真实高度final textHeight = painter.height;// 将弧度转化为度数final radians = math.pi * rotate / 180;//通过三角函数计算旋转后的位置和sizefinal orgSin = math.sin(radians);final sin = orgSin.abs();final cos = math.cos(radians).abs();final width = textWidth * cos;final height = textWidth * sin;final adjustWidth = fontSize * sin;final adjustHeight = fontSize * cos;// 为什么要平移?下面解释if (orgSin >= 0) { // 旋转角度为正canvas.translate(adjustWidth + padding.left,padding.top,);} else { // 旋转角度为负canvas.translate(padding.left,height + padding.top,);}canvas.rotate(radians);// 绘制文本painter.paint(canvas, Offset.zero);// 返回水印单元所占的真实空间大小(需要加上padding)return Size(width + adjustWidth + padding.horizontal,height + adjustHeight + padding.vertical,);}
注意,在旋转前我们对 canvas 进行了平移操作,如果不限平移,就会导致旋转之后一部分内容的位置跑在画布之外了,如图10-11:
接下来实现 shouldRepaint 方法:
@overridebool shouldRepaint(TextWaterMarkPainter oldPainter) {return oldPainter.rotate != rotate ||oldPainter.text != text ||oldPainter.padding != padding ||oldPainter.textDirection != textDirection ||oldPainter.textStyle != textStyle;}
上面这些属性发生变化时都会导致水印 UI 发生变化,所以需要重绘。
@overrideWidget build(BuildContext context) {return wTextWaterMark();}Widget wTextWaterMark() {return Stack(children: [wPage(),IgnorePointer(child: WaterMark(painter: TextWaterMarkPainter(text: 'Flutter 中国 @wendux',textStyle: TextStyle(fontSize: 15,fontWeight: FontWeight.w200,color: Colors.black38, //为了水印能更清晰一些,颜色深一点),rotate: -20, // 旋转 -20 度),),),],);}Widget wPage() {return Center(child: ElevatedButton(child: const Text('按钮'),onPressed: () => print('tab'),),);}... //省略无关代码
运行后效果如图10-12:
拥有交错效果的文本水印比较常见,效果如图10-13:
要实现这样的效果按照之前思路,我们只需要将单元水印绘制为图中红色框圈出来的部分即可,可以看到这个单元水印和之前TextWaterMarkPainter 有一点不同,即 TextWaterMarkPainter 只能绘制单个文本,而现在我们需要绘制两个问文本,且两个文本沿竖直方向排列,且两个文本左边起始位置有偏移。
我们想想如何实现?直接能想到的是继续在 TextWaterMarkPainter 的 paintUnit 方法后面加逻辑,但这样会带来两个问题:
不能直接修改 TextWaterMarkPainter 实现,但我们有想复用 TextWaterMarkPainter 的逻辑,这时可以使用代理模式,即我们新建一个WaterMarkPainter,在里面来调用 TextWaterMarkPainter 方法即可。
/// 交错文本水印画笔,可以在水平或垂直方向上组合两个文本水印,/// 通过给第二个文本水印指定不同的 padding 来实现交错效果。class StaggerTextWaterMarkPainter extends WaterMarkPainter {StaggerTextWaterMarkPainter({required this.text,this.padding1,this.padding2 = const EdgeInsets.all(30),this.rotate,this.textStyle,this.staggerAxis = Axis.vertical,String? text2,}) : text2 = text2 ?? text;//第一个文本String text;//第二个文本,如果不指定则和第二个文本相同String text2;//我们限制两个文本的旋转角度和文本样式必须相同,否则显得太乱了double? rotate;ui.TextStyle? textStyle;//第一个文本的paddingEdgeInsets? padding1;//第二个文本的paddingEdgeInsets padding2;// 两个文本沿哪个方向排列Axis staggerAxis;@overrideSize paintUnit(Canvas canvas, double devicePixelRatio) {final TextWaterMarkPainter painter = TextWaterMarkPainter(text: text,padding: padding1,rotate: rotate ?? 0,textStyle: textStyle,);// 绘制第一个文本水印前保存画布状态,因为在绘制过程中可能会平移或旋转画布canvas.save();// 绘制第一个文本水印final size1 = painter.paintUnit(canvas, devicePixelRatio);// 绘制完毕后恢复画布状态。canvas.restore();// 确定交错方向bool vertical = staggerAxis == Axis.vertical;// 将 Canvas平移至第二个文本水印的起始绘制点canvas.translate(vertical ? 0 : size1.width, vertical ? size1.height : 0);// 设置第二个文本水印的 padding 和 text2painter..padding = padding2..text = text2;// 绘制第二个文本水印final size2 = painter.paintUnit(canvas, devicePixelRatio);// 返回两个文本水印所占用的总大小return Size(vertical ? math.max(size1.width, size2.width) : size1.width + size2.width,vertical? size1.height + size2.height: math.max(size1.height, size2.height),);}@overridebool shouldRepaint(StaggerTextWaterMarkPainter oldPainter) {return oldPainter.rotate != rotate ||oldPainter.text != text ||oldPainter.text2 != text2 ||oldPainter.staggerAxis != staggerAxis ||oldPainter.padding1 != padding1 ||oldPainter.padding2 != padding2 ||oldPainter.textDirection != textDirection ||oldPainter.textStyle != textStyle;}}
上面代码有三点需要注意:
下面代码运行后就可以看到图10-13的效果了:
Widget wStaggerTextWaterMark() {return Stack(children: [wPage(),IgnorePointer(child: WaterMark(painter: StaggerTextWaterMarkPainter(text: '《Flutter实战》',text2: 'wendux',textStyle: TextStyle(color: Colors.black38,),padding2: EdgeInsets.only(left: 40), // 第二个文本左边向右偏移 40rotate: -10,),),),],);}
我们实现的两个文本水印画笔能对单元水印指定padding,但是如果我们需要对整个水印组件应用偏移效果呢?比如期望如图10-14所示的效果:让 WaterMark 的整个背景向左平移了30像素,可以看到第一列的水印文本只显示了一部分。
首先,我们不能在文本水印画笔中应用偏移,因为水印画笔画的是单元水印,如果我们绘制的单元水印只显示了部分文本,则单元水印重复时每个重复区域也都只显示部分文本。所以我们得对 WaterMark 的背景整体做一个偏移,这时想必读者应该想到了 Transform 组件,OK,那我们先用 Transform 组件来试试。
Transform.translate(offset: Offset(-30,0), //向做偏移30像素child: WaterMark(painter: TextWaterMarkPainter(text: 'Flutter 中国 @wendux',textStyle: TextStyle(color: Colors.black38,),rotate: -20,),),),
运行后效果如图10-15:
可以发现虽然整体向做偏移了,但是右边出现了空白,这时因为 WaterMark 占用的空间本来就是和屏幕等宽的,所以它绘制时的区域也就和屏幕一样大,而Transform.translate 的作用相当于是在绘制时将绘制的原点向做平移了 30 像素,所以右边就出现了空白。
既然如此,那如果能让 WaterMark 的绘制区域超过屏幕宽度 30 像素,这样平移后不就可以了么?这个思路是对的,我们知道 WaterMark 中是通过 DecoratedBox 去绘制的背景,但我们不能去修改 DecoratedBox 的绘制逻辑,如果将 DecoratedBox 相关代码拷贝一份出来修改,这样后期的维护成本就很大,所以直接修改 DecoratedBox 的方法不可取。
我们知道大多数组件的绘制区域是和自身布局大小是相同的,那么我们能不能强制让 WaterMark 的宽度超出屏幕宽度30 像素呢?当然可以,可滚动组件不都是这个原理么!那么肯定有一个方法能行的通,即强制指定WaterMark的宽度比屏幕宽度大30,然后用一个 SingleChildScrollView包裹:
Widget wTextWaterMarkWithOffset() {return Stack(children: [wPage(),IgnorePointer(child: LayoutBuilder(builder: (context, constraints) {print(constraints);return SingleChildScrollView(scrollDirection: Axis.horizontal,child: Transform.translate(offset: Offset(-30, 0),child: SizedBox(// constraints.maxWidth 为屏幕宽度,+30 像素width: constraints.maxWidth + 30,height: constraints.maxHeight,child: WaterMark(painter: TextWaterMarkPainter(text: 'Flutter 中国 @wendux',textStyle: TextStyle(color: Colors.black38,),rotate: -20,),),),),);}),),],);}
上面的代码可以实现我们期望的效果(见图10-14)。
需要说明的是因为 SingleChildScrollView 被 IgnorePointer 包裹着,所以它是接收不到事件的,所以不会受用户滑动的干扰。
我们知道 SingleChildScrollView 内部要创建Scrollable 和 Viewport 对象,而在这个场景下 SingleChildScrollView 是不会响应事件的,所以创建 Scrollable 就属于多余的开销,我们需要探索一种更优的方案。
我们能否先通过 UnconstrainedBox 取消父组件对子组件大小的约束,然后通过 SizedBox 指定 WaterMark 宽度比屏幕长 30 像素 来实现,比如:
LayoutBuilder(builder: (_, constraints) {return UnconstrainedBox( // 取消父组件对子组件大小的约束alignment: Alignment.topRight,child: SizedBox(//指定 WaterMark 宽度比屏幕长 30 像素width: constraints.maxWidth + 30,height: constraints.maxHeight,child: WaterMark(...),),);},),
运行后效果如图10-16:
我们看到,左边出现了一个溢出提示条,这是因为 UnconstrainedBox 虽然在其子组件布局时可以取消约束(子组件可以为无限大),但是 UnconstrainedBox 自身是受其父组件约束的,所以当 UnconstrainedBox 随着其子组件变大后,如果 UnconstrainedBox 的大小超过它父组件大小时,就导致了溢出。
如果没有这个溢出提示条,则我们想要的偏移效果实际上已经实现了!偏移的实现原理是我们指定了屏幕右对齐,因为子组件的右边界和父组件右边界对齐时,超出的 30 像素宽度就会在父组件的左边界之外,从而就实现了我们期望的效果。我们知道在 Release 模式下是不会绘制溢出提示条的,因为溢出条的绘制逻辑是在 assert 函数中,比如:
// Display the overflow indicator.assert(() {paintOverflowIndicator(context, offset, _overflowContainerRect, _overflowChildRect);return true;}());
所以在 Release 模式下上面代码也不会有问题,但是我们还是不应该使用这种方法,因为既然有提示,则这就代表 UnconstrainedBox 子元素溢出是不被预期的行为。
原因搞清楚后,我们解决思路就是:在取消约束的同时不要让组件大小超出父组件的空间即可。而我们之前章节介绍的 FittedBox 组件,它可以取消父组件对子组件的约束并同时可以让其子组件适配 FittedBox 父组件的大小,正好符合我们的要求,下面我们修改一下代码:
LayoutBuilder(builder: (_, constraints) {return FittedBox( //FittedBox会取消父组件对子组件的约束alignment: Alignment.topRight, // 通过对齐方式来实现平移效果fit: BoxFit.none,//不进行任何适配处理child: SizedBox(//指定 WaterMark 宽度比屏幕长 30 像素width: constraints.maxWidth + 30,height: constraints.maxHeight,child: WaterMark(painter: TextWaterMarkPainter(text: 'Flutter 中国 @wendux',textStyle: TextStyle(color: Colors.black38,),rotate: -20,),),),);},),
运行后,能实现我们预期的效果(见图10-14)。
FittedBox 主要的使用场景是对子组件进行一些缩放、拉升等以适配父组件的空间,而在本例的场景中我们并没有用到这个功能(适配方式制定了 BoxFit.none ),还是有点杀鸡用牛刀的感觉,那还有其他更合适的组件来解决这个问题吗?答案是有,OverflowBox !
OverflowBox 和 UnconstrainedBox 相同的是可以取消父组件对子组件的约束,但不同的是 OverflowBox 自身大小不会随着子组件大小而变化,它的大小只取决于其父组件的约束(约束为 constraints.biggest),即在满足父组件约束的前提下会尽可能大。我们封装一个 TranslateWithExpandedPaintingArea 组件来包裹 WaterMark 组件:
class TranslateWithExpandedPaintingArea extends StatelessWidget {const TranslateWithExpandedPaintingArea({Key? key,required this.offset,this.clipBehavior = Clip.none,this.child,}) : super(key: key);final Widget? child;final Offset offset;final Clip clipBehavior;@overrideWidget build(BuildContext context) {return LayoutBuilder(builder: (context, constraints) {final dx = offset.dx.abs();final dy = offset.dy.abs();Widget widget = OverflowBox(//平移多少,则子组件相应轴的长度增加多少minWidth: constraints.minWidth + dx,maxWidth: constraints.maxWidth + dx,minHeight: constraints.minHeight + dy,maxHeight: constraints.maxHeight + dy,alignment: Alignment(// 不同方向的平移,要指定不同的对齐方式offset.dx <= 0 ? 1 : -1,offset.dy <= 0 ? 1 : -1,),child: child,);//超出组件布局空间的部分要剪裁掉if (clipBehavior != Clip.none) {widget = ClipRect(clipBehavior: clipBehavior, child: widget);}return widget;},);}}
上面代码有三点需要说明:
所以最终的代码就是:
Widget wTextWaterMarkWithOffset2() {return Stack(children: [wPage(),IgnorePointer(child: TranslateWithExpandedPaintingArea(offset: Offset(-30, 0),child: WaterMark(painter: TextWaterMarkPainter(text: 'Flutter 中国 @wendux',textStyle: TextStyle(color: Colors.black38,),rotate: -20,),),),),],);}
运行后,能实现我们预期的效果(见图10-14)。
本节主要内容总结: