HarmonyOS 鸿蒙Next中属性字符串与富文本组件的兼容性问题

HarmonyOS 鸿蒙Next中属性字符串与富文本组件的兼容性问题 【问题描述】:在富文本组件RichEditor内使用属性字符串StyledString管理image组件,会导致富文本组件避让软键盘弹出时,图片image组件和文字text组件不同步,图片image组件会有一个抖动的过渡动画。

【问题现象】:属性字符串模式和span模式都尝试过了,只要是图片image组件被属性字符串管理,在避让键盘弹出时就会出现该现象。

【版本信息】:DevEco Studio 6.1.0 Release、API23

需求:属性字符串与富文本组件两者兼容,不会出现抖动动画问题

13 回复

MutableStyledString+ImageAttachment试试呢。

import { image } from '@kit.ImageKit';
import { LengthMetrics } from '@kit.ArkUI';

@Entry
@Component
struct StyledStringImageAttachment {
  richController: RichEditorStyledStringController = new RichEditorStyledStringController();
  richOptions: RichEditorStyledStringOptions = { controller: this.richController };

  private async getPixmapFromMedia(resource: Resource) {
    let unit8Array = await this.getUIContext().getHostContext()?.resourceManager?.getMediaContent(resource.id);
    let imageSource = image.createImageSource(unit8Array?.buffer?.slice(0, unit8Array?.buffer?.byteLength));
    let createPixelMap: image.PixelMap = await imageSource.createPixelMap({
      desiredPixelFormat: image.PixelMapFormat.RGBA_8888
    });
    await imageSource.release();
    return createPixelMap;
  }

  leadingMarginValue: ParagraphStyle = new ParagraphStyle({ leadingMargin: LengthMetrics.vp(5) });
  lineHeightStyle1: LineHeightStyle = new LineHeightStyle(new LengthMetrics(24));
  boldTextStyle: TextStyle = new TextStyle({ fontWeight: FontWeight.Bold });
  paragraphStyledString: MutableStyledString =
    new MutableStyledString('可变文本 可变文本 可变文本 可变文本 可变文本', [
      {
        start: 0,
        length: 28,
        styledKey: StyledStringKey.PARAGRAPH_STYLE,
        styledValue: this.leadingMarginValue
      },
      {
        start: 14,
        length: 9,
        styledKey: StyledStringKey.FONT,
        styledValue: new TextStyle({ fontSize: LengthMetrics.vp(14), fontColor: '#ffd73d3d' })
      },
      {
        start: 24,
        length: 4,
        styledKey: StyledStringKey.FONT,
        styledValue: new TextStyle({ fontSize: LengthMetrics.vp(14), fontWeight: FontWeight.Lighter })
      },
      {
        start: 11,
        length: 4,
        styledKey: StyledStringKey.LINE_HEIGHT,
        styledValue: this.lineHeightStyle1
      }
    ]);

  build() {
    Column({ space: 12 }) {
      Row() {
        Column({ space: 10 }) {
          Blank().layoutWeight(1)
          RichEditor(this.richOptions)
            .backgroundColor('#999999')
            .onReady(async () => {
              let imgPixelMap = await this.getPixmapFromMedia($r('app.media.startIcon'));
              if (imgPixelMap) {
                let mutableStr = new MutableStyledString(new ImageAttachment({
                  value: imgPixelMap,
                  size: { width: 180, height: 160 },
                  verticalAlign: ImageSpanAlignment.BASELINE,
                  objectFit: ImageFit.Fill
                }));
                mutableStr.appendStyledString(this.paragraphStyledString);
                this.richController.setStyledString(mutableStr);
              }
            })
            .width('100%')
            .height(200)
        }
        .width('100%')
      }
      .height('100%')
      .backgroundColor('#F8F8FF')
    }
  }
}

更多关于HarmonyOS 鸿蒙Next中属性字符串与富文本组件的兼容性问题的实战系列教程也可以访问 https://www.itying.com/category-93-b0.html


大意了,没看清你代码,你都没使用键盘避让,当然没复现出问题

自定义键盘避让么

import { image } from '@kit.ImageKit';
import { LengthMetrics } from '@kit.ArkUI';

@Entry
@Component
struct StyledStringImageAttachment {
  richController: RichEditorStyledStringController = new RichEditorStyledStringController();
  richOptions: RichEditorStyledStringOptions = { controller: this.richController };

  private async getPixmapFromMedia(resource: Resource) {
    let unit8Array = await this.getUIContext().getHostContext()?.resourceManager?.getMediaContent(resource.id);
    let imageSource = image.createImageSource(unit8Array?.buffer?.slice(0, unit8Array?.buffer?.byteLength));
    let createPixelMap: image.PixelMap = await imageSource.createPixelMap({
      desiredPixelFormat: image.PixelMapFormat.RGBA_8888
    });
    await imageSource.release();
    return createPixelMap;
  }

  leadingMarginValue: ParagraphStyle = new ParagraphStyle({ leadingMargin: LengthMetrics.vp(5) });
  lineHeightStyle1: LineHeightStyle = new LineHeightStyle(new LengthMetrics(24));
  boldTextStyle: TextStyle = new TextStyle({ fontWeight: FontWeight.Bold });
  paragraphStyledString: MutableStyledString =
    new MutableStyledString('可变文本 可变文本 可变文本 可变文本 可变文本', [
      {
        start: 0,
        length: 28,
        styledKey: StyledStringKey.PARAGRAPH_STYLE,
        styledValue: this.leadingMarginValue
      },
      {
        start: 14,
        length: 9,
        styledKey: StyledStringKey.FONT,
        styledValue: new TextStyle({ fontSize: LengthMetrics.vp(14), fontColor: '#ffd73d3d' })
      },
      {
        start: 24,
        length: 4,
        styledKey: StyledStringKey.FONT,
        styledValue: new TextStyle({ fontSize: LengthMetrics.vp(14), fontWeight: FontWeight.Lighter })
      },
      {
        start: 11,
        length: 4,
        styledKey: StyledStringKey.LINE_HEIGHT,
        styledValue: this.lineHeightStyle1
      }
    ]);

  @State height1: string | number = '80%';
  @State height2: number = 100;
  @State supportAvoidance: boolean = true;
  // 自定义键盘组件
  @Builder
  CustomKeyboardBuilder() {
    Column() {
      Row() {
        Button('增加特表情包').onClick(() => {

        })
      }

      Grid() {
        ForEach(['1', '2', '3', '4', '5', '6', '7', '8', '9', '*', '0', '#'], (item: string) => {
          GridItem() {
            Button(item).width(110).onClick(() => {

            })
          }
        })
      }.maxCount(3).columnsGap(10).rowsGap(10).padding(5)
    }.backgroundColor(Color.Gray)
  }

  build() {
    Column({ space: 12 }) {
      Row() {
        Column({ space: 10 }) {
          Blank().layoutWeight(1)
          RichEditor(this.richOptions)
            .backgroundColor('#999999')
            .onReady(async () => {
              let imgPixelMap = await this.getPixmapFromMedia($r('app.media.startIcon'));
              if (imgPixelMap) {
                let mutableStr = new MutableStyledString(new ImageAttachment({
                  value: imgPixelMap,
                  size: { width: 180, height: 160 },
                  verticalAlign: ImageSpanAlignment.BASELINE,
                  objectFit: ImageFit.Fill
                }));
                mutableStr.appendStyledString(this.paragraphStyledString);
                this.richController.setStyledString(mutableStr);
              }
            })
            .customKeyboard(this.CustomKeyboardBuilder(), { supportAvoidance: this.supportAvoidance })
            .width('100%')
            .height(200)
        }
        .width('100%')
      }
      .height('100%')
      .backgroundColor('#F8F8FF')
    }
  }
}

你把你那个用来占位的Blank().layoutWeight(1)删掉,再把你的富文本组件内的内容加一些,差不多够占满屏幕的时候,再点击键盘,这个时候,就能触发我所说的:富文本组件内容避让键盘弹出时触发的图片动画问题

您好,可参考以下demo,文本和图片同步效果是否满足您的需求

import { image } from '@kit.ImageKit';
import { drawing } from '@kit.ArkGraphics2D';
import { DrawContext } from '@kit.ArkUI';
import { KeyboardAvoidMode } from '@kit.ArkUI';

// 参照示例6创建CustomSpan,在onDraw中绘制PixelMap
class PixelMapCustomSpan extends CustomSpan {
  pixelMap: image.PixelMap | null = null;
  spanWidth: number = 0;
  spanHeight: number = 0;
  gUIContext: UIContext | undefined = undefined;

  constructor(gUIContext: UIContext, pixelMap: image.PixelMap, width: number, height: number) {
    super();
    this.gUIContext = gUIContext;
    this.pixelMap = pixelMap;
    this.spanWidth = width;
    this.spanHeight = height;
  }

  onMeasure(measureInfo: CustomSpanMeasureInfo): CustomSpanMetrics {
    return { width: this.spanWidth, height: this.spanHeight };
  }

  // 参照示例6的onDraw签名:onDraw(context: DrawContext, drawInfo: CustomSpanDrawInfo)
  // drawInfo.x:Span相对于挂载组件的x偏移;drawInfo.lineTop:Span相对于组件的上边距
  // 将drawInfo.x和drawInfo.lineTop作为drawImage的left和top入参,保证PixelMap随RichEditor滚动
  onDraw(context: DrawContext, drawInfo: CustomSpanDrawInfo): void {
    if (this.pixelMap === null || this.gUIContext === undefined) {
      return;
    }
    const canvas = context.canvas;
    const samplingOptions = new drawing.SamplingOptions();
    // drawImage只支持3-4个参数(pixelMap,left,top,samplingOptions?),无宽高参数
    // 通过canvas变换实现缩放:先平移到Span位置,再缩放使PixelMap填满Span占位区域
    const imageInfo = this.pixelMap.getImageInfoSync();
    const pxWidth = this.gUIContext.vp2px(this.spanWidth);
    const pxHeight = this.gUIContext.vp2px(this.spanHeight);
    const scaleX = pxWidth / imageInfo.size.width;
    const scaleY = pxHeight / imageInfo.size.height;
    canvas.save();
    canvas.translate(drawInfo.x, drawInfo.lineTop);
    canvas.scale(scaleX, scaleY);
    canvas.drawImage(this.pixelMap, 0, 0, samplingOptions);
    canvas.restore();
  }
}

@Entry
@Component
struct CustomSpanPixelMapDemo {
  richController: RichEditorStyledStringController = new RichEditorStyledStringController();
  richOptions: RichEditorStyledStringOptions = { controller: this.richController };
  pixelMap: image.PixelMap | null = null;
  isPageShow: boolean = true;

  // 参照示例4的getPixmapFromMedia创建PixelMap
  private async getPixmapFromMedia(resource: Resource): Promise<image.PixelMap> {
    let unit8Array = await this.getUIContext().getHostContext()?.resourceManager?.getMediaContent(resource.id);
    let imageSource = image.createImageSource(unit8Array?.buffer?.slice(0, unit8Array?.buffer?.byteLength));
    let createPixelMap: image.PixelMap = await imageSource.createPixelMap({
      desiredPixelFormat: image.PixelMapFormat.RGBA_8888
    });
    await imageSource.release();
    return createPixelMap;
  }

  aboutToAppear(): void {
    this.getUIContext().setKeyboardAvoidMode(KeyboardAvoidMode.RESIZE_WITH_CARET)
  }

  async onPageShow() {
    if (!this.isPageShow) {
      return;
    }
    this.isPageShow = false;

    // 参照示例4通过资源创建PixelMap
    this.pixelMap = await this.getPixmapFromMedia($r('app.media.startIcon'));

    // 参照示例6创建CustomSpan并组装属性字符串
    const customSpan = new PixelMapCustomSpan(this.getUIContext(), this.pixelMap!, 180, 160);
    const styledString = new MutableStyledString(customSpan);
    styledString.appendStyledString(new MutableStyledString('树阴照水爱晴柔。小荷才露尖尖角,早有蜻蜓立上头。春日[作者树阴照水爱晴柔。小荷才露尖尖角,早有蜻蜓立上头。春日[作者。树阴照水爱晴柔。小荷才露尖尖角,早有蜻蜓立上头。春日[作者] 朱熹 [朝代] 宋胜日寻芳泗水滨,无边光景一时新。等闲识得东风面,万紫千红总是春。咏柳[作者] 贺知章 [朝代] 唐碧玉妆成一树高,万条垂下绿丝绦。不知细叶谁裁出,二月春风似剪刀。木兰诗 / 木兰辞[作者] 佚名 [朝代] 南北朝唧唧复唧唧,木兰当户织。不闻机杼声,唯闻女叹息。问女何所思,问女何所忆。女亦无所思,女亦无所忆。昨夜见军帖,可汗大点兵,军书十二卷,卷卷有爷名。阿爷无大儿,木兰无长兄,愿为市鞍马,从此替爷征。东市买骏马,西市买鞍鞯...元日[作者] 王安石 [朝代] 宋爆竹声中一岁除,春风送暖入屠苏。千门万户曈曈日,总把新桃换旧符。春望[作者] 杜甫 [朝代] 唐国破山河在,城春草木深。感时花溅泪,恨别鸟惊心。烽火连三月,家书抵万金。白头搔更短,浑欲不胜簪。望庐山瀑布[作者] 李白 [朝代] 唐日照香炉生紫烟,遥看瀑布挂前川。飞流直下三千尺,疑是银河落九天。清明[作者] 杜牧 [朝代] 唐清明时节雨纷纷,路上行人欲断魂。借问酒家何处有?牧童遥指杏花村。悯农(其一)[作者] 李绅 [朝代] 唐春种一粒粟,秋收万颗子。四海无闲田,农夫犹饿死。'));

    // 通过RichEditorStyledStringController绑定属性字符串到RichEditor
    this.richController.setStyledString(styledString);
  }

  build() {
    Column({ space: 12 }) {
      Row() {
        Column({ space: 10 }) {
          RichEditor(this.richOptions)
            .backgroundColor('#999999')
            .onReady(async () => {
            })
            .width('100%')
        }
        .width('100%')
      }
      .height('100%')
      .backgroundColor('#F8F8FF')
    }
  }
}

您好,这边使用键盘避让未复现问题,可以提供下复现问题的demo嘛

import { image } from '@kit.ImageKit';
import { LengthMetrics } from '@kit.ArkUI';
import { KeyboardAvoidMode } from '@kit.ArkUI';
@Entry
@Component
struct StyledStringImageAttachment {
  richController: RichEditorStyledStringController = new RichEditorStyledStringController();
  richOptions: RichEditorStyledStringOptions = { controller: this.richController };

  private async getPixmapFromMedia(resource: Resource) {
    let unit8Array = await this.getUIContext().getHostContext()?.resourceManager?.getMediaContent(resource.id);
    let imageSource = image.createImageSource(unit8Array?.buffer?.slice(0, unit8Array?.buffer?.byteLength));
    let createPixelMap: image.PixelMap = await imageSource.createPixelMap({
      desiredPixelFormat: image.PixelMapFormat.RGBA_8888
    });
    await imageSource.release();
    return createPixelMap;
  }

  leadingMarginValue: ParagraphStyle = new ParagraphStyle({ leadingMargin: LengthMetrics.vp(5) });
  lineHeightStyle1: LineHeightStyle = new LineHeightStyle(new LengthMetrics(24));
  boldTextStyle: TextStyle = new TextStyle({ fontWeight: FontWeight.Bold });
  paragraphStyledString: MutableStyledString =
    new MutableStyledString('调用setKeyboardAvoidMode设置键盘避让模式为RESIZE模式,实现键盘抬起时page的压缩效果', [
      {
        start: 0,
        length: 28,
        styledKey: StyledStringKey.PARAGRAPH_STYLE,
        styledValue: this.leadingMarginValue
      },
      {
        start: 14,
        length: 9,
        styledKey: StyledStringKey.FONT,
        styledValue: new TextStyle({ fontSize: LengthMetrics.vp(14), fontColor: '#ff3dd769' })
      },
      {
        start: 24,
        length: 4,
        styledKey: StyledStringKey.FONT,
        styledValue: new TextStyle({ fontSize: LengthMetrics.vp(14), fontWeight: FontWeight.Lighter })
      },
      {
        start: 11,
        length: 4,
        styledKey: StyledStringKey.LINE_HEIGHT,
        styledValue: this.lineHeightStyle1
      }
    ]);
  aboutToAppear(): void {
    this.getUIContext().setKeyboardAvoidMode(KeyboardAvoidMode.RESIZE)
  }
  build() {
    Column({ space: 12 }) {
      Row() {
        Column({ space: 10 }) {
          Blank().layoutWeight(1)
          RichEditor(this.richOptions)
            .backgroundColor('#999999')
            .onReady(async () => {
              let imgPixelMap = await this.getPixmapFromMedia($r('app.media.startIcon'));
              if (imgPixelMap) {
                let mutableStr = new MutableStyledString(new ImageAttachment({
                  value: imgPixelMap,
                  size: { width: 180, height: 160 },
                  verticalAlign: ImageSpanAlignment.BASELINE,
                  objectFit: ImageFit.Fill
                }));
                mutableStr.appendStyledString(this.paragraphStyledString);
                this.richController.setStyledString(mutableStr);
              }
            })
          TextArea()
            .width('100%')
            .borderWidth(1)
            .width('100%')
            .height(200)
        }
        .width('100%')
      }
      .height('100%')
      .backgroundColor('#F8F8FF')
    }
  }
}

补充一个排查角度:这个现象大概率不是 StyledString 写法本身错误,而是 RichEditor 在软键盘弹出时发生避让和重排,文字 Span 与 ImageAttachment 参与布局的节奏不完全一致。可以把它理解成:文字像轻便的纸条,键盘一顶就跟着走;图片像一块较重的小卡片,测量和回位慢半拍,于是看起来会“抖一下”。

建议先做三步验证:第一,只保留纯文本 StyledString,看是否不抖;第二,保留图片但固定宽高、缩小尺寸,看抖动是否减轻;第三,尽量避免让 RichEditor 带着大图一起参与整体 resize,可考虑 overlay/手动 padding 的键盘避让方式。

如果是 IM、评论发布、动态编辑器这类复杂图文输入,比较稳的方案是 RichEditor 只负责文本,图片放到外层列表或宫格单独渲染,编辑区里只保留“图片占位符”。提交时再把文本和图片 URI 组装成结构化数据。这样键盘弹出时不会拖着 ImageAttachment 一起重排,体验会稳定很多。

参考:华为官方 RichEditor 组件文档、StyledString/ImageAttachment 用法,以及 expandSafeArea/KeyboardAvoidMode 相关说明。

RichEditor 文档:https://developer.huawei.com/consumer/cn/doc/harmonyos-references-V14/ts-basic-components-richeditor-V14

键盘避让/安全区文档:https://developer.huawei.com/consumer/cn/doc/harmonyos-references/ts-universal-attributes-expand-safe-area

关闭富文本组件的默认避让,改用外层容器手动避让应该可以吧

抖动的根源在于 RichEditor 默认的避让机制在处理图文混排时动画不同步。我们可以通过将 RichEditor 的避让属性设置为不避让,转而让其外层容器(如 ScrollColumn)来承载软键盘的避让抬升。

// 在页面组件中
@Entry
@Component
struct RichEditorDemo {
  private controller: RichEditorController = new RichEditorController();
  
  build() {
    Scroll() { // 使用外层 Scroll 容器
      Column() {
        RichEditor({ controller: this.controller })
          .width('100%')
          .height('100%')
          // 关闭 RichEditor 自身的键盘避让行为,防止其内部图文测量冲突
          .expandSafeArea([UserTrack.KEYBOARD]) 
      }
    }
    .width('100%')
    .height('100%')
  }
}

这个抖动问题的核心是布局时序不匹配,是API21+版本RichEditor的已知兼容性问题:

  1. 软键盘弹出触发窗口resize后,RichEditor会先更新可布局高度,文字是同步完成排版的;
  2. StyledString内部管理的Image组件默认是异步加载、异步计算宽高占位,文字排版完成后,图片才会完成位置二次更新,两次布局的时间差就表现为图片抖动、和文字不同步;
  3. API23初始版本的RichEditor底层布局时序存在bug,避让软键盘时不会主动同步StyledString内部图片的布局,进一步放大了这个问题。

这个问题本质上其实是:

RichEditor + StyledString(ImageSpan)
在键盘避让阶段的布局同步问题。

不是你业务代码的问题。

目前 HarmonyOS API23 下:

RichEditor 对图片 Span 的布局刷新
和 Text Span 并不是同一套节奏。

所以:

软键盘弹出时:

  • Text 会立即跟随 RichEditor 重排
  • ImageSpan 会走单独的异步测量/动画流程

最终就会看到:

图片“抖一下”

或者:

图片有一个位移动画过渡

尤其:

  • StyledString
  • ImageAttachment
  • 属性字符串模式

更容易出现。

——————————

你这里提到:

<span>模式也有</span>

其实进一步说明:

问题不在:

StyledString 本身

而在:

RichEditor 内部对图片节点的排版实现。

——————————

目前 HarmonyOS 富文本里:

文字:

属于轻量 Inline Layout

但:

图片:

更接近嵌入式组件节点

很多情况下:

ImageSpan 并不是完全 inline。

所以:

键盘避让时:

图片会二次布局

尤其:

  • 页面 resize
  • Window avoid area 更新
  • RichEditor relayout

时最明显。

——————————

这个问题目前业内有几个常见现象:

键盘弹出:

图片慢半拍

图片位置先错位再归位

图片有 Translate 动画感

大图尤其明显

多个图片 Span 更明显

——————————

目前看:

这是:

RichEditor 内部实现问题

不是单纯 API 使用问题。

——————————

现阶段可行规避方案:

——————————

方案1(最有效)

避免:

直接在 RichEditor 内嵌 ImageSpan

改成:

图文分层

例如:

  • RichEditor 只负责文字
  • 图片单独组件渲染

类似:

微信输入框那种思路。

这是目前最稳方案。

——————————

方案2

降低图片尺寸。

因为:

ImageSpan 越大
布局重排越明显

尤其:

  • 宽图
  • 高分辨率图

更容易抖。

——————————

方案3

关闭/减少键盘避让动画。

例如:

不要:

resize 模式

改:

overlay 模式

或者:

手动处理安全区。

因为:

真正触发问题的是:

窗口高度变化

不是键盘本身。

——————————

方案4

输入阶段:

临时隐藏图片 Span。

键盘稳定后再恢复。

很多 IM 编辑器会这么干。

虽然不优雅,但能消除抖动。

——————————

方案5

不要使用:

StyledString + ImageAttachment

而是:

纯 Span Tree

虽然你说也有问题。

但:

实际:

StyledString 模式通常更明显

因为:

内部会多一次属性解析。

——————————

方案6(高级方案)

完全放弃 RichEditor。

自己做:

自定义富文本编辑器

现在很多大型项目:

  • IM
  • 评论
  • 动态发布

最后都这么干。

因为:

目前 RichEditor:

在复杂交互场景还不够成熟。

——————————

尤其:

你如果要做:

  • @人
  • 图片
  • 表情
  • 卡片
  • 混排
  • 光标控制
  • Span原子删除

后面还会遇到更多问题。

——————————

目前我个人建议:

如果是:

轻富文本

可以继续 RichEditor。

但:

如果是:

复杂 IM / 动态编辑器

建议尽早:

自研编辑器架构

不要过度绑定 RichEditor。

——————————

最后总结一下:

你这个:

ImageSpan 键盘避让抖动

目前更像:

HarmonyOS RichEditor 的已知布局同步问题

不是:

StyledString 使用错误。

短期内:

最现实方案是:

避免图片真正以内联Span形式参与键盘避让布局。

否则:

现阶段 API23 下很难彻底消除这个动画抖动。

咱就是说, 你有没有考虑过用三方库呢 ??

在鸿蒙Next中,AttributeString 的部分Span(如自定义绘制、图片Span)不被 RichText 组件直接支持,导致样式丢失或渲染异常。建议使用 Text 组件配合 Span 容器,或改用系统预置的Span类型确保兼容。

该问题为当前API 23的已知局限性:RichEditor在处理StyledString内的Image时,软键盘避让触发布局重绘,但Image与Text的排版更新并不同步,导致Image出现独立的位移过渡动画(视觉表现为抖动)。目前没有直接的属性字符串兼容方案。可用的避规方法:在页面级监听软键盘高度变化(如window.getLastAvoidArea),手动计算偏移量并整体平移RichEditor或其父容器,代替系统默认的局部避让行为,使图片与文字保持同步移动。若业务允许,也可将图片独立置于RichEditor外部,通过位置对齐模拟内嵌效果。建议关注后续系统版本对该动画时序的修复。

回到顶部