真•文本环绕问题的探究和分享

2023-10-25 10:22:04 浏览数 (1)

前言

上周领导安排了一个任务:希望我们的动态展示不是固定把图片展示在文本的上面或者下面,希望图片放在文本内容里,也不需要很复杂的效果,就排版好看就行。

Ok,这不就是富文本吗,我一下子就联想到了RichText,一想到RichText支持WidgetSpan,我就知道问题不大,但是经过测试发现这里面是个大坑......

话不多说,先展示一下本地Demo的实际效果图:


--- 本文编辑于:Flutter - 真•文本环绕问题的探究和分享

正文开始

示例一 : 解释Inline的行为

代码语言:javascript复制

dart
class _HomeState extends State<Home> {
  late TextSpan textSpan;
  @override
  void initState() {
    super.initState();
    textSpan = TextSpan(
        style: const TextStyle(color: Color.fromARGB(221, 99, 72, 85)),
        children: [
          TextSpan(text: tianlong[0],style: const TextStyle(color: Color.fromARGB(192, 87, 96, 230),fontSize: 22)),
          TextSpan(text: tianlong[1]),
          WidgetSpan(
              child: Image.network(
            "https://static.wikia.nocookie.net/spongebobsquarepants/images/a/ad/Spongebob-squarepants-1-.png/revision/latest?cb=20200215024852&path-prefix=zh",
            width: 100,
            height: 100,
          )),
          TextSpan(text: tianlong[2]),
          TextSpan(text: tianlong[3]),
          TextSpan(text: tianlong[4]),
        ]);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(),
      body: SingleChildScrollView(child: RichText(text: textSpan)),
    );
  }
}

看文本中包含图片的一行的高度等于图片的高度,这就是InlineSpan固有的行为。不管TextSpan还是WidgetSpan都是继承自InlieSpan,也就是说不论你的WidgetSpan包含什么样的child,在布局WidgetSpan的时候,它所在行的行高一定会取那一行中最高的作为行高,所以很显然自带的RichText不作处理无法直接展示文字环绕效果。

探讨文本是如何渲染的:

看一下RichText和其对应的RenderObject的关系:

当我们把TextSpan交给RichText之后,其实所有的布局、绘制都是交由对应的RenderObject:RenderParagraph来完成的

RenderParagraph的构造函数:

在构造函数中又将textSpan交给了TextPainter类

RenderParagraph布局主要过程如下:

代码语言:javascript复制


-   1:对所有占位元素进行布局,计算占位元素尺寸等信息
-   2:对文本进行布局
-   3:设置占位元素的位置
分析1:

由于RenderParagraph混入了ContainerRenderObjectMixin,所以将对其包含的所有children(行内元素)进行布局,那么RenderParagraph的children从哪里来的:

其实在RichText的构造函数中已经传给super,交由MultiChildRenderObjectWidget持有,而MultiChildRenderObjectWidget会生成对应的MultiChildRenderObjectElement,而在MultiChildRenderObjectElement附着在element树的时候会生成其对应的RenderObject的所有子Renderobject,而这些子RenderObject就是步骤一里进行布局的children

我们知道一开始传入的数据只有一个TextSpan,那么怎么就出来了children这个东西呢?来看上图中5735行的方法:

其实就是把TextSpan下所有的WidgetSpan全部收集起来了

再回到1处调用,可看到其实代码1的作用就是计算所有的WidgetSpan的布局信息的

分析2:

将刚才计算的占位元素(WidgetSpan)的布局信息设置给_textPainter,然后调用:

当对_textPainter调用layout方法之后即可计算出整个textSpan占用的尺寸等信息,其实TextPainter中还要生成对应的ui.Paragraph对象,由它来与引擎交互真正进行文本信息计算,flutter又引进了_NativeParagraph类,总之这一层是与引擎交换信息。

分析3及其后:

3其实没什么说的,就是布局偏移信息

performLayout之后的代码就是处理文本溢出等策略

本次尝试涉及到TextPainter中的能力:

注:说实在的TextPainter提供的能力实在是少的可怜,即使是ui.Paragraph也没有多出几个有用的API,只能在有限的API中尝试找到可用的方法,如果后期flutter开放更多能力自定义文本将会更加简单

getPositionForOffset:

该函数通过传入一个位置偏移量来计算出距离该位置处最近的文本偏移量

getBoxesForSelection:

该函数通过传入一个文本区域计算出这个区域中的布局方格,通常情况下每行一个方格,不过在遇到双向文本特殊情况会在一行计算出多个布局方格,具体自行测试

文本环绕的思路:

  1. 最佳方案当然是期待引擎能够提供UI.Paragraph添加可环绕的占位信息的Api,而不是当前只可添加Inline占位信息的Api
  2. 将占位区域(可环绕区域,下称定位块)看做障碍物,然后逐行进行绘制,遇到障碍物就拆分内容,跨过障碍物继续绘制,如下:
  1. 将除了障碍物的部分分割成多个矩形区域,如何分割将是一个巨大的挑战,我们的示例中将展示这种一个定位块最简单的分割方式:

上面是只有一个定位块的情况会简单很多,假如有两个定位块:

或者这样:

有很多中情况,多个块呢:

中间将会有多个缝隙,究竟要不要填充,都要计算,以及多个块之间相互交集与否,总之块越多分割起来越复杂,由于这个原因,以及后文中会提到的待完善功能,我将给出一个定位块的示例。

最难点:文本分割

正如我们所知道的,RichText接收的数据为一个单个TextSpan,且这个TextSpan会有N层嵌套,它不是一个简单文本字符串,如何来计算这个TextSpan该从哪里分割是困扰我最大的问题

TextSpan结构分析

假设一个嵌套的TextSpan如下:

代码语言:javascript复制

Dart
final t = TextSpan(
      text: "1",
      children: [
        TextSpan(text: "2", children: [
          TextSpan(text: "3"),
        ]),
        TextSpan(text: "4", children: [
          TextSpan(text: "5", children: [
            TextSpan(text: "6"),
            TextSpan(text: "7"),
          ]),
          TextSpan(text: "8"),
        ]),
        TextSpan(text: "9"),
      ],
      style: TextStyle(color: Colors.black)
    );

它是一个树状结构,对应图如下:

渲染结果如下:

所以我们看出TextSpan是按照深度优先策略进行渲染的,这样的结构可以压平成这样:

这和上面的树状图按照深度优先策略查找顺序是一样的,唯一需要处理的可能就是style的继承,压缩思路,其实就是深度遍历,然后一个个收集起来进行组合创建新的TextSpan,记住这一点,后文给出压平代码;

深Copy TextSpan:

由于TextSpan是不可变的且嵌套下去的,我们先定义一个辅助方法,根据现有的TextSpan深度Copy复制一个新的TextSpan的代码:

代码语言:javascript复制

Dart
  static InlineSpan createSpanFrom(
    InlineSpan inlineSpan, {
    bool removeChildren = false,
    String? replaceText,
    TextStyle? parentTextStyle,
  }) {
    final effectStyle =
        parentTextStyle?.merge(inlineSpan.style) ?? inlineSpan.style;
    if (inlineSpan is WidgetSpan) {
      return inlineSpan;
    } else if (inlineSpan is TextSpan) {
      String? effectText =
          replaceText == "" ? null : replaceText ?? inlineSpan.text;
      return TextSpan(
        text: effectText,
        children: removeChildren
            ? null
            : inlineSpan.children
                ?.map(
                    (e) => createSpanFrom(e, parentTextStyle: inlineSpan.style))
                .toList(),
        locale: inlineSpan.locale,
        mouseCursor: inlineSpan.mouseCursor,
        onEnter: inlineSpan.onEnter,
        onExit: inlineSpan.onExit,
        recognizer: inlineSpan.recognizer,
        semanticsLabel: inlineSpan.semanticsLabel,
        spellOut: inlineSpan.spellOut,
        style: effectStyle,
      );
    }
    throw ErrorDescription("有除了TextSpan和WidgetSpan以外的Span,需要额外处理");
  }
}

TextPosition对象解析

包含两个属性int offset 和TextAffinity affinity

offset:

文本字符串中的位置,指的是对应索引字符串之后的位置

affinity:

辅助定位,主要为了应对双向文本或者强制换行的时候光标应该在哪个位置

根据TextPosition找到指定的分割位置:

通过遍历TextSpan,累积增加文本长度直到查找到TextPosition的offset恰好落在该TextSpan的范围内,将遍历过程中这个位置之前的内容合并创建出一个前导TextSpan,剩余部分合并创建一个后置TextSpan,这样就可以完成TextSpan分割了:

关键代码:

代码语言:javascript复制

dart
  void _splitSpanInPosition(TextSpan textSpan, TextPosition position) {
    final Accumulator offset = Accumulator();
    InlineSpan? result;
    textSpan.visitChildren((InlineSpan span) {
      if (result != null) {
        _trailingTextSpans.add(createSpanFrom(span, removeChildren: true));
        return true;
      } else {
        result = _doSplitSpanInPosition(span, position, offset);
        if (result == null) {
          _leadingTextSpans.add(createSpanFrom(span, removeChildren: true));
        }
      }
      return true;
    });
  }

  InlineSpan? _doSplitSpanInPosition(
    InlineSpan textSpan,
    TextPosition position,
    Accumulator offset,
  ) {
    InlineSpan? beforeCurrentTextSpan;
    InlineSpan? afterCurrentTextSpan;

    final TextAffinity affinity = position.affinity;
    final int targetOffset = position.offset;
    int endOffset = 0;
    /// 暂只支持TextSpan
    textSpan as TextSpan;
    final text = textSpan.text;
    if (text == null) {
      return null;
    }
    endOffset = offset.value   text.length;
    if (offset.value == targetOffset && affinity == TextAffinity.downstream ||
        offset.value < targetOffset && targetOffset < endOffset ||
        endOffset == targetOffset && affinity == TextAffinity.upstream) {
      /// 找到了对应的TextSpan,这个时候就要切分了
      if (offset.value < targetOffset && targetOffset < endOffset) {
        final beforeStr = text.substring(0, targetOffset - offset.value);
        final afterStr = text.substring(targetOffset - offset.value);
        beforeCurrentTextSpan = createSpanFrom(textSpan,
            removeChildren: true, replaceText: beforeStr) as TextSpan;
        afterCurrentTextSpan = createSpanFrom(textSpan,
            removeChildren: true, replaceText: afterStr) as TextSpan;
        // currentTextSpan = textSpan;
      } else if (offset.value == targetOffset &&
          affinity == TextAffinity.downstream) {
        beforeCurrentTextSpan =
            createSpanFrom(textSpan, removeChildren: true, replaceText: "")
                as TextSpan;
        afterCurrentTextSpan =
            createSpanFrom(textSpan, removeChildren: true) as TextSpan;
        // currentTextSpan = textSpan;
      } else if (endOffset == targetOffset &&
          affinity == TextAffinity.upstream) {
        beforeCurrentTextSpan =
            createSpanFrom(textSpan, removeChildren: true) as TextSpan;
        afterCurrentTextSpan =
            createSpanFrom(textSpan, removeChildren: true, replaceText: "")
                as TextSpan;
        // currentTextSpan = textSpan;
      }
      if (beforeCurrentTextSpan != null) {
        _leadingTextSpans.add(beforeCurrentTextSpan);
      }
      if (afterCurrentTextSpan != null) {
        _trailingTextSpans.add(afterCurrentTextSpan);
      }
      return textSpan;
    }
    offset.increment(text.length);
    return null;
  }

TextPosition 从哪里来?

这里用到了前文提到的getPositionForOffset方法,当我们划分好矩形方块之后即可传入矩形的右下角位置获取这个矩形能够放置的TextSpan的TextPosition了。

而前文提到的另一个方法getBoxesForSelection,则是为了更精准判断文本实际可渲染矩形的,另外要注意实际测试过程中发现一些坑,比如textPaint传入最大宽度10来进行布局,但实际得到的宽度可能会大于10,而且可能大于最大宽度还不少,这些问题尚不清楚,读者可自行测试,有了解的可以交流。

注意事项:

上面提到了本文Demo还未完善的内容在这里:由于WidgetSpan的尺寸等信息需要在布局阶段中计算,本次Demo只是给出一个简单的展示,故直接给出了固定的占位信息,如需处理复杂情况,请自行在布局阶段计算,或参考本Demo给出固定信息的方案。

附:

  • 一个本文查阅资料过程中参考的插件:drop_cap_text
  • 本文中的Demo代码

总结

本文探讨的过程中意外保留了TextSpan的兼容性,而不是创建一个纯文本String来自行解析,这样保留了TextSpan固有的手势检测等固有能力,同时也可以保留WidgetSpan的自定义能力,因此在这方面是一个不小的优势。

不过目前是按照分块布局的,而非逐行布局,经过我目前调研逐行布局还是一个挑战。总之这个思路是一个不错的尝试和开端。

后续可能会做的事:

  1. 研究一下多个矩形块的情况
  2. 尝试一下上文提到的思路2的方式逐行绘制
  3. 考虑加上光标,增加可编辑能力
  4. 制作一个可用的插件上传到pub上

往期推荐

  • Flutter混编方案在起点客户端的实践之路
  • Flutter性能揭秘之RepaintBoundary
  • 当我用ChatGPT摸了一上午鱼,结果......
  • 从Kotlin中return@forEach了个寂寞

0 人点赞