OpenHarmony开发者论坛

标题: 史上最强大的文本溢出效果,强得飞起 ! [打印本页]

作者: zmtzawqlp    时间: 2024-12-3 10:14
标题: 史上最强大的文本溢出效果,强得飞起 !
[md]相关阅读:

* [Flutter Text: 扶我起来](https://juejin.cn/post/6955095562215489573)
* [Harmony Text: 扶我起来](https://juejin.cn/post/7438259497519546380)

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

![candies.png](https://forums-obs.openharmony.c ... pq0b6byd5gtbgbo.png "candies.png")

## 前言

`ExtendedText` 的主要功能是支持 `自定义文本溢出效果`。[ExtendedText](https://pub-web.flutter-io.cn/packages/extended_text) 作为 `5` 年前在 `Flutter` 平台发布的组件库,可以说是陪伴了一代代 `Flutterer` 的成长。前段时间,`ExtendedText` 也增加了Harmony纯血 `Next` 系统的原生支持。

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

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

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

| 平台         | ellipsis 自定义                                                                                   |
| ------------ | ------------------------------------------------------------------------------------------------- |
| android      | 不支持                                                                                            |
| Ios          | 不支持                                                                                            |
| web          | 不支持                                                                                            |
| flutter      | 不支持([ExtendedText](https://pub-web.flutter-io.cn/packages/extended_text) 支持)                    |
| Harmony Next | `ellipsis` ([ExtendedText](https://ohpm.openharmony.cn/#/cn/detail/@candies%2Fextended_text) 支持) |

* 溢出效果的位置,即在开头,中间,还是结尾。

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

| 平台         | 开头                                                                                                        | 中间                                                                                                         | 结尾                             |
| ------------ | ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | -------------------------------- |
| 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](https://juejin.cn/post/6955095562215489573) 支持)                                        | 不支持([ExtendedText](https://juejin.cn/post/6955095562215489573) 支持)                                         | `TextOverflow.ellipsis`        |
| Harmony Next | `EllipsisMode.START` ([ExtendedText](https://ohpm.openharmony.cn/#/cn/detail/@candies%2Fextended_text) 支持) | `EllipsisMode.MIDDLE` ([ExtendedText](https://ohpm.openharmony.cn/#/cn/detail/@candies%2Fextended_text) 支持) | `EllipsisMode.END`             |

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

![1026b7b3b3804e60bdd2cb43ac4d8ce1~tplv-k3u1fbpfcp-jj-mark-v1_0_0_0_0_5o6Y6YeR5oqA.png](https://forums-obs.openharmony.c ... ktzhnmxti8dhhln.png "1026b7b3b3804e60bdd2cb43ac4d8ce1~tplv-k3u1fbpfcp-jj-mark-v1_0_0_0_0_5o6Y6YeR5oqA.png")

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

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

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

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

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

| ![textOverflowPosition_auto.png](https://forums-obs.openharmony.c ... f7gpzlw466u3f2u.png "textOverflowPosition_auto.png") | ![Screenshot_2024-11-29T191811.png](https://forums-obs.openharmony.c ... g3p3a8nko3023ar.png "Screenshot_2024-11-29T191811.png") |
| ----------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |

## 实现

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

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

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

### 计算文本不溢出的情况

* `Flutter` 端我们可以在 `performLayout` 方法中通过不断尝试裁剪 `TextPainter` 的 `InlineSpan`,通过以下方法,判断文本是否溢出。

```dart
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` 的内容,通过以下方法,判断文本是否溢出。

```typescript
_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](https://github.com/fluttercandie ... e_span_base.dart#L9)。

> 你见到的并不是真实的

```dart
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` 种情况都不满足,即前面需要裁剪,后面也需要裁剪,那么我们裁剪的区域左边一部分和右边一部分,中间的部分要保证高亮文本可见。

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

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

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

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

```dart
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` 端通过 `canvas` 的 `clipRect` 方法,在绘制文字之前裁剪掉那部分的区域。

```dart
// 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` 端通过 `canvas` 的 `clipRect` 方法,在绘制文字之前裁剪掉那部分的区域。

```typescript
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/Paragraph` 有 `3` 行,宽度是 `100`。单行的 `TextPainter/Paragraph` 的宽度是 `500` 。

### start

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

```dart
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](https://forums-obs.openharmony.c ... wgz90991n99dw90.jpg "IMG_20241202_135356.jpg")

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

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

```dart
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` 端寻找高亮文本(可见)的代码如下:

```dart
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]`,不管后续怎么裁剪,我们都要保证这个范围在需要保留下来。

```dart
_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/Paragraph` 有 `3` 行,宽度是 `100`,溢出效果宽度是 `20` 。 我们以 `[x1,x2] ` 为范围,左右增加当前 `TextPainter/Paragraph` 的总长度的一半, 注意左右边界,超出的部分反补给另外一端。

![IMG_20241202_161816.jpg](https://forums-obs.openharmony.c ... pb3pgiv50p40wy0.jpg "IMG_20241202_161816.jpg")

```dart
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](https://forums-obs.openharmony.c ... azjss4x2akni2ij.png "47709FAA7B6829A8DC13EBF43E59EBE1_副本.png")

## 使用

### 安装

`Flutter` 端执行 `flutter pub add extended_text`

`Harmony` 端执行 `ohpm install @candies/extended_text`

### 设置可见 Span

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

`Flutter` 端代码如下:

```dart
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` 端代码如下:

```typescript
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(),
    ];
  }
}
```

### 设置溢出位置模式

将 `TextOverflowWidget` 的 `position` 设置成 `TextOverflowPosition.auto` 即可。

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

## 结语

| ![textOverflowPosition_auto.png](https://forums-obs.openharmony.c ... f7gpzlw466u3f2u.png "textOverflowPosition_auto.png") | ![Screenshot_2024-11-29T191811.png](https://forums-obs.openharmony.c ... g3p3a8nko3023ar.png "Screenshot_2024-11-29T191811.png") |
| ----------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| ![text_demo.png](https://forums-obs.openharmony.c ... brgqrcczrhyzshd.png "text_demo.png")                                 | ![overflow.png](https://forums-obs.openharmony.c ... pkxv2gk3dvz5u5m.png "overflow.png")                                         |

至此,我们在全平台(`Web`,`Android`,`iOS`,`Windows`, `Mac`, `Linux`, `HarmonyOS`,`HyperOS`, `ColorOS`,`OriginOS`,`MagicOS`,`Chrome OS`,‌`FuchsiaOS`,`Flyme`,`MIUI` )支持了丰富的文本溢出效果。

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

爱 `Harmony`,爱 `糖果`,欢迎加入[Harmony Candies](https://github.com/HarmonyCandies),一起生产可爱的Harmony小糖果[QQ群:981630644](https://qm.qq.com/q/ajfsyk2RcA)

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

![candies.png](https://forums-obs.openharmony.c ... 7a6y9y9c09u5n13.png "candies.png")

![1733138911920.jpg](https://forums-obs.openharmony.c ... qeczcu65ccvz675.jpg "1733138911920.jpg")

[/md]




欢迎光临 OpenHarmony开发者论坛 (https://forums.openharmony.cn/) Powered by Discuz! X3.5