HarmonyOS社交通讯应用开发19 : 文本编辑区 EditorComponent
文本编辑区 EditorComponent
引言
发布页的媒体区下面是文本编辑区——EditorComponent。它只做两件事,但都做得很有讲究:
- 编辑:一个
TextInput输标题,一个TextArea输正文,输入内容实时同步到 AppStorage 全局状态; - 拖拽接收:支持把外部(甚至其他设备)拖来的纯文本直接落到标题或正文里。
看似简单的两个输入框,实际承担着"全局状态同步"与"跨设备文字互通"两个职责。本篇围绕这两个职责展开:先讲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/TextInputController的caretPosition与文本拼接实现,复杂度会明显上升——本项目没有这么做,我们也不展开。
AppStorage 双写:为什么 onChange 里还要 set 一次
细心的话会发现一个"冗余":@StorageLink双向绑定已经会把this.mainTitle = ...写回 AppStorage,为什么onChange里还要显式AppStorage.set('mainTitle', mainTitle)?这要从@StorageLink的时序说起:onChange回调发生时,@StorageLink的自动回写基于"状态变量赋值"这一动作触发;直接AppStorage.set则是绕过组件状态、直达全局存储。两者叠加的意义在于:
- 解耦时序:即使将来把标题改成
@Prop(单向)或普通成员变量,AppStorage.set仍能保证全局数据同步不中断; - 明确契约:读者一眼就能看出"这个输入框的值会进 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用最朴素的两个输入框,示范了三个高级主题:
- 全局状态同步:
@StorageLink+AppStorage.set双保险,让标题/正文随时可被接续打包; - 拖拽文本接收:
draggable+allowDrop(PLAIN_TEXT)+onDrop+getDataFromUdmf重试封装,一套组合拳把"别处拖来的文字落进输入框"做到可靠; - 键盘与布局:
onFocus/onBlur上报键盘状态、expandSafeArea([SafeAreaType.KEYBOARD])避让、layoutWeight(1)弹性填满。
如果要用一句话记住本文,那就是:EditorComponent 是"声明式思维"的最小完整样本——不操作组件实例、不监听原生事件,只用状态(@StorageLink)和声明(allowDrop/expandSafeArea/layoutWeight)就表达完了全部交互逻辑。初学者写编辑类页面时,可以对照本文检查三件事:输入内容是否进了全局状态?拖拽接收是否用声明式过滤?键盘避让是否分区域处理?三问都对,体验基本不会差。
至此,发布页的"上(媒体)中(文本)"两层已就位,下一篇分析最下方的BottomToolbar——位置添加与图标工具栏。
(本文引用源码:entry/src/main/ets/view/contentEditor/EditorComponent.ets、entry/src/main/ets/constants/CommonConstants.ets)
