news 2026/8/25 6:36:02

HarmonyOS社交通讯应用开发19 : 文本编辑区 EditorComponent

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
HarmonyOS社交通讯应用开发19 : 文本编辑区 EditorComponent

文本编辑区 EditorComponent

引言

发布页的媒体区下面是文本编辑区——EditorComponent。它只做两件事,但都做得很有讲究:

  1. 编辑:一个TextInput输标题,一个TextArea输正文,输入内容实时同步到 AppStorage 全局状态;
  2. 拖拽接收:支持把外部(甚至其他设备)拖来的纯文本直接落到标题或正文里。

看似简单的两个输入框,实际承担着"全局状态同步"与"跨设备文字互通"两个职责。本篇围绕这两个职责展开:先讲TextInput/TextArea与状态同步,再讲拖拽三件套(draggable/allowDrop/onDrop)与 UDMF 数据读取,最后看键盘安全区的配合。

知识点讲解

TextInput 与 TextArea

ArkUI 的两个文本输入组件,本项目这样分工:

  • TextInput:单行输入框,用于标题。placeholder显示占位提示("Add a title to be more likely to recommend (optional)"),text是受控文本。
  • TextArea:多行输入框,用于正文,可换行、可滚动。

两者都提供.onChange(callback),在内容变化时回调最新文本。这里"受控"的含义是:输入框显示什么由text参数决定,而text又绑定在状态上——用户每敲一个字,onChange把新值写回状态,状态变化又驱动输入框刷新。这个"状态 → 视图 → 状态"的闭环,是 ArkUI 声明式开发的核心心智模型:开发者不直接操作组件,只操作数据

AppStorage 双向同步

EditorComponent@StorageLink绑定全局状态:

@StorageLink('mainTitle')mainTitle: string ='';@StorageLink('textContent')textContent: string ='';

@StorageLink是双向的:组件内赋值会写回 AppStorage;AppStorage 被外部修改(比如接续恢复数据时)组件会自动刷新。而onChange里那句AppStorage.set('mainTitle', mainTitle)看似冗余,实则是显式加固——即使某些时序下@StorageLink的自动回写未生效,全局存储也必然拿到最新值。

UDMF 与纯文本拖拽

UDMF(Unified Data Management Framework,统一数据管理框架)是鸿蒙跨应用、跨设备共享数据的标准:拖拽时数据被封装成UnifiedData,内部是若干UnifiedRecord(记录),每条记录有类型标识。纯文本对应uniformTypeDescriptor.UniformDataType.PLAIN_TEXT,落地后可用unifiedDataChannel.PlainText.textContent取到字符串。

拖拽接收的三个属性:

  • .draggable(true):组件可作为拖拽源(可被拖出);
  • .allowDrop([类型列表]):声明组件可接收哪些类型的数据;
  • .onDrop(callback):数据落入时触发,参数是DragEvent

getDataFromUdmf:带重试的取数封装

DragEvent.getData()在拖拽刚落下时,数据可能尚未就绪(跨进程/跨设备传输有延迟),首次调用可能拿到空。EditorComponent(以及AddMedia)封装了一个"重试一次"的工具方法:

getDataFromUdmf(event: DragEvent,callback: (data: DragEvent)=> void) {if(this.getDataFromUdmfRetry(event,callback)) { return;// 第一次就成功}// 1.5 秒后重试一次setTimeout(()=> { this.getDataFromUdmfRetry(event,callback); },1500); }

getDataFromUdmfRetry内部 try/catch 尝试event.getData(),能取到且记录非空才回调并返回 true。这套"先试、失败再等 1.5s 补一次"的模式,是拖拽场景下的常见稳健写法。两个细节值得学:一是getData()可能抛异常(不只是返回空),所以必须 try/catch;二是"先试一次、1.5 秒后再补一次"的重试窗口是经验值——太短数据没传完,太长用户感知卡顿。这套封装在 AddMedia 与 EditorComponent 两个组件里各写了一份,属于"小工具就地复制"的务实取舍。

拖拽事件流与 allowDrop 守门

一次完整的拖放会依次触发一串事件:onDragStart(拖起)→onDragEnter(进入目标)→onDragMove(在目标内移动)→onDrop(放下)→onDragEnd(结束)。本项目只实现了onDrop——对"接收文字"这个需求,其他阶段可以全部省略。接收方只需三个配置:.draggable(true)允许组件被拖出、.allowDrop([类型])声明可接收的类型、.onDrop(callback)处理落地数据。

其中allowDrop是"守门员":类型不匹配的数据根本进不了onDrop,系统在拖拽过程中就会用光标样式提示"不可投放"。项目里标题/正文只声明PLAIN_TEXT,所以拖图片进来会被直接拒绝——用声明式过滤代替代码里的类型判断,是更安全的做法

结合本项目源码分析

组件骨架与键盘联动

文件路径:entry/src/main/ets/view/contentEditor/EditorComponent.ets

@Component exportstructEditorComponent { @StorageLink(BreakpointConstants.BREAKPOINT_NAME)currentBreakpoint: WidthBreakpoint = WidthBreakpoint.WIDTH_LG; @StorageLink('mainTitle')mainTitle:string= '';// 标题,全局同步@StorageLink('textContent')textContent:string= '';// 正文,全局同步// 是否显示软键盘(来自父组件 @Link,上一篇已讲)@Link isKeyboard: boolean; @Link isShowLocalInfo: boolean; build(){Flex({direction: FlexDirection.Column }){TextInput({text:this.mainTitle,placeholder: $r('app.string.text_input_placeholder')}) {...}TextArea({text:this.textContent,placeholder: $r('app.string.richEditor_placeholder')}) {...} } .width(CommonConstants.FULL_PERCENT) .height($r('app.integer.flex_input_height'))// 500vp.margin({ bottom:$r('app.integer.flex_input_margin')}) .layoutWeight(CommonConstants.DEFAULT_LAYOUT_WEIGHT)// 占据剩余空间.expandSafeArea([SafeAreaType.KEYBOARD])// 键盘避让.padding({ left: ..., right:...})// 断点响应式 padding} }

.layoutWeight(1)是关键布局手段:在父 Flex 中,媒体区和工具栏高度固定,文本区用权重 1 吃掉所有剩余高度,保证不同屏幕尺寸下页面都"填满"。

标题输入框:编辑 + 拖拽接收

TextInput({text:this.mainTitle,placeholder: $r('app.string.text_input_placeholder') }) .onChange((mainTitle:string) =>{this.mainTitle= mainTitle;AppStorage.set('mainTitle', mainTitle);// 同步到全局,接续时可打包带走}) .onFocus(() =>{this.isKeyboard=true;// 获焦 → 键盘展开}) .onBlur(() =>{this.isKeyboard=false;// 失焦 → 键盘收起}) .width(CommonConstants.FULL_PERCENT) .height($r('app.integer.text_input_height'))// 48vp.fontSize($r('app.integer.text_size_body1'))// 16fp.backgroundColor($r('sys.color.background_primary')) .constraintSize({minHeight: $r('app.integer.text_input_height') }) .margin({top: $r('app.integer.text_input_margin') })// —— 以下为拖拽接收配置 ——.draggable(true)// 标题可被拖出.allowDrop([uniformTypeDescriptor.UniformDataType.PLAIN_TEXT])// 只接收纯文本.onDrop((dragEvent?: DragEvent) =>{// 拖来的文字落进标题this.getDataFromUdmf((dragEventasDragEvent),(event: DragEvent) =>{try{letrecords:Array<unifiedDataChannel.UnifiedRecord> = event.getData().getRecords();letplainText: unifiedDataChannel.PlainText= records[0]asunifiedDataChannel.PlainText;this.mainTitle= plainText.textContent;// 覆盖标题}catch(err) { hilog.error(DOMAIN,TAG,FORMAT,`GetData failed. Cause code:${err.code}, message:${err.message}`); } }) })

要点:allowDrop只声明了PLAIN_TEXT,所以其他类型(图片等)拖到这里会被系统直接拒绝;onDrop里取records[0]并强转PlainText,读textContent覆盖标题。注意this.mainTitle = ...AppStorage的同步——@StorageLink双向绑定会自动写回,无需再手动set

正文输入框:同样的配方

TextArea({text:this.textContent,placeholder: $r('app.string.richEditor_placeholder')}) .width(CommonConstants.FULL_PERCENT) .height(CommonConstants.FULL_PERCENT) .id(CommonConstants.TITLE_ID)// 'titleId':焦点调度目标(见第 15 篇).fontSize($r('app.integer.text_size_body1')) .backgroundColor($r('sys.color.background_primary')) .constraintSize({minHeight: $r('app.integer.text_input_height')}) .margin({ top:$r('app.integer.text_input_margin')}) .onFocus(()=> { this.isKeyboard =true; this.isShowLocalInfo =false;// 开始输入时收起位置列表}) .onBlur(()=> { this.isKeyboard =false; }) .onChange((textContent:string)=> { this.textContent = textContent;AppStorage.set('textContent', textContent);// 同步全局}) .draggable(true) .allowDrop([uniformTypeDescriptor.UniformDataType.PLAIN_TEXT]).onDrop((dragEvent?: DragEvent)=> { this.getDataFromUdmf((dragEventasDragEvent),(event: DragEvent) =>{try{letrecords: Array<unifiedDataChannel.UnifiedRecord> = event.getData().getRecords();letplainText: unifiedDataChannel.PlainText = records[0]asunifiedDataChannel.PlainText; this.textContent = plainText.textContent;// 覆盖正文} catch (err) {...} }) })

正文框比标题多三个细节:id(TITLE_ID)changeFocus调度(上一篇已讲);onFocus顺带收起位置列表;height('100%')撑满剩余空间。

一个值得注意的设计取舍

两个输入框的onDrop都是"整体覆盖"(this.mainTitle = plainText.textContent)而不是"光标处插入"。对示例工程来说,覆盖式写法最简单直观,也足以演示"跨设备拖文字进来"的能力闭环。真实产品若要"插入到光标处",需要借助TextAreaController/TextInputControllercaretPosition与文本拼接实现,复杂度会明显上升——本项目没有这么做,我们也不展开。

AppStorage 双写:为什么 onChange 里还要 set 一次

细心的话会发现一个"冗余":@StorageLink双向绑定已经会把this.mainTitle = ...写回 AppStorage,为什么onChange里还要显式AppStorage.set('mainTitle', mainTitle)?这要从@StorageLink的时序说起:onChange回调发生时,@StorageLink的自动回写基于"状态变量赋值"这一动作触发;直接AppStorage.set则是绕过组件状态、直达全局存储。两者叠加的意义在于:

  1. 解耦时序:即使将来把标题改成@Prop(单向)或普通成员变量,AppStorage.set仍能保证全局数据同步不中断;
  2. 明确契约:读者一眼就能看出"这个输入框的值会进 AppStorage",比隐式的装饰器行为更好理解。

对于接续场景,AppStorage 里的mainTitle/textContent是打包迁移的数据源,双写等于给这条"生命线"上了双保险。这是示例工程里少见的"看似重复、实则有意"的写法,值得揣摩。

布局细节:为什么是 Flex + layoutWeight

正文区高度用CommonConstants.DEFAULT_LAYOUT_WEIGHT(值为 1)配合.layoutWeight(1):在父级 Flex 中,媒体区(96vp 固定高)与工具栏(固定高)先占位,剩余高度全部给文本区。这保证了小屏上文本区被压缩、大屏上文本区被拉伸,输入区域永远"弹性填满"。配合.height($r('app.integer.flex_input_height'))(500vp)作为基准高度与constraintSize({ minHeight: 48 })下限,即使极端窄屏也不会把输入框压没了——"基准 + 弹性 + 下限"三段式布局,是编辑类页面最实用的防变形配方。

与全局状态的联动全景

EditorComponent放进整页看,文本数据的流动是:

用户输入/拖入文字 → onChange / onDrop →@StorageLink自动回写 AppStorage('mainTitle'/'textContent') → (接续时)打包进 ContentInfo → 迁移到目标设备 → (恢复时)AppStorage 值被写入 → 输入框自动刷新

文本区没有把数据私有化在组件内部,而是直接写进全局存储——这正是为"应用接续"服务的:发布到一半的内容,标题、正文、媒体、位置全部在 AppStorage 里,接续时一次性打包带走。

小结

EditorComponent用最朴素的两个输入框,示范了三个高级主题:

  1. 全局状态同步@StorageLink+AppStorage.set双保险,让标题/正文随时可被接续打包;
  2. 拖拽文本接收draggable+allowDrop(PLAIN_TEXT)+onDrop+getDataFromUdmf重试封装,一套组合拳把"别处拖来的文字落进输入框"做到可靠;
  3. 键盘与布局onFocus/onBlur上报键盘状态、expandSafeArea([SafeAreaType.KEYBOARD])避让、layoutWeight(1)弹性填满。

如果要用一句话记住本文,那就是:EditorComponent 是"声明式思维"的最小完整样本——不操作组件实例、不监听原生事件,只用状态(@StorageLink)和声明(allowDrop/expandSafeArea/layoutWeight)就表达完了全部交互逻辑。初学者写编辑类页面时,可以对照本文检查三件事:输入内容是否进了全局状态?拖拽接收是否用声明式过滤?键盘避让是否分区域处理?三问都对,体验基本不会差。

至此,发布页的"上(媒体)中(文本)"两层已就位,下一篇分析最下方的BottomToolbar——位置添加与图标工具栏。

(本文引用源码:entry/src/main/ets/view/contentEditor/EditorComponent.etsentry/src/main/ets/constants/CommonConstants.ets

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/8/25 6:35:58

AI 写代码能直接上线吗?一次 Spring Boot 接口开发的完整验证

适读对象&#xff1a; 使用 AI 生成 Java / Spring Boot 代码、又担心质量和安全性的开发者 本文目标&#xff1a; AI 辅助开发与工程验证 典型场景&#xff1a; 以“创建工单”接口为例&#xff0c;展示从需求澄清、代码草稿到测试、审查与上线门禁的完整过程摘要 很多团队的问…

作者头像 李华
网站建设 2026/8/25 6:34:15

Grasp协议:构建跨工具代码协作的标准化桥梁

你好&#xff0c;我是专注于技术分享的博主。在团队协作开发中&#xff0c;你是否遇到过这样的困境&#xff1a;不同成员使用的IDE插件、代码分析工具或AI助手&#xff08;如Cursor&#xff09;各自为政&#xff0c;数据无法互通&#xff0c;导致信息孤岛和重复劳动&#xff1f…

作者头像 李华
网站建设 2026/8/25 6:31:15

504. Java 反射 - 创建一个简单的依赖注入框架

文章目录504. Java 反射 - 创建一个简单的依赖注入框架1. 什么是依赖注入&#xff08;DI&#xff09;&#xff1f;2. 我们的目标3. 定义注解4. 编写依赖工厂&#xff08;DI 容器&#xff09;5. 编写业务类示例6. 测试依赖注入输出&#xff1a;7. 总结504. Java 反射 - 创建一个…

作者头像 李华
网站建设 2026/8/25 6:28:50

Linux命令-yum(RPM 包管理工具)

Linux命令-yum&#xff08;RPM 包管理工具&#xff09;&#x1f530; 命令简介&#x1f4d6; 语法格式⚙️ 常用选项与子命令全局选项&#x1f4a1; 实战示例1. 软件包安装2. 搜索与查询3. 更新系统与软件包4. 卸载软件包5. 仓库管理6. yum 历史管理7. 依赖分析8. 高级用法⚠️…

作者头像 李华
网站建设 2026/8/25 6:28:15

基于QML的Windows 11风格虚拟键盘:从编译部署到自定义开发全指南

1. 先搞清楚这个开源项目能解决什么实际问题如果你在 Windows 11 上用过触摸屏设备&#xff0c;或者遇到过物理键盘临时失灵的情况&#xff0c;可能会发现系统自带的屏幕键盘启动慢、界面大、自定义选项少。这个基于 QML 开源的 Windows 11 风格屏幕键盘项目&#xff0c;核心就…

作者头像 李华