• Lv0
    粉丝4

积分144 / 贡献0

提问1答案被采纳0文章11

[经验分享] 史上最强大的文本溢出效果,强得飞起 ! 原创

zmtzawqlp 显示全部楼层 发表于 昨天 10:14

相关阅读:

关注微信公众号 糖果代码铺 ,获取更多 Harmony/Flutter 讯息。

candies.png

前言

ExtendedText 的主要功能是支持自定义文本溢出效果ExtendedText 作为 5 年前在 Flutter 平台发布的组件库,可以说是陪伴了一代代 Flutterer 的成长。前段时间,ExtendedText 也增加了Harmony纯血 Next 系统的原生支持。

这里再说下什么是 自定义文本溢出效果

  • 溢出效果的自定义,即希望溢出的效果不是单调的 ...,而且可以指定任何组件。

对比其他平台,该系果的支持情况如下:

平台 ellipsis 自定义
android 不支持
Ios 不支持
web 不支持
flutter 不支持(ExtendedText 支持)
Harmony Next ellipsis (ExtendedText 支持)
  • 溢出效果的位置,即在开头,中间,还是结尾。

对比其他平台,该效果的支持情况如下:

平台 开头 中间 结尾
android android:ellipsize = "start"   android:ellipsize = "middle" android:ellipsize = "end" 
Ios NSLineBreakByTruncatingHead NSLineBreakByTruncatingMiddle NSLineBreakByTruncatingTail
web text-overflow: ellipsis clip 不支持 text-overflow: clip ellipsis
flutter 不支持(ExtendedText 支持) 不支持(ExtendedText 支持) TextOverflow.ellipsis
Harmony Next EllipsisMode.START (ExtendedText 支持) EllipsisMode.MIDDLE (ExtendedText 支持) EllipsisMode.END

但是需求往往在不经意间就会出现,有用户在评论区问, 支持高亮关键字加两端省略吗? 并且附上了一张图片。

1026b7b3b3804e60bdd2cb43ac4d8ce1~tplv-k3u1fbpfcp-jj-mark-v1_0_0_0_0_5o6Y6YeR5oqA.png

就是手机短信里面的搜索功能,我自己也看了一下,总结下要求:

  • 搜索的词语,高亮,即保证它要出现在文本之中
  • 根据高亮文本的位置,来设置文本溢出效果。如果高亮文字在中间,那么开头和结尾都显示溢出效果;如果高亮文字在前面可以完全显示,那么最后结尾溢出效果;如果高亮文字在文字后面,那么开头显示溢出效果。

由于溢出效果的位置完全是依据高亮文本的位置而定的,当前 ExtendedText 的功能并不能支持,所以新增了新的溢出模式 TextOverflowPosition.auto

export enum TextOverflowPosition {
  /// 开头
  start,
  /// 中间
  middle,
  /// 结尾
  end,
  /// 确保 keepVisible Span 可见
  /// 自动调整溢出位置
  auto,
}

最终实现效果如下图,也支持多行显示。

textOverflowPosition_auto.png Screenshot_2024-11-29T191811.png

实现

  • 打开冰箱
  • 放进大象
  • 关上冰箱

跟将一个大象放进冰箱一样简单,做出文本溢出效果只需要下面 4 步。

  • 裁剪文本
  • 计算文本不溢出的情况
  • 绘制溢出效果,并且遮蔽下层的文字

计算文本不溢出的情况

  • Flutter 端我们可以在 performLayout 方法中通过不断尝试裁剪 TextPainterInlineSpan,通过以下方法,判断文本是否溢出。
bool _didVisualOverflow({TextPainter? textPainter}) {
    final Size textSize = (textPainter ?? _textPainter).size;
    final bool textDidExceedMaxLines =
        (textPainter ?? _textPainter).didExceedMaxLines;
    final bool didOverflowHeight =
        size.height < textSize.height || textDidExceedMaxLines;
    final bool didOverflowWidth = size.width < textSize.width;

    if (size.height < textSize.height) {
      size = constraints.constrain(textSize);
    }

    return didOverflowWidth || didOverflowHeight;
  }
  • 在Harmony端,我们可以在 onMeasureSize 方法中通过不断调整裁剪 ParagraphBuilder 的内容,通过以下方法,判断文本是否溢出。
_didVisualOverflow(paragraph: text.Paragraph, constraint: ConstraintSizeOptions): boolean {
  let textSize: SizeResult = {
    width: px2vp(paragraph.getMaxWidth()),
    height: px2vp(paragraph.getHeight()),
  };
  let size: SizeResult = {
    width: constraint.maxWidth! as number,
    height: constraint.maxHeight! as number,
  }
  let textDidExceedMaxLines =
    paragraph.didExceedMaxLines();
  let
    didOverflowHeight =
      size.height < textSize.height || textDidExceedMaxLines;
  let
    didOverflowWidth = size.width < textSize.width;
  let hasVisualOverflow = didOverflowWidth || didOverflowHeight;
  return hasVisualOverflow;
}

裁剪文本

通过上面一步,我们可以计算出一个临界值,考虑到有复制选择功能,裁剪掉的文本不能直接丢弃,这里利用到 SpecialTextSpan

你见到的并不是真实的

SpecialTextSpan(
   'abef',
   actualText: 'abcdef',
  );

比如 abcdef, 我们找到的 Range[2,3] ,即最终显示 ab...ef。考虑支持选择复制,所以我们这里不能简单丢掉 cd

maxIndex 为文本的长度,找到文本 不溢出的溢出 临界点 index。根据溢出位置可以分为下面 4 种情况。

start

[0,offset] 区域的文本都需要被裁剪掉,即 [0,index] 舍弃, [index,maxIndex] 显示。

middle

[m,index] 区域的文本都需要被裁剪掉,其中 m 为溢出效果区域左侧的索引位置。

[0,m] 显示; [m,index] 舍弃(这里绘制溢出效果); [index,maxIndex]显示。

end

无需更多计算

auto

  • 如果高亮文本在 [offset,max] 区域能显示,这种情况就相当于 start 的情况;
  • 如果高亮文本在 [0,offset] 区域能显示,这种情况就相当于 end 的情况;
  • 如果上面 2 种情况都不满足,即前面需要裁剪,后面也需要裁剪,那么我们裁剪的区域左边一部分和右边一部分,中间的部分要保证高亮文本可见。

要使用该功能,首先需要将高亮文本(可见)对于的 SpankeepVisible 设置成 true 。后续在计算中,我们就可以找到它,然后确定高亮文本(可见)的范围,进行进一步的处理。

Harmony 端寻找高亮文本(可见)的代码如下:

let keepVisibleSpan: InlineSpan | null = null;
this.text.visitChildren((span) => {
  if (span.keepVisible === true) {
    keepVisibleSpan = span;
    return false;
  }
  return true;
})

Flutter 端寻找高亮文本(可见)的代码如下:

SpecialInlineSpanBase? keepVisibleSpan;
  text.visitChildren((InlineSpan span) {
    if (span is SpecialInlineSpanBase &&
        (span as SpecialInlineSpanBase).keepVisible == true) {
      keepVisibleSpan = span as SpecialInlineSpanBase;
      return false;
    }
    return true;
  });

绘制溢出效果,并且消除下层的文字

start

绘制在第一行的最左边。

middle

如果总行数是奇数的话,绘制在中间的一行的正中间;如果总行数是偶数的话,绘制在(总行数除以 2)+ 1 行的最左边。

end

绘制在最后一行的最右边。

auto

分为三种情况。绘制在第一行的最左边;绘制在最后一行的最右边;或者绘制在第一行的最左边以及最后一行的最右边。

消除下层文字

除了绘制溢出效果,我们还要注意一点,那就是将溢出效果下面的文字可以消除掉。具体方式为

Flutter 端通过 canvasclipRect 方法,在绘制文字之前裁剪掉那部分的区域。

// zmtzawqlp
    // clip rect of over flow
    if (_overflowRects != null) {
      context.canvas.saveLayer(offset & size, Paint());
      if (overflowWidget?.clearType == TextOverflowClearType.clipRect) {
        if (_overflowClipTextRects != null) {
          for (final Rect rect in _overflowClipTextRects!) {
            context.canvas.clipRect(
              rect.shift(offset),
              clipOp: ui.ClipOp.difference,
            );
          }
        }

        if (_overflowRects != null) {
          for (final Rect rect in _overflowRects!) {
            context.canvas.clipRect(
              rect.shift(offset),
              clipOp: ui.ClipOp.difference,
            );
          }
        }
      }
    }

    _textPainter.paint(context.canvas, offset);

    paintInlineChildren(context, offset);

    // zmtzawqlp
    if (_overflowRects != null) {
      context.canvas.restore();
    }
    // zmtzawqlp
    _paintTextOverflow(context, offset);

Harmony 端通过 canvasclipRect 方法,在绘制文字之前裁剪掉那部分的区域。

if (this.overflowClipRects.length != 0) {
  context.canvas.saveLayer();

  for (let index = 0; index < this.overflowClipRects.length; index++) {
    const overflowClipRect = this.overflowClipRects[index];
    context.canvas.clipRect(overflowClipRect, drawing.ClipOp.DIFFERENCE);
  }
}
this.paragraph.paint(context.canvas, 0, 0);

if (this.overflowClipRects.length != 0) {
  context.canvas.restore();
}

性能再突破

之前计算文本不溢出的情况,是以溢出效果的所在区域获取初始的范围,然后利用通过二分查找。实际上,这种算法会造成更多的尝试次数。

我们可以得到一个单行的 TextPainter/Paragraph 配合当前 TextPainter/Paragraph, 用来计算粗略的范围。

假设当前 TextPainter/Paragraph3 行,宽度是 100。单行的 TextPainter/Paragraph 的宽度是 500

start

那么需要裁剪掉的部分即为 500 - 100 * 3 = 200

for (final ui.LineMetrics line in lines) {
          oneLineWidth -= line.width;
        }

        end = ExtendedTextLibraryUtils
                .convertTextPainterPostionToTextInputPostion(
                    text,
                    oneLineTextPainter.getPositionForOffset(Offset(
                        math.max(oneLineWidth, overflowWidgetSize.width),
                        oneLineTextPainter.height / 2)))!
            .offset;

即可以得到初始的裁剪范围为 0 到 单行 TextPainter/Paragraph 200 位置的 index

middle

IMG_20241202_135356.jpg

这里行数是有 3 行,那么中间一行的 index 就是 1 。那么开始的溢出效果左边 x 的位置。而右边为 500 -100 -w 的位置,从后减去每行的宽度,直到 index 1 行溢出的右边。

然后也要考虑偶数行的情况,比如假设为有 4 行. 那么中间一行的 index 就是为 2,那么开始的溢出效果左边 x 的位置。而右边为 500 -100 -w 的位置,从后减去每行的宽度,直到 index 1 行溢出的右边。

final int lineNum = (lines.length / 2).floor();
        final bool isEven = lines.length.isEven;
        final ui.LineMetrics line = lines[lineNum];
        double lineTop = 0;

        for (int index = 0; index < lineNum; index++) {
          final ui.LineMetrics line = lines[index];
          lineTop += line.height;
        }

        final double lineCenter = lineTop + line.height / 2;
        ui.Rect overflowRect = Rect.zero;
        final double textWidth = _textPainter.width;
        if (isEven) {
          overflowRect = Rect.fromLTRB(
            0,
            lineCenter - overflowWidgetSize.height / 2,
            overflowWidgetSize.width,
            lineCenter + overflowWidgetSize.height / 2,
          );
        } else {
          overflowRect = Rect.fromLTRB(
            textWidth / 2 - overflowWidgetSize.width / 2,
            lineCenter - overflowWidgetSize.height / 2,
            textWidth / 2 + overflowWidgetSize.width / 2,
            lineCenter + overflowWidgetSize.height / 2,
          );
        }

        start = ExtendedTextLibraryUtils
                .convertTextPainterPostionToTextInputPostion(
                    text,
                    _textPainter
                        .getPositionForOffset(overflowRect.centerRight))!
            .offset;

        for (int index = lines.length - 1; index > lineNum; index--) {
          final ui.LineMetrics line = lines[index];
          oneLineWidth -= line.width;
        }

        oneLineWidth -= line.width - overflowRect.right;

        end = ExtendedTextLibraryUtils
                .convertTextPainterPostionToTextInputPostion(
                    text,
                    oneLineTextPainter.getPositionForOffset(Offset(
                        math.max(oneLineWidth, overflowWidgetSize.width),
                        oneLineTextPainter.height / 2)))!
            .offset;

end

不需要计算。

auto

前面我们找到了高亮文本(可见)。

Flutter 端寻找高亮文本(可见)的代码如下:

SpecialInlineSpanBase? keepVisibleSpan;
  text.visitChildren((InlineSpan span) {
    if (span is SpecialInlineSpanBase &&
        (span as SpecialInlineSpanBase).keepVisible == true) {
      keepVisibleSpan = span as SpecialInlineSpanBase;
      return false;
    }
    return true;
  });

通过 keepVisibleSpan 得到了范围 [x1, x2],不管后续怎么裁剪,我们都要保证这个范围在需要保留下来。

_TextRange keepVisibleRange = _TextRange(
            keepVisibleSpan!.textRange.start, keepVisibleSpan!.textRange.end);

        final List<ui.TextBox> rects = oneLineTextPainter.getBoxesForSelection(
            ExtendedTextLibraryUtils
                .convertTextInputSelectionToTextPainterSelection(
          text,
          TextSelection(
              baseOffset: keepVisibleRange.start,
              extentOffset: keepVisibleRange.end),
        ));

这样子我们只需要在 [0,x1][x2,maxOffset] 之中进行文本裁剪。假设当前 TextPainter/Paragraph3 行,宽度是 100,溢出效果宽度是 20 。 我们以 [x1,x2] 为范围,左右增加当前 TextPainter/Paragraph 的总长度的一半, 注意左右边界,超出的部分反补给另外一端。

IMG_20241202_161816.jpg

final List<ui.TextBox> rects = oneLineTextPainter.getBoxesForSelection(
            ExtendedTextLibraryUtils
                .convertTextInputSelectionToTextPainterSelection(
          text,
          TextSelection(
              baseOffset: keepVisibleRange.start,
              extentOffset: keepVisibleRange.end),
        ));

        double left = double.infinity;
        double right = 0;
        for (int index = 0; index < rects.length; index++) {
          final ui.TextBox rect = rects[index];
          left = math.min(rect.left, left);
          right = math.max(rect.right, right);
        }

        keepVisibleRange = _TextRange(
          ExtendedTextLibraryUtils.convertTextPainterPostionToTextInputPostion(
                  text,
                  oneLineTextPainter.getPositionForOffset(Offset(
                      left - overflowWidgetSize.width,
                      oneLineTextPainter.height / 2)))!
              .offset,
          ExtendedTextLibraryUtils.convertTextPainterPostionToTextInputPostion(
                  text,
                  oneLineTextPainter.getPositionForOffset(Offset(
                      right + overflowWidgetSize.width,
                      oneLineTextPainter.height / 2)))!
              .offset,
        );

        final double totalWidth =
            _textPainter.computeLineMetrics().length * size.width;
        final double half = math.max(
            (totalWidth - (right - left)) / 2, overflowWidgetSize.width * 2);

        left = left - half;
        right = right + half;

        if (left < 0) {
          right -= left;
          left = 0;
        }
        final double maxIntrinsicWidth = oneLineTextPainter.width;
        if (right > maxIntrinsicWidth) {
          left -= right - maxIntrinsicWidth;
          right = maxIntrinsicWidth;
        }

        final _TextRange estimatedRange = _TextRange(
          ExtendedTextLibraryUtils.convertTextPainterPostionToTextInputPostion(
                  text,
                  oneLineTextPainter.getPositionForOffset(
                      Offset(left, oneLineTextPainter.height / 2)))!
              .offset,
          ExtendedTextLibraryUtils.convertTextPainterPostionToTextInputPostion(
                  text,
                  oneLineTextPainter.getPositionForOffset(
                      Offset(right, oneLineTextPainter.height / 2)))!
              .offset,
        );

性能提升 40% 以上

通过估算大概的范围,来替换 二分法 求解,理论上文本越长,性能提升越高。

整体性能再突破 40% !

47709FAA7B6829A8DC13EBF43E59EBE1_副本.png

使用

安装

Flutter 端执行 flutter pub add extended_text

Harmony 端执行 ohpm install @candies/extended_text

设置可见 Span

根据自身的情况,将想要高亮(可见) 的 SpankeepVisible 属性设置成 true

Flutter 端代码如下:

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

class HighlightText extends RegExpSpecialText {
  @override
  RegExp get regExp => RegExp(
        "<Highlight color=['\"](.*?)['\"]>(.*?)</Highlight>",
      );

  static String getHighlightString(String content) {
    return '<Highlight color="#FF2196F3">' + content + '</Highlight>';
  }

  @override
  InlineSpan finishText(int start, Match match,
      {TextStyle? textStyle, SpecialTextGestureTapCallback? onTap}) {
    final String hexColor = match[1]!;

    return SpecialTextSpan(
      text: match[2]!,
      actualText: match[0],
      start: start,
      style: textStyle?.copyWith(
        color: Color(int.parse(hexColor.substring(1), radix: 16)),
      ),
      keepVisible: true,
    );
  }
}

class HighlightTextSpanBuilder extends RegExpSpecialTextSpanBuilder {
  @override
  List<RegExpSpecialText> get regExps => <RegExpSpecialText>[
        HighlightText(),
      ];
}

Harmony 端代码如下:

import * as extended_text from '@candies/extended_text'
import { RegExpSpecialTextSpanBuilder, TextSpan } from '@candies/extended_text';
import { text } from "@kit.ArkGraphics2D"


export class HighlightText extends extended_text.RegExpSpecialText {
  get regExp(): RegExp {
    return new RegExp("<Highlight color=['"](.*?)['"]>(.*?)</Highlight>", "g");
  }

  static getHighlightString(content: string) {
    return '<Highlight color="#FF2196F3">' + content + '</Highlight>';
  }

  finishText(start: number,
    match: RegExpExecArray,
    context: Context,
    textStyle?: text.TextStyle,): extended_text.InlineSpan {
    let color = match[1];
    return new TextSpan({
      text: match[2],
      style: {
        fontSize: vp2px(18),
        color: extended_text.ColorUtils.stringTo2DColor(color),
      },
      actualText: match[0],
      start: start,
      keepVisible: true,
    });
  }
}

export class HighlightTextSpanBuilder extends RegExpSpecialTextSpanBuilder {
  get regExps() {
    return [
      new HighlightText(),
    ];
  }
}

设置溢出位置模式

TextOverflowWidgetposition 设置成 TextOverflowPosition.auto 即可。

ExtendedText(
      searchMessages[index],
      specialTextSpanBuilder: HighlightTextSpanBuilder(),
      maxLines: searchText.isEmpty ? 3 : 1,
      overflowWidget: TextOverflowWidget(
        child: const Text('\u2026 '),
        position: TextOverflowPosition.auto,
      ),
    );

结语

textOverflowPosition_auto.png Screenshot_2024-11-29T191811.png
text_demo.png overflow.png

至此,我们在全平台(WebAndroidIosWindows, Mac, Linux, HarmonyOSHyperOS, ColorOSOriginOSMagicOSChrome OS,‌FuchsiaOS)支持了丰富的文本溢出效果。

一直没人模仿, 从未被超越!能超越的文本组件的只有糖果ExtendedText

Harmony,爱糖果,欢迎加入Harmony Candies,一起生产可爱的Harmony小糖果QQ群:981630644

关注微信公众号 糖果代码铺 ,获取更多 Harmony/Flutter 讯息。

candies.png

1733138911920.jpg

©著作权归作者所有,转载或内容合作请联系作者

您尚未登录,无法参与评论,登录后可以:
参与开源共建问题交流
认同或收藏高质量问答
获取积分成为开源共建先驱

Copyright   ©2023  OpenHarmony开发者论坛  京ICP备2020036654号-3 |技术支持 Discuz!

返回顶部