用開發(fā)18 : 圖片視頻區(qū) AddMedia 詳解)
圖片視頻區(qū) AddMedia 詳解引言前兩篇分別講了本地選圖和跨設(shè)備拉取媒體這兩條通路最終都匯入同一個(gè)組件——AddMedia。它是發(fā)布頁的圖片視頻區(qū)橫向滾動的九宮格、虛線加號、長按菜單復(fù)制、點(diǎn)擊彈出粘貼、拖拽排序……所有與媒體相關(guān)的交互都發(fā)生在這個(gè)組件里。本文不再重復(fù)選圖邏輯而是聚焦AddMedia本身的結(jié)構(gòu)設(shè)計(jì)與交互實(shí)現(xiàn)數(shù)據(jù)從哪來StorageLink(mediaUriArray)全局媒體列表列表怎么展示List ListItem ForEach圖片/視頻雙分支數(shù)量上限如何貫穿所有入口9 的上限與各處 toast復(fù)制/粘貼菜單如何實(shí)現(xiàn)bindMenu PasteButton虛線加號addDefaultPic如何組裝。知識點(diǎn)講解StorageLink讓媒體列表全局共享AddMedia的媒體數(shù)據(jù)聲明為StorageLink(mediaUriArray)mediaUriArray: ArrayMediaInfo [];mediaUriArray掛在 AppStorage 的mediaUriArray鍵上。這意味著無論數(shù)據(jù)是本地選圖、跨設(shè)備拉取、剪貼板粘貼還是拖拽落下的圖片 push 進(jìn)來的UI 都會自動刷新將來發(fā)布頁打包接續(xù)時(shí)也是直接讀這個(gè)全局?jǐn)?shù)組。MediaInfo接口entry/src/main/ets/model/ContentInfo.ets有三個(gè)關(guān)鍵字段export interface MediaInfo { imagePixelMap?: PixelMap;//圖片PixelMap 縮略圖圖片分支優(yōu)先判斷 mediaName: string;//媒體名展示名或 UUID mediaType: MediaType;//image|videovideoUri?: string;//視頻uri視頻分支判斷 }List 橫向滾動 ForEachArkUI 的List默認(rèn)縱向滾動設(shè)置.listDirection(Axis.Horizontal)即橫向。ForEach(this.mediaUriArray, (item) {...}, keyGenerator)遍歷數(shù)據(jù)渲染ListItem第三個(gè)參數(shù)是鍵生成器——本項(xiàng)目用JSON.stringify(item) index保證每個(gè)條目有穩(wěn)定唯一鍵避免復(fù)用錯亂。為什么九宮格用List而不是Grid因?yàn)樾枨笫且恍袡M向滾動數(shù)量從 0 到 9 動態(tài)變化List的懶加載與滾動語義正好契合Grid更擅長多行多列的靜態(tài)網(wǎng)格。選型原則很簡單數(shù)據(jù)結(jié)構(gòu)決定布局組件。另外ForEach的鍵生成器不能省略或偷懶用裸index——一旦列表增刪錯位的鍵會導(dǎo)致條目復(fù)用錯誤、圖片閃爍。本項(xiàng)目用JSON.stringify(item) index把內(nèi)容 序號都編進(jìn)鍵里雖然略重但保證了任何數(shù)據(jù)變化都能被正確識別。bindMenu長按/點(diǎn)擊彈出菜單bindMenu可以把一個(gè)Menu或Builder構(gòu)建的菜單內(nèi)容綁定到組件上。bindMenu(this.copy(...))表示長按該組件彈出復(fù)制菜單bindMenu(isShown, menuContent, options)是帶顯隱控制的重載第一個(gè)參數(shù)為 true 時(shí)菜單彈出options.onDisappear在菜單消失時(shí)回調(diào)——本項(xiàng)目用它做粘貼超量的延遲提示。PasteButton安全粘貼控件PasteButton是 HarmonyOS 的安全控件之一它看起來是個(gè)按鈕但點(diǎn)擊后由系統(tǒng)代為執(zhí)行讀取剪貼板動作應(yīng)用不需要也無法直接讀剪貼板從而規(guī)避了剪貼板權(quán)限。這類安全控件還有保存、麥克風(fēng)、相機(jī)等的共同特征是系統(tǒng)替應(yīng)用完成敏感操作應(yīng)用只在回調(diào)里拿結(jié)果用戶對什么時(shí)候讀了什么有完全知情權(quán)。本項(xiàng)目用它在菜單里提供粘貼入口——注意安全控件在某些場景下有使用限制這正是項(xiàng)目同時(shí)保留代碼級剪貼板讀取getPasteDataTest的原因之一系統(tǒng)控件受限時(shí)如某些浮窗場景仍有兜底路徑。StorageLink 數(shù)組增刪自動刷新mediaUriArray是StorageLink綁定的數(shù)組。當(dāng)代碼執(zhí)行this.mediaUriArray.push(...)時(shí)ArkUI 的響應(yīng)式系統(tǒng)能感知到數(shù)組長度變化并觸發(fā)依賴該數(shù)組的 UIForEach重建——不需要手動 setState 或重新賦值整個(gè)數(shù)組。這是 ArkUI 狀態(tài)管理最小粒度刷新的體現(xiàn)。不過要提醒如果直接替換數(shù)組的某個(gè)下標(biāo)元素arr[0] x部分版本需要先取出再放回才能觸發(fā)刷新穩(wěn)妥的做法是整體this.mediaUriArray [...this.mediaUriArray]——本工程代碼統(tǒng)一用push恰好避開了這個(gè)坑。結(jié)合本項(xiàng)目源碼分析組件外殼接收跨設(shè)備數(shù)據(jù) 整體拖拽文件路徑entry/src/main/ets/view/contentEditor/AddMedia.ets。build() {Column() {// 跨設(shè)備媒體接收器上一篇已講CollaborationServiceStateDialog({onState: (stateCode:number,bufferType:string,buffer:ArrayBuffer):voidthis.doInsertMedia(stateCode, bufferType, buffer) })this.addMedia();// 圖片視頻展示區(qū)}// 整個(gè)區(qū)域可拖出且允許接收圖片/PixelMap 落下.draggable(true) .allowDrop([uniformTypeDescriptor.UniformDataType.IMAGE, uniformTypeDescriptor.UniformDataType.OPENHARMONY_PIXEL_MAP]) .onDrop((dragEvent?: DragEvent) { ... })// 拖拽圖片落入的處理... }draggable(true)讓整個(gè)媒體區(qū)可以被拖起allowDrop聲明能接收哪些類型的數(shù)據(jù)系統(tǒng) IMAGE 與 PixelMaponDrop處理拖入??缭O(shè)備拖拽的完整機(jī)制在模塊五詳解這里只需知道AddMedia 既是媒體展示區(qū)也是圖片拖放的接盤俠。展示區(qū)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))// 長按 → 復(fù)制菜單}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)// 隱藏系統(tǒng)控制條格子太小.autoPlay(true)// 自動播放.loop(true)// 循環(huán)播放.bindMenu(this.copy(item.videoUri))// 長按 → 復(fù)制菜單} } }, (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: ... })// 斷點(diǎn)響應(yīng)式左右 padding.onClick(() {// 點(diǎn)擊媒體區(qū)先關(guān)再開確保每次點(diǎn)擊都彈出粘貼菜單this.pasteShow false;this.pasteShow true; }) .bindMenu(this.pasteShow,this.paste(), { onDisappear: () {// 菜單消失時(shí)若曾觸發(fā)超量提示補(bǔ)一個(gè) toastif(this.toastShow) {this.getUIContext().getPromptAction().showToast({ message: $r(app.string.add_picture_prompt) });this.toastShow false; } } }) }這段代碼信息量很大拆開看圖片/視頻雙分支用if (item.imagePixelMap)/else if (item.videoUri)區(qū)分兩種媒體。圖片用Image顯示 PixelMap視頻用Video組件showFirstFrame顯示首幀封面controls(false)隱藏控制條、autoPlay(true)loop(true)讓格子里的視頻自動循環(huán)播放——在 96vp 的小格子里營造輕預(yù)覽體驗(yàn)。點(diǎn)擊彈粘貼菜單onClick里先 false 后 true的寫法是刻意為之——如果連續(xù)兩次點(diǎn)擊都保持 true第二次不會重新觸發(fā)菜單先置 false 再置 true 保證每次點(diǎn)擊都能彈出。菜單內(nèi)容由this.paste()構(gòu)建onDisappear兜底處理粘貼超量的提示時(shí)機(jī)。拖動反饋每張圖的onDragEnd檢查event.getResult()如果接收方返回DRAG_FAILED比如目標(biāo)不可接收彈 100ms 的 Drag failed toast 提醒用戶。復(fù)制菜單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);// 寫入系統(tǒng)剪貼板}) }長按圖片彈出Copy菜單點(diǎn)擊后把數(shù)據(jù)寫入剪貼板。setPasteDataTest按數(shù)據(jù)類型走不同 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) {...}); }這樣復(fù)制出的數(shù)據(jù)可以在本應(yīng)用內(nèi)粘貼也可以借助跨設(shè)備剪貼板在另一臺設(shè)備上粘貼——這正是跨設(shè)備互通的一個(gè)縮影。粘貼菜單paste Builder 與 PasteButtonBuilder paste(){ Row(){//系統(tǒng)安全控件點(diǎn)擊由系統(tǒng)代為讀取剪貼板 PasteButton({ text: PasteDescription.PASTE }).fontSize(16).fontColor(Color.Black).fontWeight(FontWeight.Medium).backgroundColor(Color.White).padding({ right: 176 }).width(100%).onClick(() { this.getPasteDataTest();//讀取并處理剪貼板內(nèi)容 this.pasteShow false;//關(guān)閉菜單 }) }.height(48).width(224)}PasteButton點(diǎn)擊后由系統(tǒng)讀取剪貼板隨后應(yīng)用的onClick里調(diào)用getPasteDataTest主動獲取剪貼板數(shù)據(jù)。getPasteDataTest內(nèi)部分兩類處理MIMETYPE_PIXELMAP是圖片取出 PixelMap 加入列表、MIMETYPE_TEXT_URI是視頻 URI寫分布式文件 加入列表若列表已滿則置toastShow true由菜單的onDisappear統(tǒng)一彈提示。虛線加號addDefaultPic BuilderBuilder addDefaultPic(){Row(){Column(){// 加號圖標(biāo)系統(tǒng) SymbolSymbolGlyph($r(sys.symbol.camera_badge_plus)) .fontSize($r(app.integer.add_icon_size))// 26vp.fontColor([$r(sys.color.font_primary)])// 文案隨已添加數(shù)量三態(tài)切換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())// 點(diǎn)加號 → 彈出本地設(shè)備 跨設(shè)備菜單}三個(gè)細(xì)節(jié)值得一提三態(tài)文案mediaUriArray.length為 0 顯示 Add Image首圖引導(dǎo)、未滿 9 顯示 Continue Add鼓勵繼續(xù)加、滿 9 顯示 Upper Limit明示封頂。一行三元表達(dá)式把狀態(tài)機(jī)表達(dá)得清清楚楚。虛線加號視覺BorderStyle.Dashed 1vp 虛線邊框 圓角配合SymbolGlyph系統(tǒng)圖標(biāo)構(gòu)成標(biāo)準(zhǔn)的添加占位樣式96vp 與圖片格子完全一致保證九宮格對齊。入口聚合bindMenu(this.MyTestMenu())把本地設(shè)備 跨設(shè)備拉取兩個(gè)入口都掛在這一個(gè)加號上菜單內(nèi)容見上一篇用戶心智單一。兩個(gè)補(bǔ)充通路粘貼與拖拽的數(shù)據(jù)處理除了復(fù)制菜單AddMedia 還有兩條加圖通路值得掃一眼它們的共同點(diǎn)是把各種來源的數(shù)據(jù)統(tǒng)一還原成 PixelMap 或 URI。粘貼通路getPasteDataTest從剪貼板讀PasteDatagetPrimaryMimeType()判斷類型——MIMETYPE_PIXELMAP直接取出getPrimaryPixelMap()加入列表MIMETYPE_TEXT_URI則把 URI 當(dāng)作視頻源writeDistributedFile復(fù)制 加入列表。注意它用getRecordCount()和getPrimaryMimeType()先看一眼再決定怎么處理避免對未知類型做錯誤假設(shè)。拖拽通路onDropuri2pixelMap拖入的數(shù)據(jù)可能是OPENHARMONY_PIXEL_MAP類型的記錄跨進(jìn)程傳輸?shù)?PixelMap也可能是Image類型只有 imageUri。前者需要按details里的寬高/像素格式信息重建 PixelMap// 從拖拽記錄里解析像素圖元數(shù)據(jù)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 篇本地選圖的編碼管線正好互為鏡像——一個(gè)負(fù)責(zé)把外部數(shù)據(jù)變成 PixelMap一個(gè)負(fù)責(zé)把 PixelMap 變成文件字節(jié)流。數(shù)量上限如何貫穿全局MAX_ADD_MEDIA_NUM 9CommonConstants.ets在每個(gè)加媒體入口都有一道閘門形成多層防護(hù)selectImage截?cái)喑霾糠? toastMyTestMenu的本地設(shè)備入菜空前校驗(yàn)doInsertMedia跨設(shè)備入數(shù)據(jù)前校驗(yàn)getPasteDataTest粘貼入數(shù)據(jù)前校驗(yàn)toastShow延遲提示uri2pixelMap拖拽 URI 轉(zhuǎn)圖入數(shù)據(jù)前校驗(yàn) toastonDrop拖拽 PixelMap入數(shù)據(jù)前校驗(yàn) toastaddDefaultPic文案三態(tài) 滿 9 時(shí)菜單仍可彈出但不放行。這種處處設(shè)防的寫法對演示工程來說略顯冗余但對真實(shí)產(chǎn)品而言是必要的健壯性——任何一條通路漏了校驗(yàn)都可能讓數(shù)組突破 9 而撐爆九宮格布局。小結(jié)AddMedia是發(fā)布頁最多功能的子組件它的設(shè)計(jì)哲學(xué)可以概括為**入口多樣、出口統(tǒng)一**數(shù)據(jù)出口統(tǒng)一無論來自本地、遠(yuǎn)端、剪貼板還是拖拽最終都 push 進(jìn)mediaUriArray由同一個(gè)橫向 List 渲染交互出口統(tǒng)一復(fù)制、粘貼、拖拽、跨設(shè)備最終都產(chǎn)出MediaInfo約束統(tǒng)一MAX_ADD_MEDIA_NUM 9在每條通路上設(shè)卡保證九宮格永不超限。對初級開發(fā)者建議動手復(fù)現(xiàn)時(shí)重點(diǎn)體會三點(diǎn)StorageLink數(shù)組驅(qū)動 UI 刷新的爽快感、bindMenu的兩種用法內(nèi)容綁定/顯隱控制、以及PasteButton安全控件與普通按鈕在權(quán)限語義上的差異。此外AddMedia 還示范了一個(gè)組件邊界的設(shè)計(jì)判斷它既是數(shù)據(jù)源選圖、粘貼、拖拽、跨設(shè)備拉取都匯聚于此又是展示區(qū)九宮格還是拖放交互區(qū)draggable/allowDrop/onDrop。一個(gè)組件同時(shí)承擔(dān)三條職責(zé)看起來重但仔細(xì)想想這些職責(zé)都圍繞媒體這一個(gè)主題把它們內(nèi)聚在一起反而讓發(fā)布頁的其他部分文本區(qū)、工具欄保持純凈。當(dāng)多個(gè)職責(zé)高度耦合于同一份數(shù)據(jù)時(shí)內(nèi)聚優(yōu)于拆分——這是組件設(shè)計(jì)里高內(nèi)聚的正面案例。下一篇輪到文字區(qū)——標(biāo)題與正文的EditorComponent。本文引用源碼entry/src/main/ets/view/contentEditor/AddMedia.ets、entry/src/main/ets/model/ContentInfo.ets、entry/src/main/ets/constants/CommonConstants.ets