ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

HarmonyOS社交通讯应用开发18 : 图片视频区 AddMedia 详解

HarmonyOS社交通讯应用开发18 :  图片视频区 AddMedia 详解 图片视频区 AddMedia 详解引言前两篇分别讲了本地选图和跨设备拉取媒体这两条通路最终都汇入同一个组件——AddMedia。它是发布页的图片视频区横向滚动的九宫格、虚线加号、长按菜单复制、点击弹出粘贴、拖拽排序……所有与媒体相关的交互都发生在这个组件里。本文不再重复选图逻辑而是聚焦AddMedia本身的结构设计与交互实现数据从哪来StorageLink(mediaUriArray)全局媒体列表列表怎么展示List ListItem ForEach图片/视频双分支数量上限如何贯穿所有入口9 的上限与各处 toast复制/粘贴菜单如何实现bindMenu PasteButton虚线加号addDefaultPic如何组装。知识点讲解StorageLink让媒体列表全局共享AddMedia的媒体数据声明为StorageLink(mediaUriArray)mediaUriArray: ArrayMediaInfo [];mediaUriArray挂在 AppStorage 的mediaUriArray键上。这意味着无论数据是本地选图、跨设备拉取、剪贴板粘贴还是拖拽落下的图片 push 进来的UI 都会自动刷新将来发布页打包接续时也是直接读这个全局数组。MediaInfo接口entry/src/main/ets/model/ContentInfo.ets有三个关键字段export interface MediaInfo { imagePixelMap?: PixelMap;//图片PixelMap 缩略图图片分支优先判断 mediaName: string;//媒体名展示名或 UUID mediaType: MediaType;//image|videovideoUri?: string;//视频uri视频分支判断 }List 横向滚动 ForEachArkUI 的List默认纵向滚动设置.listDirection(Axis.Horizontal)即横向。ForEach(this.mediaUriArray, (item) {...}, keyGenerator)遍历数据渲染ListItem第三个参数是键生成器——本项目用JSON.stringify(item) index保证每个条目有稳定唯一键避免复用错乱。为什么九宫格用List而不是Grid因为需求是一行横向滚动数量从 0 到 9 动态变化List的懒加载与滚动语义正好契合Grid更擅长多行多列的静态网格。选型原则很简单数据结构决定布局组件。另外ForEach的键生成器不能省略或偷懒用裸index——一旦列表增删错位的键会导致条目复用错误、图片闪烁。本项目用JSON.stringify(item) index把内容 序号都编进键里虽然略重但保证了任何数据变化都能被正确识别。bindMenu长按/点击弹出菜单bindMenu可以把一个Menu或Builder构建的菜单内容绑定到组件上。bindMenu(this.copy(...))表示长按该组件弹出复制菜单bindMenu(isShown, menuContent, options)是带显隐控制的重载第一个参数为 true 时菜单弹出options.onDisappear在菜单消失时回调——本项目用它做粘贴超量的延迟提示。PasteButton安全粘贴控件PasteButton是 HarmonyOS 的安全控件之一它看起来是个按钮但点击后由系统代为执行读取剪贴板动作应用不需要也无法直接读剪贴板从而规避了剪贴板权限。这类安全控件还有保存、麦克风、相机等的共同特征是系统替应用完成敏感操作应用只在回调里拿结果用户对什么时候读了什么有完全知情权。本项目用它在菜单里提供粘贴入口——注意安全控件在某些场景下有使用限制这正是项目同时保留代码级剪贴板读取getPasteDataTest的原因之一系统控件受限时如某些浮窗场景仍有兜底路径。StorageLink 数组增删自动刷新mediaUriArray是StorageLink绑定的数组。当代码执行this.mediaUriArray.push(...)时ArkUI 的响应式系统能感知到数组长度变化并触发依赖该数组的 UIForEach重建——不需要手动 setState 或重新赋值整个数组。这是 ArkUI 状态管理最小粒度刷新的体现。不过要提醒如果直接替换数组的某个下标元素arr[0] x部分版本需要先取出再放回才能触发刷新稳妥的做法是整体this.mediaUriArray [...this.mediaUriArray]——本工程代码统一用push恰好避开了这个坑。结合本项目源码分析组件外壳接收跨设备数据 整体拖拽文件路径entry/src/main/ets/view/contentEditor/AddMedia.ets。build() {Column() {// 跨设备媒体接收器上一篇已讲CollaborationServiceStateDialog({onState: (stateCode:number,bufferType:string,buffer:ArrayBuffer):voidthis.doInsertMedia(stateCode, bufferType, buffer) })this.addMedia();// 图片视频展示区}// 整个区域可拖出且允许接收图片/PixelMap 落下.draggable(true) .allowDrop([uniformTypeDescriptor.UniformDataType.IMAGE, uniformTypeDescriptor.UniformDataType.OPENHARMONY_PIXEL_MAP]) .onDrop((dragEvent?: DragEvent) { ... })// 拖拽图片落入的处理... }draggable(true)让整个媒体区可以被拖起allowDrop声明能接收哪些类型的数据系统 IMAGE 与 PixelMaponDrop处理拖入。跨设备拖拽的完整机制在模块五详解这里只需知道AddMedia 既是媒体展示区也是图片拖放的接盘侠。展示区addMedia BuilderBuilderaddMedia() { Row() {// 横向列表已添加的图片/视频List({ space: CommonConstants.LIST_COMM_SPACE }) { ForEach(this.mediaUriArray, (item: MediaInfo) { ListItem() {if(item.imagePixelMap) {// —— 图片分支 ——Image(item.imagePixelMap) .width($r(app.integer.add_image_area_size))// 96vp 方格子.height($r(app.integer.add_image_area_size)) .borderRadius($r(app.integer.border_radius3)) .visibility(item ? Visibility.Visible : Visibility.None) .draggable(true)// 单张图可拖出.onDragEnd((event) {// 拖出后对方未接收失败则提示if(event.getResult() DragResult.DRAG_FAILED) {this.getUIContext().getPromptAction().showToast({ duration:100, message:Drag failed}); } }) .bindMenu(this.copy(item.imagePixelMap))// 长按 → 复制菜单}elseif(item.videoUri) {// —— 视频分支 ——Video({ src: item.videoUri, posterOptions: { showFirstFrame:true}// 封面显示首帧}) .width($r(app.integer.add_image_area_size)) .height($r(app.integer.add_image_area_size)) .borderRadius($r(app.integer.border_radius3)) .draggable(true) .controls(false)// 隐藏系统控制条格子太小.autoPlay(true)// 自动播放.loop(true)// 循环播放.bindMenu(this.copy(item.videoUri))// 长按 → 复制菜单} } }, (item: MediaInfo, index: number) JSON.stringify(item) index)// 列表末尾追加虚线加号this.addDefaultPic() } .listDirection(Axis.Horizontal)// 横向滚动.scrollBar(BarState.Off)// 隐藏滚动条.width(CommonConstants.FULL_PERCENT) .height(CommonConstants.FULL_PERCENT) } .height($r(app.integer.add_image_area_size))// 96vp.width(CommonConstants.FULL_PERCENT) .alignItems(VerticalAlign.Center) .padding({ left: ..., right: ... })// 断点响应式左右 padding.onClick(() {// 点击媒体区先关再开确保每次点击都弹出粘贴菜单this.pasteShow false;this.pasteShow true; }) .bindMenu(this.pasteShow,this.paste(), { onDisappear: () {// 菜单消失时若曾触发超量提示补一个 toastif(this.toastShow) {this.getUIContext().getPromptAction().showToast({ message: $r(app.string.add_picture_prompt) });this.toastShow false; } } }) }这段代码信息量很大拆开看图片/视频双分支用if (item.imagePixelMap)/else if (item.videoUri)区分两种媒体。图片用Image显示 PixelMap视频用Video组件showFirstFrame显示首帧封面controls(false)隐藏控制条、autoPlay(true)loop(true)让格子里的视频自动循环播放——在 96vp 的小格子里营造轻预览体验。点击弹粘贴菜单onClick里先 false 后 true的写法是刻意为之——如果连续两次点击都保持 true第二次不会重新触发菜单先置 false 再置 true 保证每次点击都能弹出。菜单内容由this.paste()构建onDisappear兜底处理粘贴超量的提示时机。拖动反馈每张图的onDragEnd检查event.getResult()如果接收方返回DRAG_FAILED比如目标不可接收弹 100ms 的 Drag failed toast 提醒用户。复制菜单copy BuilderBuilder copy(data: image.PixelMap|string) {Row(){Text($r(app.string.copy))//Copy .fontSize(16).fontWeight(FontWeight.Medium)} .padding({ left: 16 }) .height(48) .width(224) .onClick((){ this.setPasteDataTest(data);// 写入系统剪贴板}) }长按图片弹出Copy菜单点击后把数据写入剪贴板。setPasteDataTest按数据类型走不同 MIMEasync setPasteDataTest(data:image.PixelMap |string): Promisevoid {letpasteData: pasteboard.PasteData;if(typeof datastring) {// 视频以 URI 文本形式写入剪贴板pasteData pasteboard.createData(pasteboard.MIMETYPE_TEXT_URI,data); }else{// 图片以 PixelMap 形式写入剪贴板pasteData pasteboard.createData(pasteboard.MIMETYPE_PIXELMAP,data); }letsystemPasteBoard: pasteboard.SystemPasteboard pasteboard.getSystemPasteboard(); await systemPasteBoard.setData(pasteData).catch((err: BusinessError) {...}); }这样复制出的数据可以在本应用内粘贴也可以借助跨设备剪贴板在另一台设备上粘贴——这正是跨设备互通的一个缩影。粘贴菜单paste Builder 与 PasteButtonBuilder paste(){ Row(){//系统安全控件点击由系统代为读取剪贴板 PasteButton({ text: PasteDescription.PASTE }).fontSize(16).fontColor(Color.Black).fontWeight(FontWeight.Medium).backgroundColor(Color.White).padding({ right: 176 }).width(100%).onClick(() { this.getPasteDataTest();//读取并处理剪贴板内容 this.pasteShow false;//关闭菜单 }) }.height(48).width(224)}PasteButton点击后由系统读取剪贴板随后应用的onClick里调用getPasteDataTest主动获取剪贴板数据。getPasteDataTest内部分两类处理MIMETYPE_PIXELMAP是图片取出 PixelMap 加入列表、MIMETYPE_TEXT_URI是视频 URI写分布式文件 加入列表若列表已满则置toastShow true由菜单的onDisappear统一弹提示。虚线加号addDefaultPic BuilderBuilder addDefaultPic(){Row(){Column(){// 加号图标系统 SymbolSymbolGlyph($r(sys.symbol.camera_badge_plus)) .fontSize($r(app.integer.add_icon_size))// 26vp.fontColor([$r(sys.color.font_primary)])// 文案随已添加数量三态切换Text(this.mediaUriArray.length 0 ? $r(app.string.add_high_img):// 0 张Add Image(this.mediaUriArray.length CommonConstants.MAX_ADD_MEDIA_NUM ?$r(app.string.continue_add):// 未满 9Continue Add$r(app.string.image_add)))// 已满 9Upper Limit.fontSize($r(app.integer.text_size_body3)) .fontColor($r(app.color.add_image_background)) .margin({ top:$r(app.integer.comm_padding_margin_mini)}) } .justifyContent(FlexAlign.Center).width(CommonConstants.FULL_PERCENT) } .borderStyle(BorderStyle.Dashed)// 虚线边框.borderWidth($r(app.integer.default_border_width)) .borderColor($r(app.color.add_image_border_color)) .borderRadius($r(app.integer.border_radius3)) .width($r(app.integer.add_image_area_size)) .height($r(app.integer.add_image_area_size)) .margin({ right:$r(app.integer.add_default_pic_right), bottom:$r(app.integer.add_default_pic_bottom)}) .bindMenu(this.MyTestMenu())// 点加号 → 弹出本地设备 跨设备菜单}三个细节值得一提三态文案mediaUriArray.length为 0 显示 Add Image首图引导、未满 9 显示 Continue Add鼓励继续加、满 9 显示 Upper Limit明示封顶。一行三元表达式把状态机表达得清清楚楚。虚线加号视觉BorderStyle.Dashed 1vp 虚线边框 圆角配合SymbolGlyph系统图标构成标准的添加占位样式96vp 与图片格子完全一致保证九宫格对齐。入口聚合bindMenu(this.MyTestMenu())把本地设备 跨设备拉取两个入口都挂在这一个加号上菜单内容见上一篇用户心智单一。两个补充通路粘贴与拖拽的数据处理除了复制菜单AddMedia 还有两条加图通路值得扫一眼它们的共同点是把各种来源的数据统一还原成 PixelMap 或 URI。粘贴通路getPasteDataTest从剪贴板读PasteDatagetPrimaryMimeType()判断类型——MIMETYPE_PIXELMAP直接取出getPrimaryPixelMap()加入列表MIMETYPE_TEXT_URI则把 URI 当作视频源writeDistributedFile复制 加入列表。注意它用getRecordCount()和getPrimaryMimeType()先看一眼再决定怎么处理避免对未知类型做错误假设。拖拽通路onDropuri2pixelMap拖入的数据可能是OPENHARMONY_PIXEL_MAP类型的记录跨进程传输的 PixelMap也可能是Image类型只有 imageUri。前者需要按details里的宽高/像素格式信息重建 PixelMap// 从拖拽记录里解析像素图元数据constmWidth: number (pixelMapRecord.details?.width??-1) as number;constmPixelFormat:image.PixelMapFormat (pixelMapRecord.details?.[pixel-format] ??image.PixelMapFormat.UNKNOWN) asimage.PixelMapFormat;// 用这些信息初始化一张新 PixelMapconstopts:image.InitializationOptions { srcPixelFormat: mPixelFormat, pixelFormat: mPixelFormat,size: {width: mWidth,height: mHeight } };constpixelMap awaitimage.createPixelMap(buffer, opts);后者Image记录走uri2pixelMapfileIo.openSync打开 URI →image.createImageSource(fd)解码 → 缩放到 100×100 的小图 → 加入列表。这条打开→解码→缩放的管线与第 16 篇本地选图的编码管线正好互为镜像——一个负责把外部数据变成 PixelMap一个负责把 PixelMap 变成文件字节流。数量上限如何贯穿全局MAX_ADD_MEDIA_NUM 9CommonConstants.ets在每个加媒体入口都有一道闸门形成多层防护selectImage截断超出部分 toastMyTestMenu的本地设备入菜空前校验doInsertMedia跨设备入数据前校验getPasteDataTest粘贴入数据前校验toastShow延迟提示uri2pixelMap拖拽 URI 转图入数据前校验 toastonDrop拖拽 PixelMap入数据前校验 toastaddDefaultPic文案三态 满 9 时菜单仍可弹出但不放行。这种处处设防的写法对演示工程来说略显冗余但对真实产品而言是必要的健壮性——任何一条通路漏了校验都可能让数组突破 9 而撑爆九宫格布局。小结AddMedia是发布页最多功能的子组件它的设计哲学可以概括为**入口多样、出口统一**数据出口统一无论来自本地、远端、剪贴板还是拖拽最终都 push 进mediaUriArray由同一个横向 List 渲染交互出口统一复制、粘贴、拖拽、跨设备最终都产出MediaInfo约束统一MAX_ADD_MEDIA_NUM 9在每条通路上设卡保证九宫格永不超限。对初级开发者建议动手复现时重点体会三点StorageLink数组驱动 UI 刷新的爽快感、bindMenu的两种用法内容绑定/显隐控制、以及PasteButton安全控件与普通按钮在权限语义上的差异。此外AddMedia 还示范了一个组件边界的设计判断它既是数据源选图、粘贴、拖拽、跨设备拉取都汇聚于此又是展示区九宫格还是拖放交互区draggable/allowDrop/onDrop。一个组件同时承担三条职责看起来重但仔细想想这些职责都围绕媒体这一个主题把它们内聚在一起反而让发布页的其他部分文本区、工具栏保持纯净。当多个职责高度耦合于同一份数据时内聚优于拆分——这是组件设计里高内聚的正面案例。下一篇轮到文字区——标题与正文的EditorComponent。本文引用源码entry/src/main/ets/view/contentEditor/AddMedia.ets、entry/src/main/ets/model/ContentInfo.ets、entry/src/main/ets/constants/CommonConstants.ets
返回列表