组件分享:Flutter 文本水印

520 阅读3分钟

写了个水印组件,有缘人直接拿走。 (不是故意水贴,有时间回来再编辑)

简单介绍

基于 InheritedWidget、SingleChildRenderObjectWidget 实现。

水印原理

  1. 将设定的旋转角度、控件尺寸带入三角函数可求得水印图层尺寸。

  2. 建立图层绘制水印,然后同样基于三角函数求得坐标,随后进行旋转、位移。

  3. 裁剪并合并图层。

202210191052879.png

202210191052880.png

202210191052881.png

  • 局部水印

Widget build(BuildContext context) {
  return YTextWatermark(
    text: '我的水印',
    spacing: 22,
    runSpacing: 44,
    child: Container(color: Colors.yellow, width: 200, height: 600),
  );
}
  • 全局水印

如果希望水印覆盖整个应用,则需要使用 YTextWatermark 包裹 MyApp。

void main() {
  runApp(
    const YTextWatermark(
      child: MyApp(),
    ),
  );
}

可以在子节点中调用内置函数更新水印

void _update(BuildContext context) {
  YTextWatermark.of(context).update(
    text: '我是新水印', // 文本
    // style: const TextStyle(color: Colors.red), // 文本样式
    // clipOffset: Offset.zero,                   // 裁切偏移
    // layerOffset: Offset.zero,                  // 画布偏移
    // angle: 45.0,                               // * 旋转角度
    // spacing: 100.0,                            // * x 轴字体间距
    // runSpacing: 100.0,                         // * y 轴字体间距
  );
}

可以在子节点中调用内置函数清除水印

void _clear(BuildContext context) {
  YTextWatermark.of(context).clear();
}

可以在子节点中调用内置函数基于appBar裁剪水印

void _clip(BuildContext context) {
  // 此处使用 Scaffold 组件中 body 所对应的 context
  // 基于 body 相对屏幕的偏移来裁剪水印,从而去掉 AppBar 上的水印。
  final RenderBox paperRender = context.findRenderObject()! as RenderBox;
  Offset offset = paperRender.localToGlobal(Offset.zero);
  YTextWatermark.of(context).update(clipOffset: offset);
}

源码:

import 'dart:math';

import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';

const _defaultWatermarkTextStyle = TextStyle(color: Colors.black12, fontSize: 12, fontWeight: FontWeight.w400);

class YTextWatermark extends StatefulWidget {
  const YTextWatermark({
    Key? key,
    required this.child,
    this.text,
    this.style,
    this.spacing = 55.0,
    this.runSpacing = 55.0,
    this.clipOffset = Offset.zero,
    this.layerOffset = Offset.zero,
    this.angle = 45,
    this.clipBehavior = Clip.hardEdge,
  }) : super(key: key);

  final String? text;
  final TextStyle? style;
  final double spacing;
  final double runSpacing;
  final Offset clipOffset;
  final Offset layerOffset;
  final double angle;
  final Clip clipBehavior;
  final Widget child;

  @override
  State<YTextWatermark> createState() => YTextWatermarkState();

  static YTextWatermarkState of(BuildContext context) {
    final _YTextWatermarkScope scope = _YTextWatermarkScope.of(context);
    return scope.watermarkState;
  }
}

class YTextWatermarkState extends State<YTextWatermark> {
  String? text;
  late TextStyle style;
  late double spacing;
  late double runSpacing;
  late Offset clipOffset;
  late Offset layerOffset;
  late double angle;
  late Clip clipBehavior;

  @override
  void initState() {
    this
      ..text = widget.text
      ..style = _defaultWatermarkTextStyle.merge(widget.style)
      ..spacing = widget.spacing
      ..runSpacing = widget.runSpacing
      ..clipBehavior = widget.clipBehavior
      ..clipOffset = widget.clipOffset
      ..layerOffset = widget.layerOffset
      ..angle = widget.angle
      ..clipBehavior = widget.clipBehavior;
    super.initState();
  }

  void update({
    String? text,
    TextStyle? style,
    double? spacing,
    double? runSpacing,
    Offset? clipOffset,
    Offset? layerOffset,
    double? angle,
    Clip? clipBehavior,
  }) {
    setState(() {
      if (text != null) this.text = text;
      if (style != null) this.style = this.style.merge(style);
      if (spacing != null) this.spacing = spacing;
      if (runSpacing != null) this.runSpacing = runSpacing;
      if (clipOffset != null) this.clipOffset = clipOffset;
      if (layerOffset != null) this.layerOffset = layerOffset;
      if (angle != null) this.angle = angle;
      if (clipBehavior != null) this.clipBehavior = clipBehavior;
    });
  }

  void clear() {
    setState(() {
      text = null;
    });
  }

  @override
  Widget build(BuildContext context) {
    return _YTextWatermarkScope(
      watermarkState: this,
      child: RepaintBoundary(
        child: _YTextLayer(
          textSpan: TextSpan(text: text, style: style),
          spacing: spacing,
          runSpacing: runSpacing,
          clipOffset: clipOffset,
          offset: layerOffset,
          angle: angle,
          clipBehavior: clipBehavior,
          child: RepaintBoundary(
            child: widget.child,
          ),
        ),
      ),
    );
  }
}

class _YTextWatermarkScope extends InheritedWidget {
  final YTextWatermarkState watermarkState;

  const _YTextWatermarkScope({
    Key? key,
    required this.watermarkState,
    required Widget child,
  }) : super(key: key, child: child);

  @override
  bool updateShouldNotify(covariant _YTextWatermarkScope oldWidget) {
    return watermarkState != oldWidget.watermarkState;
  }

  static _YTextWatermarkScope of(BuildContext context) {
    final result = context.dependOnInheritedWidgetOfExactType<_YTextWatermarkScope>();
    assert(result != null, '在上层组件树种未找到 YTextWatermark 组件,请检查代码~。');
    return result!;
  }
}

extension _RadiansExtension on double {
  double get radians => this * pi / 180;
}

class _YTextLayer extends SingleChildRenderObjectWidget {
  const _YTextLayer({
    Key? key,
    required this.textSpan,
    this.spacing = 140.0,
    this.runSpacing = 140.0,
    this.clipOffset = Offset.zero,
    this.offset = Offset.zero,
    this.angle = 45,
    this.clipBehavior = Clip.hardEdge,
    required Widget child,
  }) : super(key: key, child: child);

  final TextSpan textSpan;
  final double spacing;
  final double runSpacing;
  final Offset clipOffset;
  final Offset offset;
  final double angle;
  final Clip clipBehavior;

  @override
  RenderObject createRenderObject(BuildContext context) => _RenderYTextLayer(
        textSpan: textSpan,
        spacing: spacing,
        runSpacing: runSpacing,
        clipOffset: clipOffset,
        layerOffset: offset,
        angle: angle,
        clipBehavior: clipBehavior,
      );

  @override
  void updateRenderObject(BuildContext context, covariant _RenderYTextLayer renderObject) {
    renderObject
      ..textSpan = textSpan
      ..spacing = spacing
      ..runSpacing = runSpacing
      ..clipOffset = clipOffset
      ..layerOffset = offset
      ..angle = angle
      ..clipBehavior = clipBehavior;
    renderObject.markNeedsPaint();
  }
}

class _RenderYTextLayer extends RenderConstrainedBox {
  _RenderYTextLayer({
    required this.textSpan,
    required this.spacing,
    required this.runSpacing,
    required this.clipOffset,
    required this.angle,
    required this.clipBehavior,
    required this.layerOffset,
  }) : super(additionalConstraints: const BoxConstraints.expand());

  TextSpan textSpan;
  double spacing;
  double runSpacing;
  Offset clipOffset;
  Offset layerOffset;
  double angle;
  Clip clipBehavior;

  @override
  bool hitTestSelf(Offset position) => false;

  @override
  void performLayout() {
    child!.layout(constraints, parentUsesSize: true);
    size = child!.size;
  }

  void paintWatermark(PaintingContext context, Offset offset) {
    final textPainter = TextPainter(
      text: textSpan,
      textDirection: TextDirection.rtl,
      textAlign: TextAlign.end,
      textWidthBasis: TextWidthBasis.longestLine,
      maxLines: 3,
    )..layout();

    final double x1 = size.height * sin(angle.radians);
    final double x2 = size.width * sin((90 - angle).radians);
    final double y1 = size.height * sin((90 - angle).radians);
    final double y2 = size.width * sin(angle.radians);

    context.canvas.save();
    context.canvas
      ..translate(offset.dx, offset.dy)
      ..rotate(-angle.radians)
      ..translate(-x1, 0);
    final int xCount = (x1 + x2) ~/ (textPainter.width + spacing) + 1;
    final int yCount = (y1 + y2) ~/ (textPainter.height + runSpacing) + 1;
    for (int x = 0; x < xCount; x++) {
      for (int y = 0; y < yCount; y++) {
        final double dxd = (spacing / 2 + textPainter.width / 2) * (y % 2);
        final double dx = (textPainter.width + spacing) * x - dxd;
        final double dy = (textPainter.height + runSpacing) * y;
        textPainter.paint(context.canvas, Offset(dx, dy));
      }
    }
    context.canvas.restore();
  }

  @override
  void paint(PaintingContext context, Offset offset) {
    super.paint(context, offset);
    if (textSpan.text?.isEmpty ?? true) {
      return;
    }
    context.pushClipRect(
      needsCompositing,
      offset + layerOffset,
      Rect.fromLTWH(
        clipOffset.dx,
        clipOffset.dy,
        size.width,
        size.height,
      ),
      paintWatermark,
      clipBehavior: clipBehavior,
    );
  }
}